From 82ee4ecd6107924c035f7f047390963d4e777e0f Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Thu, 23 Jul 2026 21:46:17 +0800 Subject: [PATCH 01/32] feat: smart package manager selection (#1190) * main * throw if pm isnt installed * throw * nit * changeset * tmv sv ordering * sh is unknwon on windows (execSync know about it) * hint to label & next step * caching agent install, keep none by default --------- Co-authored-by: jycouet --- .changeset/honest-bugs-jam.md | 5 +++ packages/sv/src/cli/add.ts | 14 ++++++-- packages/sv/src/cli/create.ts | 19 +++++++--- packages/sv/src/core/package-manager.ts | 47 ++++++++++++++++++++----- 4 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 .changeset/honest-bugs-jam.md diff --git a/.changeset/honest-bugs-jam.md b/.changeset/honest-bugs-jam.md new file mode 100644 index 000000000..dc67a3205 --- /dev/null +++ b/.changeset/honest-bugs-jam.md @@ -0,0 +1,5 @@ +--- +"sv": patch +--- + +feat: smart(er) package manager selection diff --git a/packages/sv/src/cli/add.ts b/packages/sv/src/cli/add.ts index 783046cd5..7ea9da941 100644 --- a/packages/sv/src/cli/add.ts +++ b/packages/sv/src/cli/add.ts @@ -1,5 +1,5 @@ import * as p from '@clack/prompts'; -import { color } from '@sveltejs/sv-utils'; +import { color, resolveCommandArray } from '@sveltejs/sv-utils'; import { Command } from 'commander'; import * as pkg from 'empathic/package'; import fs from 'node:fs'; @@ -774,13 +774,21 @@ export async function runAddonsApply({ common.buildAndLogArgs(packageManager, 'add', argsFormattedAddons); } + let depsInstalled = true; if (packageManager) { workspace.packageManager = packageManager; - await installDependencies(packageManager, options.cwd); - await formatFiles({ packageManager, cwd: options.cwd, filesToFormat }); + depsInstalled = await installDependencies(packageManager, options.cwd); + if (depsInstalled) { + await formatFiles({ packageManager, cwd: options.cwd, filesToFormat }); + } } const nextSteps = getNextSteps(successfulAddons, workspace, answers, setupResults); + if (packageManager && !depsInstalled) { + nextSteps.unshift( + `Install ${color.command(packageManager)}, then run ${color.command(resolveCommandArray(packageManager, 'install', []))}` + ); + } return { nextSteps, argsFormattedAddons, filesToFormat, successfulAddons, setupResults }; } diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index 67da051de..d4a1ef85b 100644 --- a/packages/sv/src/cli/create.ts +++ b/packages/sv/src/cli/create.ts @@ -132,7 +132,10 @@ export const create = new Command('create') } common.runCommand(async () => { - const { directory, addOnNextSteps, packageManager } = await createProject(cwd, options); + const { directory, addOnNextSteps, packageManager, depsInstalled } = await createProject( + cwd, + options + ); let i = 1; const initialSteps: string[] = ['📁 Project steps', '']; @@ -144,7 +147,10 @@ export const create = new Command('create') ` ${i++}: ${color.command(`cd ${pathHasSpaces ? `"${relative}"` : relative}`)}` ); } - if (!packageManager) { + if (packageManager && !depsInstalled) { + initialSteps.push(` ${i++}: Install ${color.command(pm)}`); + } + if (!packageManager || !depsInstalled) { initialSteps.push(` ${i++}: ${color.command(resolveCommandArray(pm, 'install', []))}`); } @@ -405,12 +411,15 @@ async function createProject(cwd: ProjectPath, options: Options) { const addOnNextSteps = getNextSteps(addOnSuccessfulAddons, workspace, answers, addonSetupResults); addPnpmAllowBuilds(projectPath, packageManager, 'esbuild'); + let depsInstalled = false; if (packageManager) { - await installDependencies(packageManager, projectPath); - await formatFiles({ packageManager, cwd: projectPath, filesToFormat: addOnFilesToFormat }); + depsInstalled = await installDependencies(packageManager, projectPath); + if (depsInstalled) { + await formatFiles({ packageManager, cwd: projectPath, filesToFormat: addOnFilesToFormat }); + } } - return { directory: projectPath, addOnNextSteps, packageManager }; + return { directory: projectPath, addOnNextSteps, packageManager, depsInstalled }; } async function createProjectFromPlayground(url: string, cwd: string): Promise { diff --git a/packages/sv/src/core/package-manager.ts b/packages/sv/src/core/package-manager.ts index 21376bf88..fece9f764 100644 --- a/packages/sv/src/core/package-manager.ts +++ b/packages/sv/src/core/package-manager.ts @@ -13,20 +13,17 @@ import * as find from 'empathic/find'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; -import { exec } from 'tinyexec'; +import { exec, execSync } from 'tinyexec'; export const AGENT_NAMES: AgentName[] = AGENTS.filter( (agent): agent is AgentName => !agent.includes('@') ); -const agentOptions: PackageManagerOptions = AGENT_NAMES.map((pm) => ({ value: pm, label: pm })); -agentOptions.unshift({ label: 'None', value: undefined }); export const installOption: Option = new Option( '--install ', 'installs dependencies with a specified package manager' ).choices(AGENT_NAMES); -type PackageManagerOptions = Array<{ value: AgentName | undefined; label: AgentName | 'None' }>; export async function packageManagerPrompt(cwd: string): Promise { const detected = await detect({ cwd }); const agent = detected?.name ?? getUserAgent(); @@ -35,6 +32,19 @@ export async function packageManagerPrompt(cwd: string): Promise { + const installed = isInstalled(agent); + return { + value: agent, + label: installed ? agent : color.dim(`${agent} (not installed)`), + installed + }; + }).sort((a, b) => Number(b.installed) - Number(a.installed)) + ]; + const pm = await p.select({ message: 'Which package manager do you want to install dependencies with?', options: agentOptions, @@ -48,7 +58,13 @@ export async function packageManagerPrompt(cwd: string): Promise { +/** Returns `false` when the package manager isn't installed and the install was skipped. */ +export async function installDependencies(agent: AgentName, cwd: string): Promise { + if (!isInstalled(agent)) { + p.log.warn(`${color.command(agent)} is not installed, skipping dependency installation.`); + return false; + } + const task = p.taskLog({ title: `Installing dependencies with ${color.command(agent)}...`, limit: Math.ceil(process.stdout.rows / 2), @@ -77,7 +93,7 @@ export async function installDependencies(agent: AgentName, cwd: string): Promis const exitCode = proc.exitCode ?? 0; if (exitCode === 0) { task.success(`Successfully installed dependencies with ${color.command(agent)}`); - return; + return true; } if (agent === 'pnpm' && output.join('\n').includes('ERR_PNPM_IGNORED_BUILDS')) { @@ -85,7 +101,7 @@ export async function installDependencies(agent: AgentName, cwd: string): Promis p.log.warn( `Some build scripts were skipped. Run ${color.command(`${agent} approve-builds`)} to approve them.` ); - return; + return true; } task.error('Failed to install dependencies'); @@ -98,7 +114,7 @@ export async function detectPackageManager(cwd: string): Promise { return detected?.name ?? getUserAgent() ?? 'npm'; } -export function getUserAgent(): AgentName | undefined { +function getUserAgent(): AgentName | undefined { const userAgent = process.env.npm_config_user_agent; if (!userAgent) return undefined; @@ -108,6 +124,21 @@ export function getUserAgent(): AgentName | undefined { return AGENTS.includes(name) ? name : undefined; } +const installedCache = new Map(); +function isInstalled(agent: AgentName): boolean { + let installed = installedCache.get(agent); + if (installed === undefined) { + try { + execSync(agent, ['--version'], { nodeOptions: { stdio: 'ignore' } }); + installed = true; + } catch { + installed = false; + } + installedCache.set(agent, installed); + } + return installed; +} + export function addPnpmAllowBuilds( cwd: string, packageManager: AgentName | null | undefined, From 580c2f7ae34d733393c4e5c185feffe0f1fe9f8e Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:52:27 -0700 Subject: [PATCH 02/32] fix: recognize the nub package manager (#1187) Bump package-manager-detector to ^1.8.0, the first release with nub in its agent and command tables. sv-utils bundles the library at build time, so the published bundle only knows about nub once rebuilt against >=1.8.0. --- .changeset/recognize-nub-package-manager.md | 6 ++++++ packages/migrate/package.json | 2 +- packages/sv-utils/package.json | 2 +- pnpm-lock.yaml | 14 +++++++------- 4 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 .changeset/recognize-nub-package-manager.md diff --git a/.changeset/recognize-nub-package-manager.md b/.changeset/recognize-nub-package-manager.md new file mode 100644 index 000000000..ec0aa0046 --- /dev/null +++ b/.changeset/recognize-nub-package-manager.md @@ -0,0 +1,6 @@ +--- +'@sveltejs/sv-utils': patch +'svelte-migrate': patch +--- + +fix: recognize the `nub` package manager diff --git a/packages/migrate/package.json b/packages/migrate/package.json index 341a04976..edf489696 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -31,7 +31,7 @@ "@clack/prompts": "1.5.0", "import-meta-resolve": "^4.2.0", "magic-string": "^0.30.21", - "package-manager-detector": "^1.6.0", + "package-manager-detector": "^1.8.0", "picocolors": "^1.1.1", "semver": "^7.8.1", "tiny-glob": "^0.2.9", diff --git a/packages/sv-utils/package.json b/packages/sv-utils/package.json index bc25c41f1..b67cd7ea4 100644 --- a/packages/sv-utils/package.json +++ b/packages/sv-utils/package.json @@ -30,7 +30,7 @@ "decircular": "^1.0.0", "dedent": "^1.7.2", "esrap": "^2.2.11", - "package-manager-detector": "^1.6.0", + "package-manager-detector": "^1.8.0", "semver": "^7.8.1", "silver-fleece": "^1.2.1", "smol-toml": "^1.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cae2b8385..0e2759184 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,8 +81,8 @@ importers: specifier: ^0.30.21 version: 0.30.21 package-manager-detector: - specifier: ^1.6.0 - version: 1.6.0 + specifier: ^1.8.0 + version: 1.8.0 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -185,8 +185,8 @@ importers: specifier: ^2.2.11 version: 2.2.11(@typescript-eslint/types@8.60.1) package-manager-detector: - specifier: ^1.6.0 - version: 1.6.0 + specifier: ^1.8.0 + version: 1.8.0 semver: specifier: ^7.8.1 version: 7.8.1 @@ -1837,8 +1837,8 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} parse-imports-exports@0.2.4: resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} @@ -3944,7 +3944,7 @@ snapshots: dependencies: quansync: 0.2.11 - package-manager-detector@1.6.0: {} + package-manager-detector@1.8.0: {} parse-imports-exports@0.2.4: dependencies: From ad6d098343f285cba4005a1b11a24497743cef48 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Sat, 25 Jul 2026 09:28:36 -0700 Subject: [PATCH 03/32] fix: format all created project files so fresh projects pass lint (#1192) When prettier is among the scaffolded add-ons, format the whole project rather than only the add-on-touched files, so a fresh project passes its own lint. Prettier's ignore rules still apply. Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- .changeset/format-created-project-files.md | 5 ++++ packages/sv/src/cli/create.ts | 7 ++++-- packages/sv/src/create/tests/check.ts | 28 ++++++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 .changeset/format-created-project-files.md diff --git a/.changeset/format-created-project-files.md b/.changeset/format-created-project-files.md new file mode 100644 index 000000000..86974f1c9 --- /dev/null +++ b/.changeset/format-created-project-files.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +fix: format all created project files when using the prettier add-on diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index d4a1ef85b..f112a8cc7 100644 --- a/packages/sv/src/cli/create.ts +++ b/packages/sv/src/cli/create.ts @@ -176,7 +176,7 @@ export const create = new Command('create') }) .showHelpAfterError(true); -async function createProject(cwd: ProjectPath, options: Options) { +export async function createProject(cwd: ProjectPath, options: Options) { if (options.fromPlayground) { p.log.warn( 'Svelte maintainers have not reviewed playgrounds for malicious code! Use at your discretion.' @@ -415,7 +415,10 @@ async function createProject(cwd: ProjectPath, options: Options) { if (packageManager) { depsInstalled = await installDependencies(packageManager, projectPath); if (depsInstalled) { - await formatFiles({ packageManager, cwd: projectPath, filesToFormat: addOnFilesToFormat }); + const filesToFormat = addOnSuccessfulAddons.some((addon) => addon.addon.id === 'prettier') + ? ['.'] + : addOnFilesToFormat; + await formatFiles({ packageManager, cwd: projectPath, filesToFormat }); } } diff --git a/packages/sv/src/create/tests/check.ts b/packages/sv/src/create/tests/check.ts index 455a2a3a9..9aa03af48 100644 --- a/packages/sv/src/create/tests/check.ts +++ b/packages/sv/src/create/tests/check.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util'; import { exec } from 'tinyexec'; import { beforeAll, describe, expect, test } from 'vitest'; import { add, officialAddons } from '../../../../sv/src/index.ts'; +import { createProject } from '../../cli/create.ts'; import { type LanguageType, type TemplateType, create } from '../index.ts'; // Resolve the given path relative to the current file @@ -48,8 +49,31 @@ for (const template of templates.filter((t) => t !== 'addon')) { const cwd = path.join(test_workspace_dir, `${template}-${types}`); fs.rmSync(cwd, { recursive: true, force: true }); - create({ cwd, name: `create-svelte-test-${template}-${types}`, template, types }); - await add({ cwd, addons: { eslint: officialAddons.eslint }, options: { eslint: {} } }); + if (template === 'demo' && types === 'typescript') { + const ignoredArtifact = path.join(cwd, 'static', 'ignored.json'); + fs.mkdirSync(path.dirname(ignoredArtifact), { recursive: true }); + fs.writeFileSync(ignoredArtifact, '{"ignored":true}'); + + await createProject(cwd, { + types, + addOns: true, + add: ['prettier', 'eslint'], + install: 'pnpm', + template, + fromPlayground: undefined, + dirCheck: false, + downloadCheck: false + }); + + describe('prettier ignore', () => { + test(`${template}-${types}`, () => { + expect(fs.readFileSync(ignoredArtifact, 'utf-8')).toBe('{"ignored":true}'); + }); + }); + } else { + create({ cwd, name: `create-svelte-test-${template}-${types}`, template, types }); + await add({ cwd, addons: { eslint: officialAddons.eslint }, options: { eslint: {} } }); + } const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8')); From 15e23c8c4726b1b2cbec113ef9f80513de7b6c62 Mon Sep 17 00:00:00 2001 From: "jyc.dev" Date: Sat, 25 Jul 2026 18:34:56 +0200 Subject: [PATCH 04/32] feat: `addOption` in setup phase (#1042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: addOption in setup phase * fmt² * okay cut * humm * fmt * chore: update api surface * test: await async setupAddons in engine tests * Update packages/sv/src/core/config.ts Co-authored-by: Scott Wu * Update packages/sv/src/core/config.ts Co-authored-by: Scott Wu * feat: typesafe addOption keys via defineAddon setup generic - addOption now constrains key + question type from defineAddon() - regenerate api surface --------- Co-authored-by: Scott Wu --- .changeset/lovely-walls-retire.md | 5 + documentation/docs/30-add-ons/99-community.md | 5 +- documentation/docs/50-api/10-sv.md | 37 +++- packages/sv/api-surface-testing.md | 31 +++- packages/sv/api-surface.md | 40 ++++- packages/sv/src/cli/add.ts | 12 +- packages/sv/src/core/config.ts | 79 ++++++-- packages/sv/src/core/engine.ts | 24 ++- packages/sv/src/core/options.ts | 4 +- packages/sv/src/core/tests/engine.ts | 4 +- packages/sv/src/core/tests/setup.ts | 168 ++++++++++++++++++ 11 files changed, 372 insertions(+), 37 deletions(-) create mode 100644 .changeset/lovely-walls-retire.md create mode 100644 packages/sv/src/core/tests/setup.ts diff --git a/.changeset/lovely-walls-retire.md b/.changeset/lovely-walls-retire.md new file mode 100644 index 000000000..d4ebb9473 --- /dev/null +++ b/.changeset/lovely-walls-retire.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +feat(cli): `addOption` is now available in the setup phase to dynamically add options to your add-on diff --git a/documentation/docs/30-add-ons/99-community.md b/documentation/docs/30-add-ons/99-community.md index 6738c20ba..9af218bd5 100644 --- a/documentation/docs/30-add-ons/99-community.md +++ b/documentation/docs/30-add-ons/99-community.md @@ -37,9 +37,12 @@ export default defineAddon({ }) .build(), - setup: ({ dependsOn, isKit, unsupported }) => { + setup: ({ dependsOn, isKit, unsupported, addOption }) => { if (!isKit) unsupported('Requires SvelteKit'); dependsOn('vitest'); + + // dynamically add options (e.g. based on workspace state or fetched data) + // addOption('key', { question: '...', type: 'boolean', default: true }); }, run: ({ isKit, cancel, sv, options, file, language, directory }) => { diff --git a/documentation/docs/50-api/10-sv.md b/documentation/docs/50-api/10-sv.md index 613576ca4..393701143 100644 --- a/documentation/docs/50-api/10-sv.md +++ b/documentation/docs/50-api/10-sv.md @@ -16,10 +16,18 @@ export default defineAddon({ id: 'my-addon', options: defineAddonOptions().build(), - // called before run — declare dependencies and environment requirements - setup: ({ dependsOn, unsupported, isKit }) => { + // called before run - declare dependencies, environment requirements, and dynamic options + setup: ({ dependsOn, unsupported, addOption, isKit }) => { if (!isKit) unsupported('Requires SvelteKit'); dependsOn('eslint'); + + // dynamically add options based on workspace state or fetched data + addOption('theme', { + question: 'Which theme?', + type: 'select', + default: 'dark', + options: [{ value: 'dark' }, { value: 'light' }] + }); }, // the actual work — add files, edit files, declare dependencies @@ -50,6 +58,31 @@ export default defineAddon({ The `sv` object in `run` provides `file`, `dependency`, `devDependency`, and `execute`. For file transforms (AST-based editing of scripts, Svelte components, CSS, JSON, etc.) and package manager helpers, see [`@sveltejs/sv-utils`](sv-utils). +### Typed dynamic options + +If your add-on adds options dynamically in `setup` (e.g. from a fetch), you can pass a type parameter to `defineAddon` to get strong typing for those options: + +```ts +import { defineAddon, defineAddonOptions } from 'sv'; +// ---cut--- +const addon = defineAddon<{ theme: string }>()({ + id: 'my-addon', + options: defineAddonOptions().build(), + setup: ({ addOption }) => { + addOption('theme', { + question: 'Which theme?', + type: 'string', + default: 'dark' + }); + }, + run: ({ options }) => { + options.theme; // string + } +}); +``` + +The type parameter maps value types (`boolean`, `string`, `number`) to question definitions. Without it, `defineAddon` stays strict and only allows statically defined options. + ## `defineAddonOptions` Builder for add-on options. Chained with `.add()` and finalized with `.build()`. diff --git a/packages/sv/api-surface-testing.md b/packages/sv/api-surface-testing.md index e995f67d9..06781f813 100644 --- a/packages/sv/api-surface-testing.md +++ b/packages/sv/api-surface-testing.md @@ -80,7 +80,9 @@ type OptionValues = { ? Value : Args[K] extends MultiSelectQuestion ? Value[] - : 'ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`.'; + : Args[K] extends Question + ? unknown + : 'ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`.'; }; type WorkspaceOptions = OptionValues; type Workspace = { @@ -127,7 +129,11 @@ type SvApi = { file: (path: string, edit: (content: string) => string | false) => void; }; -type Addon = { +type Addon< + Args extends OptionDefinition, + Id extends string = string, + Setup extends Record = Record +> = { id: Id; alias?: string; shortDescription?: string; @@ -140,11 +146,15 @@ type Addon = { unsupported: (reason: string) => void; runsAfter: (name: keyof typeof officialAddons) => void; + addOption: >( + key: K, + question: SetupOptions[K] + ) => void; } ) => MaybePromise; run: ( workspace: Workspace & { - options: WorkspaceOptions; + options: WorkspaceOptions & Record; sv: SvApi; cancel: (reason: string) => void; @@ -152,10 +162,23 @@ type Addon = { ) => MaybePromise; nextSteps?: ( workspace: Workspace & { - options: WorkspaceOptions; + options: WorkspaceOptions & Record; } ) => string[]; }; + +type SetupOptions> = { + [K in keyof T]: BaseQuestion & + (T[K] extends boolean + ? BooleanQuestion + : T[K] extends string + ? StringQuestion + : T[K] extends number + ? NumberQuestion + : T[K] extends Array + ? MultiSelectQuestion + : Question); +}; type MaybePromise = Promise | T; type AddonMap = Record>; type AddonById = Extract< diff --git a/packages/sv/api-surface.md b/packages/sv/api-surface.md index 355a7505f..3f01d9a4b 100644 --- a/packages/sv/api-surface.md +++ b/packages/sv/api-surface.md @@ -90,7 +90,9 @@ type OptionValues = { ? Value : Args[K] extends MultiSelectQuestion ? Value[] - : 'ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`.'; + : Args[K] extends Question + ? unknown + : 'ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`.'; }; type WorkspaceOptions = OptionValues; type Workspace = { @@ -138,7 +140,11 @@ type SvApi = { file: (path: string, edit: (content: string) => string | false) => void; }; -type Addon = { +type Addon< + Args extends OptionDefinition, + Id extends string = string, + Setup extends Record = Record +> = { id: Id; alias?: string; shortDescription?: string; @@ -151,11 +157,15 @@ type Addon = { unsupported: (reason: string) => void; runsAfter: (name: keyof typeof officialAddons) => void; + addOption: >( + key: K, + question: SetupOptions[K] + ) => void; } ) => MaybePromise; run: ( workspace: Workspace & { - options: WorkspaceOptions; + options: WorkspaceOptions & Record; sv: SvApi; cancel: (reason: string) => void; @@ -163,14 +173,35 @@ type Addon = { ) => MaybePromise; nextSteps?: ( workspace: Workspace & { - options: WorkspaceOptions; + options: WorkspaceOptions & Record; } ) => string[]; }; +type SetupOptions> = { + [K in keyof T]: BaseQuestion & + (T[K] extends boolean + ? BooleanQuestion + : T[K] extends string + ? StringQuestion + : T[K] extends number + ? NumberQuestion + : T[K] extends Array + ? MultiSelectQuestion + : Question); +}; + declare function defineAddon( config: Addon ): Addon; +declare function defineAddon>(): < + const Id extends string, + Args extends OptionDefinition +>( + config: Omit, Id, SetupValues>, 'options'> & { + options: Args; + } +) => Addon, Id, SetupValues>; type AddonInput = { readonly specifier: string; @@ -225,6 +256,7 @@ type SetupResult = { dependsOn: string[]; unsupported: string[]; runsAfter: string[]; + additionalOptions: Record; }; type AddonDefinition = Addon>, Id>; type MaybePromise = Promise | T; diff --git a/packages/sv/src/cli/add.ts b/packages/sv/src/cli/add.ts index 7ea9da941..cba90b999 100644 --- a/packages/sv/src/cli/add.ts +++ b/packages/sv/src/cli/add.ts @@ -431,7 +431,7 @@ export async function promptAddonQuestions({ // If we have selected addons, run setup on them (regardless of official status) if (addons.length > 0) { - setupResults = setupAddons(addons, workspace); + setupResults = await setupAddons(addons, workspace); } // prompt which addons to apply (only when no addons were specified) @@ -439,7 +439,7 @@ export async function promptAddonQuestions({ if (addons.length === 0) { // For the prompt, we only show official addons const officialLoaded = officialAddons.map((a) => createLoadedAddon(a)); - const results = setupAddons(officialLoaded, workspace); + const results = await setupAddons(officialLoaded, workspace); const addonOptions = officialAddons // only display supported addons relative to the current environment .filter(({ id, hidden }) => results[id].unsupported.length === 0 && !hidden) @@ -467,14 +467,14 @@ export async function promptAddonQuestions({ } // Re-run setup for all selected addons (including any that were added via CLI options) - setupResults = setupAddons(addons, workspace); + setupResults = await setupAddons(addons, workspace); } // Ensure all selected addons have setup results // This should always be the case, but we add a safeguard const missingSetupResults = addons.filter((a) => !setupResults[a.addon.id]); if (missingSetupResults.length > 0) { - const additionalSetupResults = setupAddons(missingSetupResults, workspace); + const additionalSetupResults = await setupAddons(missingSetupResults, workspace); Object.assign(setupResults, additionalSetupResults); } @@ -547,7 +547,7 @@ export async function promptAddonQuestions({ // Run setup for any newly added dependencies const newlyAddedAddons = addons.filter((a) => !setupResults[a.addon.id]); if (newlyAddedAddons.length > 0) { - const newSetupResults = setupAddons(newlyAddedAddons, workspace); + const newSetupResults = await setupAddons(newlyAddedAddons, workspace); Object.assign(setupResults, newSetupResults); } } @@ -664,7 +664,7 @@ export async function runAddonsApply({ const setups = loadedAddons.length ? loadedAddons : officialAddons.map((a) => createLoadedAddon(a)); - setupResults = setupAddons(setups, workspace); + setupResults = await setupAddons(setups, workspace); } // we'll return early when no addons are selected, // indicating that installing deps was skipped and no PM was selected diff --git a/packages/sv/src/core/config.ts b/packages/sv/src/core/config.ts index 0987e1aaf..be494e011 100644 --- a/packages/sv/src/core/config.ts +++ b/packages/sv/src/core/config.ts @@ -1,5 +1,14 @@ import type { officialAddons } from '../addons/index.ts'; -import type { OptionDefinition, OptionValues, Question } from './options.ts'; +import type { + BaseQuestion, + BooleanQuestion, + MultiSelectQuestion, + NumberQuestion, + OptionDefinition, + OptionValues, + Question, + StringQuestion +} from './options.ts'; import type { Workspace, WorkspaceOptions } from './workspace.ts'; export type { OptionValues } from './options.ts'; @@ -23,7 +32,11 @@ export type SvApi = { file: (path: string, edit: (content: string) => string | false) => void; }; -export type Addon = { +export type Addon< + Args extends OptionDefinition, + Id extends string = string, + Setup extends Record = Record +> = { id: Id; alias?: string; /** one-liner shown in prompts */ @@ -48,13 +61,19 @@ export type Addon = { /** On what official addons does this addon run after? */ runsAfter: (name: keyof typeof officialAddons) => void; + + /** Dynamically add an option to be prompted to the user */ + addOption: >( + key: K, + question: SetupOptions[K] + ) => void; } ) => MaybePromise; /** Run the addon. The actual execution of the addon... Add files, edit files, etc. */ run: ( workspace: Workspace & { - /** Add-on options */ - options: WorkspaceOptions; + /** Add-on options (includes dynamically added options from setup) */ + options: WorkspaceOptions & Record; /** Api to interact with the workspace. */ sv: SvApi; /** Cancel the addon at any time! @@ -65,16 +84,50 @@ export type Addon = { } ) => MaybePromise; /** Next steps to display after the addon is run. */ - nextSteps?: (workspace: Workspace & { options: WorkspaceOptions }) => string[]; + nextSteps?: ( + workspace: Workspace & { options: WorkspaceOptions & Record } + ) => string[]; +}; + +/** Maps value types to question definitions for dynamic setup options */ +export type SetupOptions> = { + [K in keyof T]: BaseQuestion & + (T[K] extends boolean + ? BooleanQuestion + : T[K] extends string + ? StringQuestion + : T[K] extends number + ? NumberQuestion + : T[K] extends Array + ? MultiSelectQuestion + : Question); }; /** * The entry point for your addon, It will hold every thing! (options, setup, run, nextSteps, ...) + * + * For dynamic options added via `addOption` in setup, use the generic to get strong typing: + * ```ts + * const addon = defineAddon<{ extra: boolean }>()({ ... }); + * addon.options.extra.default // boolean + * ``` */ export function defineAddon( config: Addon -): Addon { - return config; +): Addon; +export function defineAddon>(): < + const Id extends string, + Args extends OptionDefinition +>( + config: Omit, Id, SetupValues>, 'options'> & { + options: Args; + } +) => Addon, Id, SetupValues>; +export function defineAddon(...args: any[]): any { + if (args.length === 0) { + return (config: any) => config; + } + return args[0]; } // ============================================================================ @@ -96,7 +149,7 @@ export function defineAddon; +}; export type AddonDefinition = Addon>, Id>; @@ -256,8 +314,7 @@ export function defineAddonOptions(): OptionBuilder<{}> { function createOptionBuilder(options: T): OptionBuilder { return { add(key, question) { - const newOptions = { ...options, [key]: question }; - return createOptionBuilder(newOptions); + return createOptionBuilder({ ...options, [key]: question }); }, build() { return options; diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 3f201fd4c..38d0bf554 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -23,6 +23,7 @@ import { } from './config.ts'; import { svDeprecated } from './deprecated.ts'; import { TESTING } from './env.ts'; +import type { Question } from './options.ts'; import { addPnpmAllowBuilds } from './package-manager.ts'; import { createWorkspace, type Workspace } from './workspace.ts'; @@ -89,7 +90,7 @@ export async function add({ createLoadedAddon(addon as AddonDefinition) ); - const setupResults = setupAddons(loadedAddons, workspace); + const setupResults = await setupAddons(loadedAddons, workspace); return await applyAddons({ loadedAddons, workspace, options, setupResults }); } @@ -164,28 +165,33 @@ export async function applyAddons({ } /** Setup addons - takes LoadedAddon[] and returns setup results */ -export function setupAddons( +export async function setupAddons( loadedAddons: LoadedAddon[], workspace: Workspace -): Record { +): Promise> { const setupResults: Record = {}; for (const loaded of loadedAddons) { const addon = loaded.addon; + const additionalOptions: Record = {}; const setupResult: SetupResult = { unsupported: [], dependsOn: [], - runsAfter: [] + runsAfter: [], + additionalOptions }; try { - addon.setup?.({ + await addon.setup?.({ ...workspace, dependsOn: (name) => { setupResult.dependsOn.push(name); setupResult.runsAfter.push(name); }, unsupported: (reason) => setupResult.unsupported.push(reason), - runsAfter: (name) => setupResult.runsAfter.push(name) + runsAfter: (name) => setupResult.runsAfter.push(name), + addOption: (key, question) => { + additionalOptions[key] = question; + } }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -194,6 +200,12 @@ export function setupAddons( { cause: err } ); } + + // Merge dynamic options into the addon's options + if (Object.keys(additionalOptions).length > 0) { + Object.assign(addon.options, additionalOptions); + } + setupResults[addon.id] = setupResult; } diff --git a/packages/sv/src/core/options.ts b/packages/sv/src/core/options.ts index f110400ac..6f2069932 100644 --- a/packages/sv/src/core/options.ts +++ b/packages/sv/src/core/options.ts @@ -62,5 +62,7 @@ export type OptionValues = { ? Value : Args[K] extends MultiSelectQuestion ? Value[] - : 'ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`.'; + : Args[K] extends Question + ? unknown + : 'ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`.'; }; diff --git a/packages/sv/src/core/tests/engine.ts b/packages/sv/src/core/tests/engine.ts index 7a653c482..cd8d1e287 100644 --- a/packages/sv/src/core/tests/engine.ts +++ b/packages/sv/src/core/tests/engine.ts @@ -34,7 +34,7 @@ describe('applyAddons cancel propagation', () => { const { status } = await applyAddons({ loadedAddons: addons, workspace, - setupResults: setupAddons(addons, workspace), + setupResults: await setupAddons(addons, workspace), options: { dep: {}, child: {} } }); expect(status.dep).toEqual(['nope']); @@ -56,7 +56,7 @@ describe('applyAddons cancel propagation', () => { const { status } = await applyAddons({ loadedAddons: addons, workspace, - setupResults: setupAddons(addons, workspace), + setupResults: await setupAddons(addons, workspace), options: { dep: {}, child: {} } }); expect(status.child).toBe('success'); diff --git a/packages/sv/src/core/tests/setup.ts b/packages/sv/src/core/tests/setup.ts new file mode 100644 index 000000000..1442c303e --- /dev/null +++ b/packages/sv/src/core/tests/setup.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { createLoadedAddon } from '../../cli/add.ts'; +import { + defineAddon, + defineAddonOptions, + type Addon, + type AddonDefinition, + type LoadedAddon +} from '../config.ts'; +import { setupAddons } from '../engine.ts'; +import type { Workspace } from '../workspace.ts'; + +const workspace: Workspace = { + cwd: '/test/project', + dependencyVersion: () => undefined, + language: 'ts', + file: { + viteConfig: 'vite.config.ts', + svelteConfig: 'svelte.config.ts', + typeConfig: 'tsconfig.json', + stylesheet: 'src/app.css', + package: 'package.json', + gitignore: '.gitignore', + prettierignore: '.prettierignore', + prettierrc: '.prettierrc', + eslintConfig: 'eslint.config.js', + vscodeSettings: '.vscode/settings.json', + vscodeExtensions: '.vscode/extensions.json', + getRelative: () => '', + findUp: () => '' + }, + isKit: false, + directory: { src: 'src', lib: 'src/lib', kitRoutes: 'src/routes' }, + packageManager: 'npm' +}; + +function toLoaded(addon: Addon): LoadedAddon { + return createLoadedAddon(addon as AddonDefinition); +} + +describe('setupAddons', () => { + it('should return setup results with empty additionalOptions', async () => { + const addon: AddonDefinition = { + id: 'test-addon', + options: {}, + setup: () => {}, + run: () => {} + }; + + const results = await setupAddons([toLoaded(addon)], workspace); + + expect(results['test-addon']).toBeDefined(); + expect(results['test-addon'].additionalOptions).toEqual({}); + expect(results['test-addon'].dependsOn).toEqual([]); + expect(results['test-addon'].unsupported).toEqual([]); + expect(results['test-addon'].runsAfter).toEqual([]); + }); + + it('should collect dynamic options via addOption', async () => { + const addon: AddonDefinition = { + id: 'dynamic-addon', + options: {}, + setup: ({ addOption }) => { + addOption('org', { + question: 'Which org?', + type: 'string', + default: 'my-org' + }); + }, + run: () => {} + }; + + const results = await setupAddons([toLoaded(addon)], workspace); + + expect(results['dynamic-addon'].additionalOptions).toEqual({ + org: { question: 'Which org?', type: 'string', default: 'my-org' } + }); + }); + + it('should preserve strong typing with defineAddon and defineAddonOptions', async () => { + const options = defineAddonOptions() + .add('plugins', { + type: 'string', + question: 'Which Tailwind plugins do you want to use?', + default: 'hello' + }) + .build(); + + const addon = defineAddon<{ extra: boolean }>()({ + id: 'typed-addon', + options, + setup: ({ addOption }) => { + addOption('extra', { + question: 'Extra?', + type: 'boolean', + default: false + }); + }, + run: ({ options }) => { + // strong typing: these would fail at compile time + // if options.plugins wasn't string or options.extra wasn't boolean + expect(options.plugins).toBeDefined(); + expect(options.extra).toBeDefined(); + } + }); + + const results = await setupAddons([toLoaded(addon)], workspace); + + // static options are strongly typed + expect(addon.options.plugins.default).toBe('hello'); + + // dynamic options from defineAddon<{ extra: boolean }>() are also strongly typed + expect(addon.options.extra.default).toBe(false); + + // dynamic options are available via setup result too + expect(results['typed-addon'].additionalOptions).toHaveProperty('extra'); + }); + + it('should await async setup', async () => { + const addon: AddonDefinition = { + id: 'async-addon', + options: {}, + setup: async ({ addOption }) => { + await Promise.resolve(); + addOption('fetched', { + question: 'Fetched option?', + type: 'boolean', + default: true + }); + }, + run: () => {} + }; + + const results = await setupAddons([toLoaded(addon)], workspace); + + expect(results['async-addon'].additionalOptions).toHaveProperty('fetched'); + expect(addon.options).toHaveProperty('fetched'); + }); + + it('should collect multiple dynamic options', async () => { + const addon: AddonDefinition = { + id: 'multi-addon', + options: {}, + setup: ({ addOption }) => { + addOption('org', { + question: 'Which org?', + type: 'string', + default: '' + }); + addOption('theme', { + question: 'Pick theme', + type: 'select', + default: 'dark', + options: [ + { value: 'dark', label: 'Dark' }, + { value: 'light', label: 'Light' } + ] + }); + }, + run: () => {} + }; + + const results = await setupAddons([toLoaded(addon)], workspace); + + expect(Object.keys(results['multi-addon'].additionalOptions)).toEqual(['org', 'theme']); + expect(Object.keys(addon.options)).toEqual(['org', 'theme']); + }); +}); From 063258f3da45cf02f3c31b8150f11fc6e02d0419 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:16:41 +0200 Subject: [PATCH 05/32] Version Packages (#1191) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/format-created-project-files.md | 5 ----- .changeset/honest-bugs-jam.md | 5 ----- .changeset/lovely-walls-retire.md | 5 ----- .changeset/recognize-nub-package-manager.md | 6 ------ packages/migrate/CHANGELOG.md | 6 ++++++ packages/migrate/package.json | 2 +- packages/sv-utils/CHANGELOG.md | 6 ++++++ packages/sv-utils/package.json | 2 +- packages/sv/CHANGELOG.md | 15 +++++++++++++++ packages/sv/package.json | 2 +- 10 files changed, 30 insertions(+), 24 deletions(-) delete mode 100644 .changeset/format-created-project-files.md delete mode 100644 .changeset/honest-bugs-jam.md delete mode 100644 .changeset/lovely-walls-retire.md delete mode 100644 .changeset/recognize-nub-package-manager.md diff --git a/.changeset/format-created-project-files.md b/.changeset/format-created-project-files.md deleted file mode 100644 index 86974f1c9..000000000 --- a/.changeset/format-created-project-files.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'sv': patch ---- - -fix: format all created project files when using the prettier add-on diff --git a/.changeset/honest-bugs-jam.md b/.changeset/honest-bugs-jam.md deleted file mode 100644 index dc67a3205..000000000 --- a/.changeset/honest-bugs-jam.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"sv": patch ---- - -feat: smart(er) package manager selection diff --git a/.changeset/lovely-walls-retire.md b/.changeset/lovely-walls-retire.md deleted file mode 100644 index d4ebb9473..000000000 --- a/.changeset/lovely-walls-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'sv': patch ---- - -feat(cli): `addOption` is now available in the setup phase to dynamically add options to your add-on diff --git a/.changeset/recognize-nub-package-manager.md b/.changeset/recognize-nub-package-manager.md deleted file mode 100644 index ec0aa0046..000000000 --- a/.changeset/recognize-nub-package-manager.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@sveltejs/sv-utils': patch -'svelte-migrate': patch ---- - -fix: recognize the `nub` package manager diff --git a/packages/migrate/CHANGELOG.md b/packages/migrate/CHANGELOG.md index 99f4212d1..8b0cb42b5 100644 --- a/packages/migrate/CHANGELOG.md +++ b/packages/migrate/CHANGELOG.md @@ -1,5 +1,11 @@ # svelte-migrate +## 1.10.3 +### Patch Changes + + +- fix: recognize the `nub` package manager ([#1187](https://github.com/sveltejs/cli/pull/1187)) + ## 1.10.2 ### Patch Changes diff --git a/packages/migrate/package.json b/packages/migrate/package.json index edf489696..79e0635fd 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -1,6 +1,6 @@ { "name": "svelte-migrate", - "version": "1.10.2", + "version": "1.10.3", "type": "module", "description": "A CLI for migrating Svelte(Kit) codebases", "license": "MIT", diff --git a/packages/sv-utils/CHANGELOG.md b/packages/sv-utils/CHANGELOG.md index fc1bd3b3f..a34b5afb8 100644 --- a/packages/sv-utils/CHANGELOG.md +++ b/packages/sv-utils/CHANGELOG.md @@ -1,5 +1,11 @@ # @sveltejs/sv-utils +## 0.3.2 +### Patch Changes + + +- fix: recognize the `nub` package manager ([#1187](https://github.com/sveltejs/cli/pull/1187)) + ## 0.3.1 ### Patch Changes diff --git a/packages/sv-utils/package.json b/packages/sv-utils/package.json index b67cd7ea4..98074a7ba 100644 --- a/packages/sv-utils/package.json +++ b/packages/sv-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sveltejs/sv-utils", - "version": "0.3.1", + "version": "0.3.2", "type": "module", "description": "Utility functions for sv", "license": "MIT", diff --git a/packages/sv/CHANGELOG.md b/packages/sv/CHANGELOG.md index 899fd5300..34bc6f9cf 100644 --- a/packages/sv/CHANGELOG.md +++ b/packages/sv/CHANGELOG.md @@ -1,5 +1,20 @@ # sv +## 0.16.6 +### Patch Changes + + +- fix: format all created project files when using the prettier add-on ([#1192](https://github.com/sveltejs/cli/pull/1192)) + + +- feat: smart(er) package manager selection ([#1190](https://github.com/sveltejs/cli/pull/1190)) + + +- feat(cli): `addOption` is now available in the setup phase to dynamically add options to your add-on ([#1042](https://github.com/sveltejs/cli/pull/1042)) + +- Updated dependencies [[`580c2f7`](https://github.com/sveltejs/cli/commit/580c2f7ae34d733393c4e5c185feffe0f1fe9f8e)]: + - @sveltejs/sv-utils@0.3.2 + ## 0.16.5 ### Patch Changes diff --git a/packages/sv/package.json b/packages/sv/package.json index b22995887..08ae3f2de 100644 --- a/packages/sv/package.json +++ b/packages/sv/package.json @@ -1,6 +1,6 @@ { "name": "sv", - "version": "0.16.5", + "version": "0.16.6", "type": "module", "description": "A command line interface (CLI) for creating and maintaining Svelte applications", "license": "MIT", From 129cc63585f8f6619ad9e577f6fe647fa4a4aa12 Mon Sep 17 00:00:00 2001 From: "jyc.dev" Date: Thu, 30 Jul 2026 12:52:51 +0200 Subject: [PATCH 06/32] feat: welcome `ai-tools` (#1050) * mv * feat: welcome `ai-tools` * abc * don't format skills * opencode -> plugin, claude question, subagents * fmt * cleanup snap * snap * update new path * feat(ai-tools): choose Svelte plugin or individual tools per client - delivery option: Svelte plugin (Claude via committed .claude/settings.json, opencode via opencode.json) or individual tools - granular MCP/skills/sub-agents selection; rename `setup` -> `mcpSetup` - consolidate per-client config into a single `CLIENTS` registry * feat(ai-tools): install skills for cursor, gemini and vscode * feat(ai-tools): CLAUDE.md imports AGENTS.md to keep a single source of truth * chore(ai-tools): rename RX_MD to REGEX_MD * feat(ai-tools): warn in next steps when plugin and loose skill/agent files coexist * fmt * fix(ai-tools): 'other' client now writes AGENTS.md * fix(ai-tools): only ask tool/MCP questions when a client can use them * Update packages/sv/src/create/shared/+skills/svelte-code-writer/SKILL.md Co-authored-by: Rich Harris * Update documentation/docs/30-add-ons/01-ai-tools.md Co-authored-by: Rich Harris * Update documentation/docs/30-add-ons/01-ai-tools.md Co-authored-by: Rich Harris * Update documentation/docs/30-add-ons/01-ai-tools.md Co-authored-by: Rich Harris * Update documentation/docs/30-add-ons/01-ai-tools.md Co-authored-by: Rich Harris * Update packages/sv/src/addons/ai-tools.ts Co-authored-by: Rich Harris * Update packages/sv/src/addons/ai-tools.ts Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+skills/svelte-code-writer/SKILL.md Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+skills/svelte-code-writer/SKILL.md Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+skills/svelte-code-writer/SKILL.md Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+skills/svelte-code-writer/SKILL.md Co-authored-by: Rich Harris * Update packages/sv/src/addons/ai-tools.ts Co-authored-by: Rich Harris * Update packages/sv/src/addons/ai-tools.ts Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+agents/svelte-file-editor.md Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+agents/svelte-file-editor.md Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+agents/svelte-file-editor.md Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+agents/svelte-file-editor.md Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+agents/svelte-file-editor.md Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+agents/svelte-file-editor.md Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+agents/svelte-file-editor.md Co-authored-by: Rich Harris * Update packages/sv/src/create/shared/+agents/svelte-file-editor.md Co-authored-by: Rich Harris * update skills * fix: drop stale reference files and update ai-tools snapshots --------- Co-authored-by: Rich Harris --- .changeset/remove-mcp-addon.md | 5 + .prettierignore | 3 +- documentation/docs/20-commands/20-sv-add.md | 2 +- documentation/docs/30-add-ons/01-ai-tools.md | 56 +++ documentation/docs/30-add-ons/17-mcp.md | 34 -- packages/sv/api-surface-testing.md | 2 +- packages/sv/api-surface.md | 2 +- packages/sv/src/addons/ai-tools.ts | 388 +++++++++++++++++ packages/sv/src/addons/index.ts | 6 +- packages/sv/src/addons/mcp.ts | 208 --------- .../addons/tests/{mcp => ai-tools}/test.ts | 89 +++- packages/sv/src/cli/tests/cli.ts | 30 +- .../create-with-all-addons/.claude/CLAUDE.md | 1 + .../.claude/agents/sv-files-snapshots.md | 1 + .../.claude/skills/sv-files-snapshots.md | 11 + .../.cursor/agents/sv-files-snapshots.md | 1 + .../.cursor/skills/sv-files-snapshots.md | 11 + .../.gemini/agents/sv-files-snapshots.md | 1 + .../.gemini/skills/sv-files-snapshots.md | 11 + .../.github/agents/sv-files-snapshots.md | 1 + .../.github/skills/sv-files-snapshots.md | 11 + .../create-with-all-addons/AGENTS.md | 2 +- .../create-with-all-addons/CLAUDE.md | 31 -- .../create-with-all-addons/GEMINI.md | 2 +- .../create-with-all-addons/README.md | 2 +- packages/sv/src/core/common.ts | 7 +- packages/sv/src/create/index.ts | 2 +- .../shared/+agents/svelte-file-editor.md | 67 +++ .../+skills/svelte-code-writer/SKILL.md | 64 +++ .../svelte-core-bestpractices/SKILL.md | 176 ++++++++ .../references/attach.md | 170 ++++++++ .../references/await-expressions.md | 187 ++++++++ .../references/bind.md | 22 + .../references/each.md | 42 ++ .../references/hydratable.md | 103 +++++ .../references/inspect.md | 63 +++ .../references/render.md | 35 ++ .../references/snippet.md | 400 ++++++++++++++++++ .../references/svelte-reactivity.md | 61 +++ scripts/update-dependencies.js | 55 ++- 40 files changed, 2071 insertions(+), 294 deletions(-) create mode 100644 .changeset/remove-mcp-addon.md create mode 100644 documentation/docs/30-add-ons/01-ai-tools.md delete mode 100644 documentation/docs/30-add-ons/17-mcp.md create mode 100644 packages/sv/src/addons/ai-tools.ts delete mode 100644 packages/sv/src/addons/mcp.ts rename packages/sv/src/addons/tests/{mcp => ai-tools}/test.ts (58%) create mode 100644 packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/CLAUDE.md create mode 100644 packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/agents/sv-files-snapshots.md create mode 100644 packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/skills/sv-files-snapshots.md create mode 100644 packages/sv/src/cli/tests/snapshots/create-with-all-addons/.cursor/agents/sv-files-snapshots.md create mode 100644 packages/sv/src/cli/tests/snapshots/create-with-all-addons/.cursor/skills/sv-files-snapshots.md create mode 100644 packages/sv/src/cli/tests/snapshots/create-with-all-addons/.gemini/agents/sv-files-snapshots.md create mode 100644 packages/sv/src/cli/tests/snapshots/create-with-all-addons/.gemini/skills/sv-files-snapshots.md create mode 100644 packages/sv/src/cli/tests/snapshots/create-with-all-addons/.github/agents/sv-files-snapshots.md create mode 100644 packages/sv/src/cli/tests/snapshots/create-with-all-addons/.github/skills/sv-files-snapshots.md delete mode 100644 packages/sv/src/cli/tests/snapshots/create-with-all-addons/CLAUDE.md create mode 100644 packages/sv/src/create/shared/+agents/svelte-file-editor.md create mode 100644 packages/sv/src/create/shared/+skills/svelte-code-writer/SKILL.md create mode 100644 packages/sv/src/create/shared/+skills/svelte-core-bestpractices/SKILL.md create mode 100644 packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/attach.md create mode 100644 packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/await-expressions.md create mode 100644 packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/bind.md create mode 100644 packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/each.md create mode 100644 packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/hydratable.md create mode 100644 packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/inspect.md create mode 100644 packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/render.md create mode 100644 packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/snippet.md create mode 100644 packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/svelte-reactivity.md diff --git a/.changeset/remove-mcp-addon.md b/.changeset/remove-mcp-addon.md new file mode 100644 index 000000000..73672764c --- /dev/null +++ b/.changeset/remove-mcp-addon.md @@ -0,0 +1,5 @@ +--- +'sv': minor +--- + +feat(ai-tools): replace `mcp` add-on with `ai-tools` add-on - set up the Svelte plugin (Claude Code, opencode) or pick individual tools (MCP server, skills, sub-agents) per client diff --git a/.prettierignore b/.prettierignore index 3c73e5188..fc57e856b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ packages/sv/src/cli/tests/snapshots/* -packages/sv-utils/src/tests/**/output.ts \ No newline at end of file +packages/sv-utils/src/tests/**/output.ts +packages/sv/src/create/shared/+skills/* \ No newline at end of file diff --git a/documentation/docs/20-commands/20-sv-add.md b/documentation/docs/20-commands/20-sv-add.md index 1c5ade947..4d4e78f77 100644 --- a/documentation/docs/20-commands/20-sv-add.md +++ b/documentation/docs/20-commands/20-sv-add.md @@ -49,10 +49,10 @@ Do not prompt to install dependencies. ## Official add-ons +- [`ai-tools`](ai-tools) - [`better-auth`](better-auth) - [`drizzle`](drizzle) - [`eslint`](eslint) -- [`mcp`](mcp) - [`mdsvex`](mdsvex) - [`paraglide`](paraglide) - [`playwright`](playwright) diff --git a/documentation/docs/30-add-ons/01-ai-tools.md b/documentation/docs/30-add-ons/01-ai-tools.md new file mode 100644 index 000000000..0f5ac3cf9 --- /dev/null +++ b/documentation/docs/30-add-ons/01-ai-tools.md @@ -0,0 +1,56 @@ +--- +title: ai-tools +--- + +The [Svelte AI tools](/docs/ai/overview) can help your LLM write better Svelte code. + +## Usage + +```sh +npx sv add ai-tools +``` + +## What you get + +You can add the tooling either through the official Svelte plugin or as individual tools. + +- The Svelte plugin bundles everything (MCP server, [skills](https://svelte.dev/docs/ai/skills) and sub-agents) and keeps itself up to date. It's available for Claude Code and OpenCode. For Claude Code it's enabled through a committed `.claude/settings.json` - the first time you open the project you'll be asked to trust the workspace, then it installs automatically (no `/plugin install` needed). +- Individual tools, for clients without a plugin (or when you want to pick exactly what to add): + - An MCP configuration for [local](https://svelte.dev/docs/ai/local-setup) or [remote](https://svelte.dev/docs/ai/remote-setup) setup + - A [README for agents](https://agents.md/) to help you use the MCP server effectively + - [Skills](https://svelte.dev/docs/ai/skills) for clients that support them + - Sub-agents for clients that support them + +## Options + +### ide + +The client(s) you want to use like `'claude-code'`, `'cursor'`, `'gemini'`, `'opencode'`, `'vscode'`, `'other'`. + +```sh +npx sv add ai-tools="ide:cursor,vscode" +``` + +### delivery + +How to add the tooling: `'plugin'` (the Svelte plugin, recommended) or `'tools'` (individual tools). Only asked when a selected client supports a plugin. + +```sh +npx sv add ai-tools="ide:claude-code+delivery:plugin" +``` + +### tools + +Which individual tools to add when not using the plugin, like `'mcp'`, `'svelte-code-writer'`, `'svelte-core-bestpractices'`, `'svelte-file-editor'`. + +```sh +npx sv add ai-tools="ide:cursor+delivery:tools+tools:mcp,svelte-file-editor" +``` + +### mcpSetup + +The MCP setup you want to use (`'local'` or `'remote'`). Only relevant when adding the MCP server as an individual tool. + +```sh +npx sv add ai-tools="mcpSetup:local" +``` diff --git a/documentation/docs/30-add-ons/17-mcp.md b/documentation/docs/30-add-ons/17-mcp.md deleted file mode 100644 index 437977c67..000000000 --- a/documentation/docs/30-add-ons/17-mcp.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: mcp ---- - -[Svelte MCP](/docs/ai/overview) can help your LLM write better Svelte code. - -## Usage - -```sh -npx sv add mcp -``` - -## What you get - -- An MCP configuration for [local](https://svelte.dev/docs/ai/local-setup) or [remote](https://svelte.dev/docs/ai/remote-setup) setup -- A [README for agents](https://agents.md/) to help you use the MCP server effectively - -## Options - -### ide - -The IDE you want to use like `'claude-code'`, `'cursor'`, `'gemini'`, `'opencode'`, `'vscode'`, `'other'`. - -```sh -npx sv add mcp="ide:cursor,vscode" -``` - -### setup - -The setup you want to use. - -```sh -npx sv add mcp="setup:local" -``` diff --git a/packages/sv/api-surface-testing.md b/packages/sv/api-surface-testing.md index 06781f813..ad3997471 100644 --- a/packages/sv/api-surface-testing.md +++ b/packages/sv/api-surface-testing.md @@ -15,7 +15,7 @@ type OfficialAddons = { mdsvex: Addon; paraglide: Addon; storybook: Addon; - mcp: Addon; + aiTools: Addon; experimental: Addon; }; declare const officialAddons: OfficialAddons; diff --git a/packages/sv/api-surface.md b/packages/sv/api-surface.md index 3f01d9a4b..d47b23ab3 100644 --- a/packages/sv/api-surface.md +++ b/packages/sv/api-surface.md @@ -25,7 +25,7 @@ type OfficialAddons = { mdsvex: Addon; paraglide: Addon; storybook: Addon; - mcp: Addon; + aiTools: Addon; experimental: Addon; }; declare const officialAddons: OfficialAddons; diff --git a/packages/sv/src/addons/ai-tools.ts b/packages/sv/src/addons/ai-tools.ts new file mode 100644 index 000000000..a30cdd15d --- /dev/null +++ b/packages/sv/src/addons/ai-tools.ts @@ -0,0 +1,388 @@ +import { log } from '@clack/prompts'; +import { color, transforms } from '@sveltejs/sv-utils'; +import fs from 'node:fs'; +import path from 'node:path'; +import { defineAddon, defineAddonOptions } from '../core/config.ts'; +import { getSharedFiles } from '../create/utils.ts'; + +const REGEX_MD = /\.md$/; + +type Client = { + label: string; + // committing this settings file enables the Svelte plugin for the client (Claude Code) + pluginSettings?: { path: string; marketplace: string; repo: string; id: string }; + // always delivered through its own plugin/config, never loose files (opencode) + pluginOnly?: boolean; + schema?: string; + mcpOptions?: { + serversKey?: string; + typeLocal?: 'stdio' | 'local'; + typeRemote?: 'http' | 'remote'; + env?: boolean; + command?: string | string[]; + args?: string[]; + }; + agentPath?: string; + // pointer file importing agentPath, keeps a single source of truth (CLAUDE.md -> @AGENTS.md) + agentLink?: { path: string; contents: string }; + configPath?: string; + skillsPath?: string; + agentsPath?: string; + agentExtension?: string; + customData?: Record; + extraFiles?: Array<{ path: string; data: Record }>; +}; + +// Single source of truth per client - drives the `ide` prompt, the conditions and the `run` logic. +const CLIENTS: Record = { + 'claude-code': { + label: 'Claude Code', + pluginSettings: { + path: '.claude/settings.json', + marketplace: 'svelte', + repo: 'sveltejs/ai-tools', + id: 'svelte@svelte' + }, + agentPath: 'AGENTS.md', + agentLink: { path: '.claude/CLAUDE.md', contents: '@../AGENTS.md\n' }, + configPath: '.mcp.json', + skillsPath: '.claude/skills', + agentsPath: '.claude/agents', + mcpOptions: { + typeLocal: 'stdio', + typeRemote: 'http', + env: true + } + }, + cursor: { + label: 'Cursor', + agentPath: 'AGENTS.md', + configPath: '.cursor/mcp.json', + skillsPath: '.cursor/skills', + agentsPath: '.cursor/agents', + mcpOptions: {} + }, + gemini: { + label: 'Gemini', + agentPath: 'GEMINI.md', + configPath: '.gemini/settings.json', + skillsPath: '.gemini/skills', + agentsPath: '.gemini/agents', + schema: + 'https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json', + mcpOptions: {} + }, + opencode: { + label: 'OpenCode', + pluginOnly: true, + agentPath: 'AGENTS.md', + configPath: '.opencode/opencode.json', + schema: 'https://opencode.ai/config.json', + customData: { plugin: ['@sveltejs/opencode'] }, + extraFiles: [ + { + path: '.opencode/svelte.json', + data: { + $schema: 'https://svelte.dev/opencode/schema.json' + } + } + ] + }, + vscode: { + label: 'VS Code', + agentPath: 'AGENTS.md', + configPath: '.vscode/mcp.json', + skillsPath: '.github/skills', + agentsPath: '.github/agents', + agentExtension: '.agent.md', + mcpOptions: { + serversKey: 'servers' + } + }, + // no known config location: only writes AGENTS.md, next steps link to the docs + other: { + label: 'Other', + agentPath: 'AGENTS.md' + } +}; + +// a client can only receive loose tool files if it has somewhere to put them +const acceptsTools = (client?: Client) => + Boolean( + client && !client.pluginOnly && (client.skillsPath || client.agentsPath || client.configPath) + ); +// clients whose plugin replaces loose files only skip them when the plugin is chosen +const wantsLooseTools = (client: Client | undefined, delivery: string | undefined) => + acceptsTools(client) && (!client!.pluginSettings || delivery !== 'plugin'); +const wantsMcpConfig = (client: Client | undefined, delivery: string | undefined) => + Boolean(client?.mcpOptions) && wantsLooseTools(client, delivery); + +// Static for curated labels; derive from getSharedFiles() (include 'skills'/'agents') to go dynamic. +const TOOLS: Record = { + mcp: { label: 'MCP server', kind: 'mcp' }, + 'svelte-code-writer': { label: 'svelte-code-writer', kind: 'skill', hint: 'skill' }, + 'svelte-core-bestpractices': { label: 'svelte-core-bestpractices', kind: 'skill', hint: 'skill' }, + 'svelte-file-editor': { label: 'svelte-file-editor', kind: 'agent', hint: 'sub-agent' } +}; + +const options = defineAddonOptions() + .add('ide', { + question: 'Which client would you like to use?', + type: 'multiselect', + default: [], + options: Object.entries(CLIENTS).map(([value, client]) => ({ value, label: client.label })), + required: true + }) + .add('delivery', { + question: 'How would you like to add the Svelte tools?', + type: 'select', + default: 'plugin', + options: [ + { value: 'plugin', label: 'Svelte plugin', hint: 'recommended, auto-installs & updates' }, + { value: 'tools', label: 'Individual tools', hint: 'choose exactly what to add' } + ], + condition: ({ ide }) => ide.some((i) => CLIENTS[i]?.pluginSettings) + }) + .add('tools', { + question: 'Which tools would you like to add?', + type: 'multiselect', + default: Object.keys(TOOLS), + options: Object.entries(TOOLS).map(([value, t]) => ({ value, label: t.label, hint: t.hint })), + required: false, + condition: ({ ide, delivery }) => ide.some((i) => wantsLooseTools(CLIENTS[i], delivery)) + }) + .add('mcpSetup', { + question: 'Which MCP setup would you like to use?', + type: 'select', + default: 'remote', + options: [ + { value: 'local', label: 'Local', hint: 'will use stdio' }, + { value: 'remote', label: 'Remote', hint: 'will use a remote endpoint' } + ], + condition: ({ ide, delivery, tools }) => + (tools === undefined || tools.includes('mcp')) && + ide.some((i) => wantsMcpConfig(CLIENTS[i], delivery)) + }) + .build(); + +export default defineAddon({ + id: 'ai-tools', + shortDescription: 'Svelte AI tools', + homepage: 'https://svelte.dev/docs/ai', + options, + run: ({ sv, options }) => { + const usePlugin = options.delivery === 'plugin'; + + // pure-plugin path skips the tools question; file-only clients then fall back to all + const selected = options.tools ?? Object.keys(TOOLS); + const selectedSkills = selected.filter((t) => TOOLS[t]?.kind === 'skill'); + const selectedAgents = selected.filter((t) => TOOLS[t]?.kind === 'agent'); + const wantsMcp = selected.includes('mcp'); + + const getLocalConfig = (o?: { + typeLocal?: 'stdio' | 'local'; + env?: boolean; + command?: string | string[]; + args?: string[] | null; + }) => { + const config: any = { + ...(o?.typeLocal ? { type: o.typeLocal } : {}), + command: o?.command ?? 'npx', + ...(o?.env ? { env: {} } : {}), + ...(o?.args === null ? {} : { args: o?.args ?? ['-y', '@sveltejs/mcp'] }) + }; + return config; + }; + const getRemoteConfig = (o?: { typeRemote?: 'http' | 'remote' }) => { + return { + ...(o?.typeRemote ? { type: o.typeRemote } : {}), + url: 'https://mcp.svelte.dev/mcp' + }; + }; + + const filesAdded: string[] = []; + const filesExistingAlready: string[] = []; + + const sharedFiles = getSharedFiles(); + const mcpFiles = sharedFiles.filter((file) => file.include.includes('mcp')); + const skillFiles = sharedFiles.filter((file) => file.include.includes('skills')); + const agentFiles = sharedFiles.filter((file) => file.include.includes('agents')); + const agentFile = mcpFiles.find((file) => file.name === 'AGENTS.md'); + + const addFile = (path: string, contents: string) => { + sv.file(path, (content) => { + if (content) { + filesExistingAlready.push(path); + return false; + } + if (!filesAdded.includes(path)) filesAdded.push(path); + return contents; + }); + }; + + for (const ide of options.ide) { + const client = CLIENTS[ide]; + if (!client) continue; + + // plugin mode: write only the settings file (the plugin bundles MCP + skills + sub-agents) + if (client.pluginSettings && usePlugin) { + const plugin = client.pluginSettings; + sv.file( + plugin.path, + transforms.json(({ data }) => { + data.extraKnownMarketplaces ??= {}; + data.extraKnownMarketplaces[plugin.marketplace] ??= { + source: { source: 'github', repo: plugin.repo } + }; + data.enabledPlugins ??= {}; + data.enabledPlugins[plugin.id] = true; + }) + ); + continue; + } + + if (!client.agentPath) continue; + + const { + mcpOptions, + agentPath, + agentLink, + configPath, + skillsPath, + agentsPath, + agentExtension, + schema, + customData, + extraFiles + } = client; + + if (!filesAdded.includes(agentPath)) { + sv.file(agentPath, (content) => { + if (content) { + filesExistingAlready.push(agentPath); + return false; + } + filesAdded.push(agentPath); + return agentFile?.contents ?? ''; + }); + } + + if (agentLink) addFile(agentLink.path, agentLink.contents); + + const writeMcp = Boolean(mcpOptions) && wantsMcp; + if (configPath && (writeMcp || customData)) { + sv.file( + configPath, + transforms.json(({ data }) => { + if (schema) { + data['$schema'] = schema; + } + + if (customData) { + for (const [key, value] of Object.entries(customData)) { + data[key] = value; + } + } + + if (writeMcp) { + const key = mcpOptions!.serversKey ?? 'mcpServers'; + data[key] ??= {}; + data[key].svelte = + options.mcpSetup === 'local' + ? getLocalConfig(mcpOptions) + : getRemoteConfig(mcpOptions); + } + }) + ); + } + + if (extraFiles) { + for (const extra of extraFiles) { + sv.file( + extra.path, + transforms.json(({ data }) => { + for (const [key, value] of Object.entries(extra.data)) { + data[key] = value; + } + }) + ); + } + } + + if (skillsPath) { + for (const file of skillFiles) { + if (!selectedSkills.includes(file.name.split('/')[0])) continue; + addFile(`${skillsPath}/${file.name}`, file.contents); + } + } + + if (agentsPath) { + for (const file of agentFiles) { + if (!selectedAgents.includes(file.name.replace(REGEX_MD, ''))) continue; + const ext = agentExtension ?? '.md'; + const name = file.name.replace(REGEX_MD, ext); + addFile(`${agentsPath}/${name}`, file.contents); + } + } + } + + if (filesExistingAlready.length > 0) { + log.warn( + `${filesExistingAlready.map((path) => color.path(path)).join(', ')} already exists, we didn't touch ${filesExistingAlready.length > 1 ? 'them' : 'it'}. ` + + `See ${color.website('https://svelte.dev/docs/ai')} for manual setup.` + ); + } + }, + + nextSteps({ options, cwd }) { + const steps = []; + + // plugin enabled + loose files -> the client would see skills/sub-agents twice + for (const ide of options.ide) { + const client = CLIENTS[ide]; + if (!client?.pluginSettings) continue; + + const plugin = client.pluginSettings; + const settingsPath = path.resolve(cwd, plugin.path); + if (!fs.existsSync(settingsPath)) continue; + + let pluginEnabled = false; + try { + pluginEnabled = Boolean( + JSON.parse(fs.readFileSync(settingsPath, 'utf8')).enabledPlugins?.[plugin.id] + ); + } catch { + // ignore unparsable settings + } + if (!pluginEnabled) continue; + + const looseDirs = [client.skillsPath, client.agentsPath].filter( + (dir): dir is string => + Boolean(dir) && + fs.existsSync(path.resolve(cwd, dir!)) && + fs + .readdirSync(path.resolve(cwd, dir!)) + .some((entry) => Object.keys(TOOLS).includes(entry.replace(REGEX_MD, ''))) + ); + if (looseDirs.length === 0) continue; + + steps.push( + `${client.label}: the Svelte plugin is enabled and ${looseDirs.map((d) => color.path(d)).join(' + ')} also exist${looseDirs.length > 1 ? '' : 's'} - remove ${looseDirs.length > 1 ? 'them' : 'it'} (or disable the plugin) to avoid duplicate skills/sub-agents.` + ); + } + + if (options.delivery === 'plugin' && options.ide.includes('claude-code')) { + steps.push( + `Open the project in Claude Code and trust the workspace - the Svelte plugin installs automatically.` + ); + } + + if (options.ide.includes('other')) { + steps.push( + `For other clients: ${color.website(`https://svelte.dev/docs/ai/${options.mcpSetup ?? 'remote'}-setup#Other-clients`)}` + ); + } + + return steps; + } +}); diff --git a/packages/sv/src/addons/index.ts b/packages/sv/src/addons/index.ts index 9be0cb3c0..6deb54db4 100644 --- a/packages/sv/src/addons/index.ts +++ b/packages/sv/src/addons/index.ts @@ -1,9 +1,9 @@ import type { Addon, AddonDefinition } from '../core/config.ts'; +import aiTools from './ai-tools.ts'; import betterAuth from './better-auth.ts'; import drizzle from './drizzle.ts'; import eslint from './eslint.ts'; import experimental from './experimental.ts'; -import mcp from './mcp.ts'; import mdsvex from './mdsvex.ts'; import paraglide from './paraglide.ts'; import playwright from './playwright.ts'; @@ -25,7 +25,7 @@ type OfficialAddons = { mdsvex: Addon; paraglide: Addon; storybook: Addon; - mcp: Addon; + aiTools: Addon; experimental: Addon; }; @@ -43,7 +43,7 @@ export const officialAddons: OfficialAddons = { mdsvex, paraglide, storybook, - mcp, + aiTools, experimental }; diff --git a/packages/sv/src/addons/mcp.ts b/packages/sv/src/addons/mcp.ts deleted file mode 100644 index 548f78e6f..000000000 --- a/packages/sv/src/addons/mcp.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { log } from '@clack/prompts'; -import { color, transforms } from '@sveltejs/sv-utils'; -import { defineAddon, defineAddonOptions } from '../core/config.ts'; -import { getSharedFiles } from '../create/utils.ts'; - -const options = defineAddonOptions() - .add('ide', { - question: 'Which client would you like to use?', - type: 'multiselect', - default: [], - options: [ - { value: 'claude-code', label: 'claude code' }, - { value: 'cursor', label: 'Cursor' }, - { value: 'gemini', label: 'Gemini' }, - { value: 'opencode', label: 'opencode' }, - { value: 'vscode', label: 'VSCode' }, - { value: 'other', label: 'Other' } - ], - required: true - }) - .add('setup', { - question: 'What setup would you like to use?', - type: 'select', - default: 'remote', - options: [ - { value: 'local', label: 'Local', hint: 'will use stdio' }, - { value: 'remote', label: 'Remote', hint: 'will use a remote endpoint' } - ], - required: true, - condition: ({ ide }) => !(ide.length === 1 && ide.includes('opencode')) - }) - .build(); - -export default defineAddon({ - id: 'mcp', - shortDescription: 'Svelte MCP', - homepage: 'https://svelte.dev/docs/mcp', - options, - run: ({ sv, options }) => { - const getLocalConfig = (o?: { - typeLocal?: 'stdio' | 'local'; - env?: boolean; - command?: string | string[]; - args?: string[] | null; - }) => { - const config: any = { - ...(o?.typeLocal ? { type: o.typeLocal } : {}), - command: o?.command ?? 'npx', - ...(o?.env ? { env: {} } : {}), - ...(o?.args === null ? {} : { args: o?.args ?? ['-y', '@sveltejs/mcp'] }) - }; - return config; - }; - const getRemoteConfig = (o?: { typeRemote?: 'http' | 'remote' }) => { - return { - ...(o?.typeRemote ? { type: o.typeRemote } : {}), - url: 'https://mcp.svelte.dev/mcp' - }; - }; - - const configurator: Record< - (typeof options.ide)[number], - | { - schema?: string; - mcpOptions?: { - serversKey?: string; - typeLocal?: 'stdio' | 'local'; - typeRemote?: 'http' | 'remote'; - env?: boolean; - command?: string | string[]; - args?: string[]; - }; - agentPath: string; - configPath: string; - customData?: Record; - extraFiles?: Array<{ path: string; data: Record }>; - } - | { other: true } - > = { - 'claude-code': { - agentPath: 'CLAUDE.md', - configPath: '.mcp.json', - mcpOptions: { - typeLocal: 'stdio', - typeRemote: 'http', - env: true - } - }, - cursor: { - agentPath: 'AGENTS.md', - configPath: '.cursor/mcp.json', - mcpOptions: {} - }, - gemini: { - agentPath: 'GEMINI.md', - configPath: '.gemini/settings.json', - schema: - 'https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json', - mcpOptions: {} - }, - opencode: { - agentPath: 'AGENTS.md', - configPath: '.opencode/opencode.json', - schema: 'https://opencode.ai/config.json', - customData: { plugin: ['@sveltejs/opencode'] }, - extraFiles: [ - { - path: '.opencode/svelte.json', - data: { - $schema: 'https://svelte.dev/opencode/schema.json' - } - } - ] - }, - vscode: { - agentPath: 'AGENTS.md', - configPath: '.vscode/mcp.json', - mcpOptions: { - serversKey: 'servers' - } - }, - other: { - other: true - } - }; - - const filesAdded: string[] = []; - const filesExistingAlready: string[] = []; - - const sharedFiles = getSharedFiles().filter((file) => file.include.includes('mcp')); - const agentFile = sharedFiles.find((file) => file.name === 'AGENTS.md'); - - for (const ide of options.ide) { - const value = configurator[ide]; - - if (value === undefined) continue; - if ('other' in value) continue; - - const { mcpOptions, agentPath, configPath, schema, customData, extraFiles } = value; - - // We only add the agent file if it's not already added - if (!filesAdded.includes(agentPath)) { - sv.file(agentPath, (content) => { - if (content) { - filesExistingAlready.push(agentPath); - return false; - } - filesAdded.push(agentPath); - return agentFile?.contents ?? ''; - }); - } - - sv.file( - configPath, - transforms.json(({ data }) => { - if (schema) { - data['$schema'] = schema; - } - - if (customData) { - for (const [key, value] of Object.entries(customData)) { - data[key] = value; - } - } - - if (mcpOptions) { - const key = mcpOptions.serversKey ?? 'mcpServers'; - data[key] ??= {}; - data[key].svelte = - options.setup === 'local' ? getLocalConfig(mcpOptions) : getRemoteConfig(mcpOptions); - } - }) - ); - - if (extraFiles) { - for (const extra of extraFiles) { - sv.file( - extra.path, - transforms.json(({ data }) => { - for (const [key, value] of Object.entries(extra.data)) { - data[key] = value; - } - }) - ); - } - } - } - - if (filesExistingAlready.length > 0) { - log.warn( - `${filesExistingAlready.map((path) => color.path(path)).join(', ')} already exists, we didn't touch ${filesExistingAlready.length > 1 ? 'them' : 'it'}. ` + - `See ${color.website('https://svelte.dev/docs/mcp/overview#Usage')} for manual setup.` - ); - } - }, - - nextSteps({ options }) { - const steps = []; - - if (options.ide.includes('other')) { - steps.push( - `For other clients: ${color.website(`https://svelte.dev/docs/mcp/${options.setup}-setup#Other-clients`)}` - ); - } - - return steps; - } -}); diff --git a/packages/sv/src/addons/tests/mcp/test.ts b/packages/sv/src/addons/tests/ai-tools/test.ts similarity index 58% rename from packages/sv/src/addons/tests/mcp/test.ts rename to packages/sv/src/addons/tests/ai-tools/test.ts index 3ab08956f..794fcd936 100644 --- a/packages/sv/src/addons/tests/mcp/test.ts +++ b/packages/sv/src/addons/tests/ai-tools/test.ts @@ -1,23 +1,50 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect } from 'vitest'; -import mcp from '../../mcp.ts'; +import aiTools from '../../ai-tools.ts'; import { setupTest } from '../_setup/suite.ts'; const { test, testCases } = setupTest( - { mcp }, + { 'ai-tools': aiTools }, { kinds: [ { type: 'default-local', options: { - mcp: { ide: ['claude-code', 'cursor', 'gemini', 'opencode', 'vscode'], setup: 'local' } + 'ai-tools': { + ide: ['claude-code', 'cursor', 'gemini', 'opencode', 'vscode'], + mcpSetup: 'local', + delivery: 'tools', + tools: ['mcp', 'svelte-code-writer', 'svelte-core-bestpractices', 'svelte-file-editor'] + } } }, { type: 'default-remote', options: { - mcp: { ide: ['claude-code', 'cursor', 'gemini', 'opencode', 'vscode'], setup: 'remote' } + 'ai-tools': { + ide: ['claude-code', 'cursor', 'gemini', 'opencode', 'vscode'], + mcpSetup: 'remote', + delivery: 'tools', + tools: ['mcp', 'svelte-code-writer', 'svelte-core-bestpractices', 'svelte-file-editor'] + } + } + }, + { + type: 'other', + options: { + 'ai-tools': { + ide: ['other'] + } + } + }, + { + type: 'plugin', + options: { + 'ai-tools': { + ide: ['claude-code', 'opencode'], + delivery: 'plugin' + } } } ], @@ -45,14 +72,41 @@ const { test, testCases } = setupTest( } ); -test.concurrent.for(testCases)('mcp $kind.type $variant', (testCase, ctx) => { +test.concurrent.for(testCases)('ai-tools $kind.type $variant', (testCase, ctx) => { const cwd = ctx.cwd(testCase); const getContent = (filePath: string) => { - const cursorPath = path.resolve(cwd, filePath); - return fs.readFileSync(cursorPath, 'utf8'); + const fullPath = path.resolve(cwd, filePath); + return fs.readFileSync(fullPath, 'utf8'); }; + if (testCase.kind.type === 'other') { + // only AGENTS.md is written, everything else is handled via the docs link + expect(fs.existsSync(path.resolve(cwd, 'AGENTS.md'))).toBe(true); + expect(fs.existsSync(path.resolve(cwd, '.mcp.json'))).toBe(false); + expect(fs.existsSync(path.resolve(cwd, '.claude'))).toBe(false); + return; + } + + if (testCase.kind.type === 'plugin') { + // Claude Code: the plugin is enabled via a committed `.claude/settings.json` + const settings = JSON.parse(getContent('.claude/settings.json')); + expect(settings.enabledPlugins).toEqual({ 'svelte@svelte': true }); + expect(settings.extraKnownMarketplaces.svelte.source).toEqual({ + source: 'github', + repo: 'sveltejs/ai-tools' + }); + // the plugin bundles everything, so no individual files are written for Claude + expect(fs.existsSync(path.resolve(cwd, '.mcp.json'))).toBe(false); + expect(fs.existsSync(path.resolve(cwd, '.claude/skills'))).toBe(false); + expect(fs.existsSync(path.resolve(cwd, '.claude/agents'))).toBe(false); + // opencode stays configured through its own plugin + expect(JSON.parse(getContent('.opencode/opencode.json')).plugin).toEqual([ + '@sveltejs/opencode' + ]); + return; + } + const cursorMcpContent = getContent(`.cursor/mcp.json`); // should keep other MCPs @@ -214,4 +268,25 @@ test.concurrent.for(testCases)('mcp $kind.type $variant', (testCase, ctx) => { } `); } + + // CLAUDE.md is a pointer to the shared AGENTS.md + expect(getContent('.claude/CLAUDE.md')).toBe('@../AGENTS.md\n'); + expect(fs.existsSync(path.resolve(cwd, 'AGENTS.md'))).toBe(true); + + // skills should be installed for all clients except opencode (plugin handles it) + const skillDirs = ['.claude/skills', '.cursor/skills', '.gemini/skills', '.github/skills']; + for (const dir of skillDirs) { + expect(fs.existsSync(path.resolve(cwd, dir, 'svelte-code-writer/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.resolve(cwd, dir, 'svelte-core-bestpractices/SKILL.md'))).toBe(true); + } + + // opencode should NOT have skills (plugin handles it) + expect(fs.existsSync(path.resolve(cwd, '.opencode/skills'))).toBe(false); + + // sub-agents should be installed for all clients except opencode + expect(fs.existsSync(path.resolve(cwd, '.claude/agents/svelte-file-editor.md'))).toBe(true); + expect(fs.existsSync(path.resolve(cwd, '.cursor/agents/svelte-file-editor.md'))).toBe(true); + expect(fs.existsSync(path.resolve(cwd, '.gemini/agents/svelte-file-editor.md'))).toBe(true); + expect(fs.existsSync(path.resolve(cwd, '.github/agents/svelte-file-editor.agent.md'))).toBe(true); + expect(fs.existsSync(path.resolve(cwd, '.opencode/agents'))).toBe(false); }); diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index c2f04fccd..9d484f1d5 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -32,7 +32,7 @@ describe('cli', () => { 'better-auth=demo:password,github', 'mdsvex', 'paraglide=languageTags:en,es+demo:yes', - 'mcp=ide:claude-code,cursor,gemini,opencode,vscode,other+setup:local' + 'ai-tools=ide:claude-code,cursor,gemini,opencode,vscode,other+delivery:tools+tools:mcp,svelte-code-writer,svelte-core-bestpractices,svelte-file-editor+mcpSetup:local' // 'storybook' // No storybook addon during tests! ] }, @@ -103,10 +103,29 @@ describe('cli', () => { projectName ); const relativeFiles = fs.readdirSync(testOutputPath, { recursive: true }) as string[]; + + // Files from ai-tools repo (skills, agents) change independently - + // snapshot only file listings, not content + const aiToolsFiles: Record = {}; + const aiToolsPattern = /[\\/](skills|agents)[\\/]/; + for (const relativeFile of relativeFiles) { if (!fs.statSync(path.resolve(testOutputPath, relativeFile)).isFile()) continue; if (['.svg', '.env'].some((ext) => relativeFile.endsWith(ext))) continue; + const normalized = relativeFile.replace(/\\/g, '/'); + + // Group ai-tools files by directory for manifest comparison + if (aiToolsPattern.test(normalized)) { + const match = normalized.match(/(.+\/(?:skills|agents))\/(.*)/); + if (match) { + const [, base, rest] = match; + aiToolsFiles[base] ??= []; + aiToolsFiles[base].push(rest); + } + continue; + } + let generated = fs.readFileSync(path.resolve(testOutputPath, relativeFile), 'utf-8'); if (relativeFile === 'package.json') { const { data: generatedPackageJson } = parse.json(generated); @@ -146,6 +165,15 @@ describe('cli', () => { ); } + // Compare ai-tools file listings against sv-files-snapshots.md manifests + for (const [dir, files] of Object.entries(aiToolsFiles)) { + const manifest = files.sort().join('\n') + '\n'; + await expect(manifest).toMatchFileSnapshot( + path.resolve(snapPath, dir, 'sv-files-snapshots.md'), + `ai-tools manifest "${dir}" does not match snapshot` + ); + } + if (projectName === 'create-with-all-addons' && process.platform !== 'win32') { const installResult = await exec('pnpm', ['install', '--no-frozen-lockfile'], { nodeOptions: { stdio: 'pipe', cwd: testOutputPath } diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/CLAUDE.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/CLAUDE.md new file mode 100644 index 000000000..dba71e970 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/CLAUDE.md @@ -0,0 +1 @@ +@../AGENTS.md diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/agents/sv-files-snapshots.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/agents/sv-files-snapshots.md new file mode 100644 index 000000000..ee6da1faf --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/agents/sv-files-snapshots.md @@ -0,0 +1 @@ +svelte-file-editor.md diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/skills/sv-files-snapshots.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/skills/sv-files-snapshots.md new file mode 100644 index 000000000..b145b1842 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.claude/skills/sv-files-snapshots.md @@ -0,0 +1,11 @@ +svelte-code-writer/SKILL.md +svelte-core-bestpractices/SKILL.md +svelte-core-bestpractices/references/attach.md +svelte-core-bestpractices/references/await-expressions.md +svelte-core-bestpractices/references/bind.md +svelte-core-bestpractices/references/each.md +svelte-core-bestpractices/references/hydratable.md +svelte-core-bestpractices/references/inspect.md +svelte-core-bestpractices/references/render.md +svelte-core-bestpractices/references/snippet.md +svelte-core-bestpractices/references/svelte-reactivity.md diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.cursor/agents/sv-files-snapshots.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.cursor/agents/sv-files-snapshots.md new file mode 100644 index 000000000..ee6da1faf --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.cursor/agents/sv-files-snapshots.md @@ -0,0 +1 @@ +svelte-file-editor.md diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.cursor/skills/sv-files-snapshots.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.cursor/skills/sv-files-snapshots.md new file mode 100644 index 000000000..b145b1842 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.cursor/skills/sv-files-snapshots.md @@ -0,0 +1,11 @@ +svelte-code-writer/SKILL.md +svelte-core-bestpractices/SKILL.md +svelte-core-bestpractices/references/attach.md +svelte-core-bestpractices/references/await-expressions.md +svelte-core-bestpractices/references/bind.md +svelte-core-bestpractices/references/each.md +svelte-core-bestpractices/references/hydratable.md +svelte-core-bestpractices/references/inspect.md +svelte-core-bestpractices/references/render.md +svelte-core-bestpractices/references/snippet.md +svelte-core-bestpractices/references/svelte-reactivity.md diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.gemini/agents/sv-files-snapshots.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.gemini/agents/sv-files-snapshots.md new file mode 100644 index 000000000..ee6da1faf --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.gemini/agents/sv-files-snapshots.md @@ -0,0 +1 @@ +svelte-file-editor.md diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.gemini/skills/sv-files-snapshots.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.gemini/skills/sv-files-snapshots.md new file mode 100644 index 000000000..b145b1842 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.gemini/skills/sv-files-snapshots.md @@ -0,0 +1,11 @@ +svelte-code-writer/SKILL.md +svelte-core-bestpractices/SKILL.md +svelte-core-bestpractices/references/attach.md +svelte-core-bestpractices/references/await-expressions.md +svelte-core-bestpractices/references/bind.md +svelte-core-bestpractices/references/each.md +svelte-core-bestpractices/references/hydratable.md +svelte-core-bestpractices/references/inspect.md +svelte-core-bestpractices/references/render.md +svelte-core-bestpractices/references/snippet.md +svelte-core-bestpractices/references/svelte-reactivity.md diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.github/agents/sv-files-snapshots.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.github/agents/sv-files-snapshots.md new file mode 100644 index 000000000..4ed244d57 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.github/agents/sv-files-snapshots.md @@ -0,0 +1 @@ +svelte-file-editor.agent.md diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.github/skills/sv-files-snapshots.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.github/skills/sv-files-snapshots.md new file mode 100644 index 000000000..b145b1842 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/.github/skills/sv-files-snapshots.md @@ -0,0 +1,11 @@ +svelte-code-writer/SKILL.md +svelte-core-bestpractices/SKILL.md +svelte-core-bestpractices/references/attach.md +svelte-core-bestpractices/references/await-expressions.md +svelte-core-bestpractices/references/bind.md +svelte-core-bestpractices/references/each.md +svelte-core-bestpractices/references/hydratable.md +svelte-core-bestpractices/references/inspect.md +svelte-core-bestpractices/references/render.md +svelte-core-bestpractices/references/snippet.md +svelte-core-bestpractices/references/svelte-reactivity.md diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/AGENTS.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/AGENTS.md index fcdbb88bb..92e91a6f2 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/AGENTS.md +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/AGENTS.md @@ -2,7 +2,7 @@ - **Language**: TypeScript - **Package Manager**: npm -- **Add-ons**: prettier, eslint, vitest, playwright, tailwindcss, sveltekit-adapter, drizzle, better-auth, mdsvex, paraglide, mcp +- **Add-ons**: prettier, eslint, vitest, playwright, tailwindcss, sveltekit-adapter, drizzle, better-auth, mdsvex, paraglide, ai-tools --- diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/CLAUDE.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/CLAUDE.md deleted file mode 100644 index fcdbb88bb..000000000 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/CLAUDE.md +++ /dev/null @@ -1,31 +0,0 @@ -## Project Configuration - -- **Language**: TypeScript -- **Package Manager**: npm -- **Add-ons**: prettier, eslint, vitest, playwright, tailwindcss, sveltekit-adapter, drizzle, better-auth, mdsvex, paraglide, mcp - ---- - -You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively: - -## Available Svelte MCP Tools: - -### 1. list-sections - -Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. -When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections. - -### 2. get-documentation - -Retrieves full documentation content for specific sections. Accepts single or multiple sections. -After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task. - -### 3. svelte-autofixer - -Analyzes Svelte code and returns issues and suggestions. -You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned. - -### 4. playground-link - -Generates a Svelte Playground link with the provided code. -After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/GEMINI.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/GEMINI.md index fcdbb88bb..92e91a6f2 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/GEMINI.md +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/GEMINI.md @@ -2,7 +2,7 @@ - **Language**: TypeScript - **Package Manager**: npm -- **Add-ons**: prettier, eslint, vitest, playwright, tailwindcss, sveltekit-adapter, drizzle, better-auth, mdsvex, paraglide, mcp +- **Add-ons**: prettier, eslint, vitest, playwright, tailwindcss, sveltekit-adapter, drizzle, better-auth, mdsvex, paraglide, ai-tools --- diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md index 579128e8d..a09633c4e 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md @@ -15,7 +15,7 @@ To recreate this project with the same configuration: ```sh # recreate this project -npx sv@0.0.0 create --template minimal --types ts --add prettier eslint vitest="usages:unit,component" playwright tailwindcss="plugins:typography,forms" sveltekit-adapter="adapter:node" drizzle="database:sqlite+sqlite:libsql" better-auth="demo:password,github" mdsvex paraglide="languageTags:en,es+demo:yes" mcp="ide:claude-code,cursor,gemini,opencode,vscode,other+setup:local" --no-install packages/sv/.test-output/cli/create-with-all-addons +npx sv@0.0.0 create --template minimal --types ts --add prettier eslint vitest="usages:unit,component" playwright tailwindcss="plugins:typography,forms" sveltekit-adapter="adapter:node" drizzle="database:sqlite+sqlite:libsql" better-auth="demo:password,github" mdsvex paraglide="languageTags:en,es+demo:yes" ai-tools="ide:claude-code,cursor,gemini,opencode,vscode,other+delivery:tools+tools:mcp,svelte-code-writer,svelte-core-bestpractices,svelte-file-editor+mcpSetup:local" --no-install packages/sv/.test-output/cli/create-with-all-addons ``` ## Developing diff --git a/packages/sv/src/core/common.ts b/packages/sv/src/core/common.ts index dce24eab7..e9403ea19 100644 --- a/packages/sv/src/core/common.ts +++ b/packages/sv/src/core/common.ts @@ -13,6 +13,9 @@ import pkg from '../../package.json' with { type: 'json' }; import type { LoadedAddon } from './config.ts'; import { UnsupportedError } from './errors.ts'; +// a file whose whole content is a single @import (e.g. CLAUDE.md -> @../AGENTS.md) +const RX_IMPORT_ONLY = /^\s*@\S+\s*$/; + const NO_PREFIX = '--no-'; let options: readonly Option[] = []; @@ -288,7 +291,7 @@ export function updateAgent( packageManager: string, loadedAddons: LoadedAddon[] ): void { - const agentFiles = ['AGENTS.md', 'GEMINI.md', 'CLAUDE.md']; + const agentFiles = ['AGENTS.md', 'GEMINI.md', '.claude/CLAUDE.md']; const languageLabel = language === 'typescript' @@ -319,6 +322,8 @@ export function updateAgent( if (!fs.existsSync(agentPath)) continue; let content = fs.readFileSync(agentPath, 'utf-8'); + // pointer files (e.g. CLAUDE.md -> @AGENTS.md) get the config through their import + if (RX_IMPORT_ONLY.test(content)) continue; content = content.replace(existingSectionPattern, ''); content = configSection + content; fs.writeFileSync(agentPath, content); diff --git a/packages/sv/src/create/index.ts b/packages/sv/src/create/index.ts index 9a1cb3877..644f6a488 100644 --- a/packages/sv/src/create/index.ts +++ b/packages/sv/src/create/index.ts @@ -23,7 +23,7 @@ export type File = { contents: string; }; -export type Condition = TemplateType | LanguageType | 'playground' | 'mcp'; +export type Condition = TemplateType | LanguageType | 'playground' | 'mcp' | 'skills' | 'agents'; export type Common = { files: Array<{ diff --git a/packages/sv/src/create/shared/+agents/svelte-file-editor.md b/packages/sv/src/create/shared/+agents/svelte-file-editor.md new file mode 100644 index 000000000..7085c8147 --- /dev/null +++ b/packages/sv/src/create/shared/+agents/svelte-file-editor.md @@ -0,0 +1,67 @@ +--- +name: svelte-file-editor +description: Specialized Svelte 5 code editor. MUST BE USED PROACTIVELY when creating, editing, or reviewing any .svelte file or .svelte.ts/.svelte.js module and MUST use the tools from the MCP server or the `svelte-file-editor` skill if they are available. Fetches relevant documentation and validates code using the Svelte MCP server tools. +--- + +You are a Svelte 5 expert responsible for writing, editing, and validating Svelte components and modules. You have access to the Svelte MCP server which provides documentation and code analysis tools. Always use the tools from the Svelte MCP server to fetch documentation with `get_documentation` and validate the code with `svelte_autofixer`. If the autofixer returns any issue or suggestions try to solve them. + +If the MCP tools are not available you can use the `svelte-code-writer` skill to learn how to use the `@sveltejs/mcp` cli to access the same tools. + +If the skill is not available you can run `npx @sveltejs/mcp@latest -y --help` to learn how to use it. + +## Available MCP tools + +### 1. list-sections + +Lists all available Svelte 5 and SvelteKit documentation sections with titles and paths. Use this first to discover what documentation is available. + +### 2. get-documentation + +Retrieves full documentation for specified sections. Accepts a single section name or an array of section names. Use after `list-sections` to fetch relevant docs for the task at hand. + +**Example sections:** `$state`, `$derived`, `$effect`, `$props`, `$bindable`, `snippets`, `routing`, `load functions` + +### 3. svelte-autofixer + +Analyzes Svelte code and returns suggestions to fix issues. Pass the component code directly to this tool. It will detect common mistakes like: + +- Using `$effect` instead of `$derived` for computations +- Missing cleanup in effects +- Svelte 4 syntax (`on:click`, `export let`, ``) +- Missing keys in `{#each}` blocks +- And more + +## Workflow + +When invoked to work on a Svelte file: + +### 1. Gather context (if needed) + +If you're uncertain about Svelte 5 syntax or patterns, use the MCP tools: + +1. Call `list-sections` to see available documentation +2. Call `get-documentation` with relevant section names + +### 2. Read the target file + +Read the file to understand the current implementation. + +### 3. Make changes + +Apply edits following Svelte 5 best practices: + +### 4. Validate changes + +After editing, ALWAYS call `svelte-autofixer` with the updated code to check for issues. + +### 5. Fix any issues + +If the autofixer reports problems, fix them and re-validate until no issues remain. + +## Output format + +After completing your work, provide: + +1. Summary of changes made +2. Any issues found and fixed by the autofixer +3. Recommendations for further improvements (if any) diff --git a/packages/sv/src/create/shared/+skills/svelte-code-writer/SKILL.md b/packages/sv/src/create/shared/+skills/svelte-code-writer/SKILL.md new file mode 100644 index 000000000..56d938370 --- /dev/null +++ b/packages/sv/src/create/shared/+skills/svelte-code-writer/SKILL.md @@ -0,0 +1,64 @@ +--- +name: svelte-code-writer +description: CLI tools for Svelte 5 documentation lookup and code analysis. MUST be used whenever creating, editing or analyzing any Svelte component (.svelte) or Svelte module (.svelte.ts/.svelte.js). If possible, this skill should be executed within the svelte-file-editor agent for optimal results. +--- + +## CLI tools + +You have access to `@sveltejs/mcp` CLI for Svelte-specific assistance. Use these commands via `npx`: + +### List documentation sections + +```bash +npx @sveltejs/mcp list-sections +``` + +Lists all available Svelte 5 and SvelteKit documentation sections with titles and paths. + +### Get documentation + +```bash +npx @sveltejs/mcp get-documentation ",,..." +``` + +Retrieves full documentation for specified sections. Use after `list-sections` to fetch relevant docs. + +**Example:** + +```bash +npx @sveltejs/mcp get-documentation "$state,$derived,$effect" +``` + +### Svelte autofixer + +```bash +npx @sveltejs/mcp svelte-autofixer "" [options] +``` + +Analyzes Svelte code and suggests fixes for common issues. + +**Options:** + +- `--async` - Enable async Svelte mode (default: false) +- `--svelte-version` - Target version: 4 or 5 (default: 5) + +**Examples:** + +```bash +# Analyze inline code (escape $ as \$) +npx @sveltejs/mcp svelte-autofixer '' + +# Analyze a file +npx @sveltejs/mcp svelte-autofixer ./src/lib/Component.svelte + +# Target Svelte 4 +npx @sveltejs/mcp svelte-autofixer ./Component.svelte --svelte-version 4 +``` + +**Important:** When passing code with runes (`$state`, `$derived`, etc.) via the terminal, escape the `$` character as `\$` to prevent shell variable substitution. + +## Workflow + +1. **Uncertain about syntax?** Run `list-sections` then `get-documentation` for relevant topics +2. **Reviewing/debugging?** Run `svelte-autofixer` on the code to detect issues +3. **Always validate** - Run `svelte-autofixer` before finalizing any Svelte component diff --git a/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/SKILL.md b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/SKILL.md new file mode 100644 index 000000000..be855ce34 --- /dev/null +++ b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/SKILL.md @@ -0,0 +1,176 @@ +--- +name: svelte-core-bestpractices +description: Guidance on writing fast, robust, modern Svelte code. Load this skill whenever in a Svelte project and asked to write/edit or analyze a Svelte component or module. Covers reactivity, event handling, styling, integration with libraries and more. +--- + +## `$state` + +Only use the `$state` rune for variables that should be _reactive_ — in other words, variables that cause an `$effect`, `$derived` or template expression to update. Everything else can be a normal variable. + +Objects and arrays (`$state({...})` or `$state([...])`) are made deeply reactive, meaning mutation will trigger updates. This has a trade-off: in exchange for fine-grained reactivity, the objects must be proxied, which has performance overhead. In cases where you're dealing with large objects that are only ever reassigned (rather than mutated), use `$state.raw` instead. This is often the case with API responses, for example. + +## `$derived` + +To compute something from state, use `$derived` rather than `$effect`: + +```js +// do this +let square = $derived(num * num); + +// don't do this +let square; + +$effect(() => { + square = num * num; +}); +``` + +> [!NOTE] `$derived` is given an expression, _not_ a function. If you need to use a function (because the expression is complex, for example) use `$derived.by`. + +Deriveds are writable — you can assign to them, just like `$state`, except that they will re-evaluate when their expression changes. + +If the derived expression is an object or array, it will be returned as-is — it is _not_ made deeply reactive. You can, however, use `$state` inside `$derived.by` in the rare cases that you need this. + +## `$effect` + +Effects are an escape hatch and should mostly be avoided. In particular, avoid updating state inside effects. + +- If you need to sync state to an external library such as D3, it is often neater to use [`{@attach ...}`](references/attach.md) +- If you need to run some code in response to user interaction, put the code directly in an event handler or use a [function binding](references/bind.md) as appropriate +- If you need to log values for debugging purposes, use [`$inspect`](references/inspect.md) +- If you need to observe something external to Svelte, use [`createSubscriber`](references/svelte-reactivity.md) + +Never wrap the contents of an effect in `if (browser) {...}` or similar — effects do not run on the server. + +## `$props` + +Treat props as though they will change. For example, values that depend on props should usually use `$derived`: + +```js +// @errors: 2451 +let { type } = $props(); + +// do this +let color = $derived(type === 'danger' ? 'red' : 'green'); + +// don't do this — `color` will not update if `type` changes +let color = type === 'danger' ? 'red' : 'green'; +``` + +## `$inspect.trace` + +`$inspect.trace` is a debugging tool for reactivity. If something is not updating properly or running more than it should you can add `$inspect.trace(label)` as the first line of an `$effect` or `$derived.by` (or any function they call) to trace their dependencies and discover which one triggered an update. + +## Events + +Any element attribute starting with `on` is treated as an event listener: + +```svelte + + + + + + + +``` + +If you need to attach listeners to `window` or `document` you can use `` and ``: + +```svelte + + +``` + +Avoid using `onMount` or `$effect` for this. + +## Snippets + +[Snippets](references/snippet.md) are a way to define reusable chunks of markup that can be instantiated with the [`{@render ...}`](references/render.md) tag, or passed to components as props. They must be declared within the template. + +```svelte +{#snippet greeting(name)} +

hello {name}!

+{/snippet} + +{@render greeting('world')} +``` + +> [!NOTE] Snippets declared at the top level of a component (i.e. not inside elements or blocks) can be referenced inside ` + +
...
+``` + +An element can have any number of attachments. + +## Attachment factories + +A useful pattern is for a function, such as `tooltip` in this example, to _return_ an attachment (demo: + +```svelte + + + + + + +``` + +Since the `tooltip(content)` expression runs inside an [effect](https://svelte.dev/docs/svelte/$effect/llms.txt), the attachment will be destroyed and recreated whenever `content` changes. The same thing would happen for any state read _inside_ the attachment function when it first runs. (If this isn't what you want, see [Controlling when attachments re-run](#Controlling-when-attachments-re-run).) + +## Inline attachments + +Attachments can also be created inline (demo: + +```svelte + + { + const context = canvas.getContext('2d'); + + $effect(() => { + context.fillStyle = color; + context.fillRect(0, 0, canvas.width, canvas.height); + }); + }} +> +``` + +> [!NOTE] +> The nested effect runs whenever `color` changes, while the outer effect (where `canvas.getContext(...)` is called) only runs once, since it doesn't read any reactive state. + +## Conditional attachments + +Falsy values like `false` or `undefined` are treated as no attachment, enabling conditional usage: + +```svelte +
...
+``` + +## Passing attachments to components + +When used on a component, `{@attach ...}` will create a prop whose key is a [`Symbol`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). If the component then [spreads](https://svelte.dev/tutorial/svelte/spread-props/llms.txt) props onto an element, the element will receive those attachments. + +This allows you to create _wrapper components_ that augment elements (demo: + +```svelte + + + + + +``` + +```svelte + + + + + + +``` + +## Controlling when attachments re-run + +Attachments, unlike [actions](https://svelte.dev/docs/svelte/use/llms.txt), are fully reactive: `{@attach foo(bar)}` will re-run on changes to `foo` _or_ `bar` (or any state read inside `foo`): + +```js +// @errors: 7006 2304 2552 +function foo(bar) { + return (node) => { + veryExpensiveSetupWork(node); + update(node, bar); + }; +} +``` + +In the rare case that this is a problem (for example, if `foo` does expensive and unavoidable setup work) consider passing the data inside a function and reading it in a child effect: + +```js +// @errors: 7006 2304 2552 +function foo(+++getBar+++) { + return (node) => { + veryExpensiveSetupWork(node); + ++++ $effect(() => { + update(node, getBar()); + });+++ + } +} +``` + +## Creating attachments programmatically + +To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](https://svelte.dev/docs/svelte/svelte-attachments/llms.txt#createAttachmentKey). + +## Converting actions to attachments + +If you're using a library that only provides actions, you can convert them to attachments with [`fromAction`](https://svelte.dev/docs/svelte/svelte-attachments/llms.txt#fromAction), allowing you to (for example) use them with components. diff --git a/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/await-expressions.md b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/await-expressions.md new file mode 100644 index 000000000..dd7d8c6e7 --- /dev/null +++ b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/await-expressions.md @@ -0,0 +1,187 @@ +As of Svelte 5.36, you can use the `await` keyword inside your components in three places where it was previously unavailable: + +- at the top level of your component's ` + + + + +

{a} + {b} = {await add(a, b)}

+``` + + + +...if you increment `a`, the contents of the `

` will _not_ immediately update to read this — + +```html +

2 + 2 = 3

+``` + +— instead, the text will update to `2 + 2 = 4` when `add(a, b)` resolves. + +Updates can overlap — a fast update will be reflected in the UI while an earlier slow update is still ongoing. + +## Concurrency + +Svelte will do as much asynchronous work as it can in parallel. For example if you have two `await` expressions in your markup... + +```svelte +

{await one(x)}

+

{await two(y)}

+``` + +...both functions will run at the same time, as they are independent expressions, even though they are _visually_ sequential. + +This does not apply to sequential `await` expressions inside your ` + + + +{#if open} + + open = false} /> +{/if} +``` + +## Caveats + +As an experimental feature, the details of how `await` is handled (and related APIs like `$effect.pending()`) are subject to breaking changes outside of a semver major release, though we intend to keep such changes to a bare minimum. + +## Breaking changes + +Effects run in a slightly different order when the `experimental.async` option is `true`. Specifically, _block_ effects like `{#if ...}` and `{#each ...}` now run before an `$effect.pre` or `beforeUpdate` in the same component, which means that in very rare situations. diff --git a/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/bind.md b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/bind.md new file mode 100644 index 000000000..f847cdc2d --- /dev/null +++ b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/bind.md @@ -0,0 +1,22 @@ +## Function bindings + +You can also use `bind:property={get, set}`, where `get` and `set` are functions, allowing you to perform validation and transformation: + +```svelte + value, + (v) => value = v.toLowerCase()} +/> +``` + +In the case of readonly bindings like [dimension bindings](#Dimensions), the `get` value should be `null`: + +```svelte +
...
+``` + +> [!NOTE] +> Function bindings are available in Svelte 5.9.0 and newer. diff --git a/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/each.md b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/each.md new file mode 100644 index 000000000..283b754f1 --- /dev/null +++ b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/each.md @@ -0,0 +1,42 @@ +## Keyed each blocks + +```svelte + +{#each expression as name (key)}...{/each} +``` + +```svelte + +{#each expression as name, index (key)}...{/each} +``` + +If a _key_ expression is provided — which must uniquely identify each list item — Svelte will use it to intelligently update the list when data changes by inserting, moving and deleting items, rather than adding or removing items at the end and updating the state in the middle. + +The key can be any object, but strings and numbers are recommended since they allow identity to persist when the objects themselves change. + +```svelte +{#each items as item (item.id)} +
  • {item.name} x {item.qty}
  • +{/each} + + +{#each items as item, i (item.id)} +
  • {i + 1}: {item.name} x {item.qty}
  • +{/each} +``` + +You can freely use destructuring and rest patterns in each blocks. + +```svelte +{#each items as { id, name, qty }, i (id)} +
  • {i + 1}: {name} x {qty}
  • +{/each} + +{#each objects as { id, ...rest }} +
  • {id}
  • +{/each} + +{#each items as [id, ...rest]} +
  • {id}
  • +{/each} +``` diff --git a/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/hydratable.md b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/hydratable.md new file mode 100644 index 000000000..acc665eae --- /dev/null +++ b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/hydratable.md @@ -0,0 +1,103 @@ +In Svelte, when you want to render asynchronous content data on the server, you can simply `await` it. This is great! However, it comes with a pitfall: when hydrating that content on the client, Svelte has to redo the asynchronous work, which blocks hydration for however long it takes: + +```svelte + + +

    {user.name}

    +``` + +That's silly, though. If we've already done the hard work of getting the data on the server, we don't want to get it again during hydration on the client. `hydratable` is a low-level API built to solve this problem. You probably won't need this very often — it will be used behind the scenes by whatever datafetching library you use. For example, it powers [remote functions in SvelteKit](https://svelte.dev/docs/kit/remote-functions/llms.txt). + +To fix the example above: + +```svelte + + +

    {user.name}

    +``` + +This API can also be used to provide access to random or time-based values that are stable between server rendering and hydration. For example, to get a random number that doesn't update on hydration: + +```ts +import { hydratable } from 'svelte'; +const rand = hydratable('random', () => Math.random()); +``` + +If you're a library author, be sure to prefix the keys of your `hydratable` values with the name of your library so that your keys don't conflict with other libraries. + +## Serialization + +All data returned from a `hydratable` function must be serializable. But this doesn't mean you're limited to JSON — Svelte uses [`devalue`](https://npmjs.com/package/devalue), which can serialize all sorts of things including `Map`, `Set`, `URL`, and `BigInt`. Check the documentation page for a full list. In addition to these, thanks to some Svelte magic, you can also fearlessly use promises: + +```svelte + + +{await promises.one} +{await promises.two} +``` + +## CSP + +`hydratable` adds an inline ` + + + +``` + + + +On updates, a stack trace will be printed, making it easy to find the origin of a state change (unless you're in the playground, due to technical limitations). + +## $inspect(...).with + +`$inspect(...)` returns an object with a `with` method, which you can invoke with a callback that will then be invoked instead of `console.log`. The first argument to the callback is either `"init"` or `"update"`; subsequent arguments are the values passed to `$inspect`: + + + +```svelte + + + + +``` + + + +## $inspect.trace(...) + +This rune, added in 5.14, causes the surrounding function to be _traced_ in development. Any time the function re-runs as part of an [effect](https://svelte.dev/docs/svelte/$effect/llms.txt) or a [derived](https://svelte.dev/docs/svelte/$derived/llms.txt), information will be printed to the console about which pieces of reactive state caused the effect to fire. + +```svelte + +``` + +`$inspect.trace` takes an optional first argument which will be used as the label. diff --git a/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/render.md b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/render.md new file mode 100644 index 000000000..f8290e953 --- /dev/null +++ b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/render.md @@ -0,0 +1,35 @@ +To render a [snippet](https://svelte.dev/docs/svelte/snippet/llms.txt), use a `{@render ...}` tag. + +```svelte +{#snippet sum(a, b)} +

    {a} + {b} = {a + b}

    +{/snippet} + +{@render sum(1, 2)} +{@render sum(3, 4)} +{@render sum(5, 6)} +``` + +The expression can be an identifier like `sum`, or an arbitrary JavaScript expression: + +```svelte +{@render (cool ? coolSnippet : lameSnippet)()} +``` + +## Optional snippets + +If the snippet is potentially undefined — for example, because it's an incoming prop — then you can use optional chaining to only render it when it _is_ defined: + +```svelte +{@render children?.()} +``` + +Alternatively, use an [`{#if ...}`](https://svelte.dev/docs/svelte/if/llms.txt) block with an `:else` clause to render fallback content: + +```svelte +{#if children} + {@render children()} +{:else} +

    fallback content

    +{/if} +``` diff --git a/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/snippet.md b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/snippet.md new file mode 100644 index 000000000..4cdece32e --- /dev/null +++ b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/snippet.md @@ -0,0 +1,400 @@ +```svelte + +{#snippet name()}...{/snippet} +``` + +```svelte + +{#snippet name(param1, param2, paramN)}...{/snippet} +``` + +Snippets, and [render tags](https://svelte.dev/docs/svelte/@render/llms.txt), are a way to create reusable chunks of markup inside your components. Instead of writing duplicative code like this... + +```svelte +{#each images as image} + {#if image.href} + +
    + {image.caption} +
    {image.caption}
    +
    +
    + {:else} +
    + {image.caption} +
    {image.caption}
    +
    + {/if} +{/each} +``` + +...you can write this: + +```svelte +{#snippet figure(image)} +
    + {image.caption} +
    {image.caption}
    +
    +{/snippet} + +{#each images as image} + {#if image.href} + + {@render figure(image)} + + {:else} + {@render figure(image)} + {/if} +{/each} +``` + +Like function declarations, snippets can have an arbitrary number of parameters, which can have default values, and you can destructure each parameter. You cannot use rest parameters, however. + +## Snippet scope + +Snippets can be declared anywhere inside your component. They can reference values declared outside themselves, for example in the ` + +{#snippet hello(name)} +

    hello {name}! {message}!

    +{/snippet} + +{@render hello('alice')} +{@render hello('bob')} +``` + + + +...and they are 'visible' to everything in the same lexical scope (i.e. siblings, and children of those siblings): + +```svelte +
    + {#snippet x()} + {#snippet y()}...{/snippet} + + + {@render y()} + {/snippet} + + + {@render y()} +
    + + +{@render x()} +``` + +Snippets can reference themselves and each other: + + + +```svelte + +{#snippet blastoff()} + 🚀 +{/snippet} + +{#snippet countdown(n)} + {#if n > 0} + {n}... + {@render countdown(n - 1)} + {:else} + {@render blastoff()} + {/if} +{/snippet} + +{@render countdown(10)} +``` + + + +## Passing snippets to components + +### Explicit props + +Within the template, snippets are values just like any other. As such, they can be passed to components as props: + + + +```svelte + + + +{#snippet header()} + fruit + qty + price + total +{/snippet} + +{#snippet row(d)} + {d.name} + {d.qty} + {d.price} + {d.qty * d.price} +{/snippet} + + +``` + +```svelte + + + +
    + {#if header} + + {@render header()} + + {/if} + + + {#each data as d} + {@render row(d)} + {/each} + +
    + + +``` + + + +Think about it like passing content instead of data to a component. The concept is similar to slots in web components. + +### Implicit props + +As an authoring convenience, snippets declared directly _inside_ a component implicitly become props _on_ the component: + + + +```svelte + + + + + {#snippet header()} + + + + + {/snippet} + + {#snippet row(d)} + + + + + {/snippet} +
    fruitqtypricetotal{d.name}{d.qty}{d.price}{d.qty * d.price}
    +``` + +```svelte + + + + + {#if header} + + {@render header()} + + {/if} + + + {#each data as d} + {@render row(d)} + {/each} + +
    + + +``` + + + +### Implicit `children` snippet + +Any content inside the component tags that is _not_ a snippet declaration implicitly becomes part of the `children` snippet: + + + +```svelte + + + + +``` + +```svelte + + + + + +``` + + + +> [!NOTE] Note that you cannot have a prop called `children` if you also have content inside the component — for this reason, you should avoid having props with that name + +### Optional snippet props + +You can declare snippet props as being optional. You can either use optional chaining to not render anything if the snippet isn't set... + +```svelte + + +{@render children?.()} +``` + +...or use an `#if` block to render fallback content: + +```svelte + + +{#if children} + {@render children()} +{:else} + fallback content +{/if} +``` + +## Typing snippets + +Snippets implement the `Snippet` interface imported from `'svelte'`: + +```svelte + +``` + +With this change, red squigglies will appear if you try and use the component without providing a `data` prop and a `row` snippet. Notice that the type argument provided to `Snippet` is a tuple, since snippets can have multiple parameters. + +We can tighten things up further by declaring a generic, so that `data` and `row` refer to the same type: + +```svelte + +``` + +## Exporting snippets + +Snippets declared at the top level of a `.svelte` file can be exported from a ` + +{@render add(1, 2)} + +``` + +```svelte + + + +{#snippet add(a, b)} + {a} + {b} = {a + b} +{/snippet} +``` + + + +> [!NOTE] +> This requires Svelte 5.5.0 or newer + +## Programmatic snippets + +Snippets can be created programmatically with the [`createRawSnippet`](https://svelte.dev/docs/svelte/svelte/llms.txt#createRawSnippet) API. This is intended for advanced use cases. + +## Snippets and slots + +In Svelte 4, content can be passed to components using [slots](https://svelte.dev/docs/svelte/legacy-slots/llms.txt). Snippets are more powerful and flexible, and so slots have been deprecated in Svelte 5. diff --git a/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/svelte-reactivity.md b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/svelte-reactivity.md new file mode 100644 index 000000000..7f2cd5a05 --- /dev/null +++ b/packages/sv/src/create/shared/+skills/svelte-core-bestpractices/references/svelte-reactivity.md @@ -0,0 +1,61 @@ +## createSubscriber + +
    + +Available since 5.7.0 + +
    + +Returns a `subscribe` function that integrates external event-based systems with Svelte's reactivity. +It's particularly useful for integrating with web APIs like `MediaQuery`, `IntersectionObserver`, or `WebSocket`. + +If `subscribe` is called inside an effect (including indirectly, for example inside a getter), +the `start` callback will be called with an `update` function. Whenever `update` is called, the effect re-runs. + +If `start` returns a cleanup function, it will be called when the effect is destroyed. + +If `subscribe` is called in multiple effects, `start` will only be called once as long as the effects +are active, and the returned teardown function will only be called when all effects are destroyed. + +It's best understood with an example. Here's an implementation of [`MediaQuery`](https://svelte.dev/docs/svelte/svelte-reactivity/llms.txt#MediaQuery): + +```js +// @errors: 7031 +import { createSubscriber } from 'svelte/reactivity'; +import { on } from 'svelte/events'; + +export class MediaQuery { + #query; + #subscribe; + + constructor(query) { + this.#query = window.matchMedia(`(${query})`); + + this.#subscribe = createSubscriber((update) => { + // when the `change` event occurs, re-run any effects that read `this.current` + const off = on(this.#query, 'change', update); + + // stop listening when all the effects are destroyed + return () => off(); + }); + } + + get current() { + // This makes the getter reactive, if read in an effect + this.#subscribe(); + + // Return the current state of the query, whether or not we're in an effect + return this.#query.matches; + } +} +``` + +
    + +```dts +function createSubscriber( + start: (update: () => void) => (() => void) | void +): () => void; +``` + +
    diff --git a/scripts/update-dependencies.js b/scripts/update-dependencies.js index 338a63938..d0692c3f7 100644 --- a/scripts/update-dependencies.js +++ b/scripts/update-dependencies.js @@ -152,7 +152,7 @@ await updatePackageFiles('packages/sv/src/create/templates', 'package.template.j // Update shared package.json files await updatePackageFiles('packages/sv/src/create/shared', 'package.json', 'shared'); -// Fetch the latest AGENTS.md from the MCP repo +// Fetch the latest AGENTS.md from the ai-tools repo const agents_response = await fetch( 'https://raw.githubusercontent.com/sveltejs/ai-tools/refs/heads/main/tools/instructions/AGENTS.md' ); @@ -160,3 +160,56 @@ fs.writeFileSync( path.resolve('packages', 'sv', 'src', 'create', 'shared', '+mcp', 'AGENTS.md'), await agents_response.text() ); + +// Fetch the latest skills from the ai-tools repo +const skillsBase = + 'https://raw.githubusercontent.com/sveltejs/ai-tools/refs/heads/main/tools/skills'; +const sharedSkillsBase = path.resolve('packages', 'sv', 'src', 'create', 'shared', '+skills'); + +/** @param {string} skillPath */ +async function fetchSkillFile(skillPath) { + const response = await fetch(`${skillsBase}/${skillPath}`); + const dest = path.resolve(sharedSkillsBase, skillPath); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, await response.text()); +} + +// Fetch skill files using the GitHub API to discover all files +const skillsApiBase = 'https://api.github.com/repos/sveltejs/ai-tools/contents/tools/skills'; + +/** @param {string} apiUrl */ +async function fetchSkillDir(apiUrl) { + const response = await fetch(apiUrl); + const entries = await response.json(); + for (const entry of entries) { + if (entry.type === 'file') { + const skillPath = entry.path.replace('tools/skills/', ''); + console.log(` - fetching skill: ${styleText('blue', skillPath)}`); + await fetchSkillFile(skillPath); + } else if (entry.type === 'dir') { + await fetchSkillDir(entry.url); + } + } +} + +console.log(`Fetching ${styleText(['cyanBright', 'bold'], 'skills')} from ai-tools repo`); +await fetchSkillDir(skillsApiBase); + +// Fetch the latest agents from the ai-tools repo +const agentsBase = + 'https://raw.githubusercontent.com/sveltejs/ai-tools/refs/heads/main/tools/agents'; +const sharedAgentsBase = path.resolve('packages', 'sv', 'src', 'create', 'shared', '+agents'); +const agentsApiBase = 'https://api.github.com/repos/sveltejs/ai-tools/contents/tools/agents'; + +console.log(`Fetching ${styleText(['cyanBright', 'bold'], 'agents')} from ai-tools repo`); +const agentsResponse = await fetch(agentsApiBase); +const agentEntries = await agentsResponse.json(); +for (const entry of agentEntries) { + if (entry.type === 'file') { + const agentName = entry.name; + console.log(` - fetching agent: ${styleText('blue', agentName)}`); + const response = await fetch(`${agentsBase}/${agentName}`); + fs.mkdirSync(sharedAgentsBase, { recursive: true }); + fs.writeFileSync(path.resolve(sharedAgentsBase, agentName), await response.text()); + } +} From 9b3fa1794ca6f93f738caff2821706049f262893 Mon Sep 17 00:00:00 2001 From: "jyc.dev" Date: Thu, 30 Jul 2026 12:53:19 +0200 Subject: [PATCH 07/32] fix: drop obsolete pnpm allowBuilds entries and surface real format errors (#1198) * fix: drop obsolete pnpm allowBuilds entries and surface real format errors * test: install generated project outside the repo workspace --- .changeset/great-pans-shave.md | 5 ++ packages/sv/src/addons/sveltekit-adapter.ts | 2 +- packages/sv/src/addons/tailwindcss.ts | 7 +-- packages/sv/src/cli/tests/cli.ts | 9 ++-- .../create-experimental/pnpm-workspace.yaml | 1 - .../pnpm-workspace.yaml | 2 - packages/sv/src/core/formatFiles.ts | 46 +++++++++++++------ 7 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 .changeset/great-pans-shave.md delete mode 100644 packages/sv/src/cli/tests/snapshots/create-with-all-addons/pnpm-workspace.yaml diff --git a/.changeset/great-pans-shave.md b/.changeset/great-pans-shave.md new file mode 100644 index 000000000..164ead88a --- /dev/null +++ b/.changeset/great-pans-shave.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +fix: run prettier directly instead of through the package manager, and stop allowing builds for packages that no longer have install scripts (`@tailwindcss/oxide`, `sharp`) diff --git a/packages/sv/src/addons/sveltekit-adapter.ts b/packages/sv/src/addons/sveltekit-adapter.ts index 8edc9996a..6ddd60d18 100644 --- a/packages/sv/src/addons/sveltekit-adapter.ts +++ b/packages/sv/src/addons/sveltekit-adapter.ts @@ -113,7 +113,7 @@ export default defineAddon({ sv.devDependency('wrangler', '^4.97.0'); if (packageManager === 'pnpm') { - sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.allowBuilds('workerd', 'sharp')); + sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.allowBuilds('workerd')); } // default to jsonc diff --git a/packages/sv/src/addons/tailwindcss.ts b/packages/sv/src/addons/tailwindcss.ts index e5f167f59..65e957ef2 100644 --- a/packages/sv/src/addons/tailwindcss.ts +++ b/packages/sv/src/addons/tailwindcss.ts @@ -1,4 +1,4 @@ -import { pnpm, transforms } from '@sveltejs/sv-utils'; +import { transforms } from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; import { addPrettierTailwind, prettierConfigPath } from './common.ts'; @@ -31,14 +31,11 @@ export default defineAddon({ shortDescription: 'css framework', homepage: 'https://tailwindcss.com', options, - run: ({ sv, options, file, isKit, directory, dependencyVersion, language, packageManager }) => { + run: ({ sv, options, file, isKit, directory, dependencyVersion, language }) => { const prettierInstalled = Boolean(dependencyVersion('prettier')); sv.devDependency('tailwindcss', '^4.3.0'); sv.devDependency('@tailwindcss/vite', '^4.3.0'); - if (packageManager === 'pnpm') { - sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.allowBuilds('@tailwindcss/oxide')); - } if (prettierInstalled) sv.devDependency('prettier-plugin-tailwindcss', '^0.8.0'); diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 9d484f1d5..1fcb40ccf 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -175,9 +175,12 @@ describe('cli', () => { } if (projectName === 'create-with-all-addons' && process.platform !== 'win32') { - const installResult = await exec('pnpm', ['install', '--no-frozen-lockfile'], { - nodeOptions: { stdio: 'pipe', cwd: testOutputPath } - }); + // the generated project lives inside this repo, so it must not join its workspace + const installResult = await exec( + 'pnpm', + ['install', '--no-frozen-lockfile', '--ignore-workspace'], + { nodeOptions: { stdio: 'pipe', cwd: testOutputPath } } + ); expect( installResult.exitCode, `pnpm install failed:\n stdout: ${installResult.stdout}\n stderr: ${installResult.stderr}` diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/pnpm-workspace.yaml b/packages/sv/src/cli/tests/snapshots/create-experimental/pnpm-workspace.yaml index 8dbdc836d..1e2db028d 100644 --- a/packages/sv/src/cli/tests/snapshots/create-experimental/pnpm-workspace.yaml +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/pnpm-workspace.yaml @@ -1,3 +1,2 @@ onlyBuiltDependencies: - workerd - - sharp diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/pnpm-workspace.yaml b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/pnpm-workspace.yaml deleted file mode 100644 index fd050a463..000000000 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -onlyBuiltDependencies: - - '@tailwindcss/oxide' diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts index 39d7eb2e3..932fe8024 100644 --- a/packages/sv/src/core/formatFiles.ts +++ b/packages/sv/src/core/formatFiles.ts @@ -11,24 +11,40 @@ export async function formatFiles(options: { const { start, stop } = p.spinner(); start('Formatting modified files'); - const args = ['prettier', '--write', '--ignore-unknown', ...options.filesToFormat]; - const cmd = resolveCommand(options.packageManager, 'execute-local', args)!; + const args = ['--write', '--ignore-unknown', ...options.filesToFormat]; - try { - const result = await exec(cmd.command, cmd.args, { - nodeOptions: { cwd: options.cwd, stdio: 'pipe' }, - throwOnError: true - }); - if (result.exitCode !== 0) { - stop('Failed to format files'); - p.log.error(result.stderr); - return; - } - } catch (e) { + // tinyexec resolves `prettier` from `node_modules/.bin`; going through the package + // manager can fail on unrelated state (e.g. pnpm refusing to run while build scripts + // are unapproved), but it's the only way to reach binaries under Yarn PnP + let result = await run('prettier', args, options.cwd); + if (result.notFound) { + const cmd = resolveCommand(options.packageManager, 'execute-local', ['prettier', ...args])!; + result = await run(cmd.command, cmd.args, options.cwd); + } + + if (result.error !== undefined) { stop('Failed to format files'); - // @ts-expect-error - p.log.error(e?.output?.stderr || 'unknown error'); + p.log.error(result.error); return; } stop('Successfully formatted modified files'); } + +async function run( + command: string, + args: string[], + cwd: string +): Promise<{ error?: string; notFound?: boolean }> { + try { + await exec(command, args, { nodeOptions: { cwd, stdio: 'pipe' }, throwOnError: true }); + return {}; + } catch (e) { + // @ts-expect-error tinyexec rethrows the spawn error as-is + if (e?.code === 'ENOENT') return { notFound: true, error: `${command} not found` }; + // @ts-expect-error `output` is only present on tinyexec's `NonZeroExitError` + const output = e?.output as { stderr?: string; stdout?: string } | undefined; + // failures can land on either stream, so report both + const message = [output?.stderr, output?.stdout].filter(Boolean).join('\n').trim(); + return { error: message || (e instanceof Error ? e.message : 'unknown error') }; + } +} From 86a4cced4b8be35cff5b46d67a09839fa6eca775 Mon Sep 17 00:00:00 2001 From: "jyc.dev" Date: Thu, 30 Jul 2026 13:06:19 +0200 Subject: [PATCH 08/32] fix(experimental): create manages `#lib` and the rest of the SvelteKit 3 shape (#1199) * feat(sv-utils): add SvelteKit 3 helpers Version detection, the `$lib` -> `#lib` move and the generated `$app/tsconfig`, so add-ons stop hardcoding kit-version-specific shapes. * fix(core): make `runsAfter` actually order add-ons It sorted by number of constraints, so an add-on could still run before one it declared it runs after. Topologically sort instead, seeded with the previous order so unconstrained add-ons keep their relative position. * fix(experimental): create manages `#lib` and the rest of the SvelteKit 3 shape - skip `handleRenderingErrors`/`explicitEnvironmentVariables`, removed in kit 3 - extend `$app/tsconfig` and own `include`, keeping deliberate compiler option overrides - rewrite `$lib` to `#lib` and declare the subpath imports Vite resolves from - drizzle drops the removed `typescript.config` hook; `defineEnvVars` moves to `@sveltejs/kit/env` - better-auth and paraglide emit the right lib prefix and route type * test(cli): cover `kit@next` with a real install, build and check No snapshot: the point is that the project works, not what it looks like. * Update packages/sv/src/addons/drizzle.ts Co-authored-by: Scott Wu * Update packages/sv-utils/src/kit3.ts Co-authored-by: Scott Wu * test(cli): cover better-auth on `kit@next`, finish the `KIT3_TSCONFIG_DEFAULT` rename better-auth has the most `#lib` imports, so it gets its own `kit@next` install/build/check case. It can't join the existing one: kit treats any `remote.js` as a remote module, including the one `jose` ships, so `remoteFunctions` breaks the build. Also rewords the changeset and updates the two remaining `KIT3_TSCONFIG_INHERITED` references. * test(cli): pin the `kit@next` prereleases the experimental cases run against * test(cli): drop the better-auth `kit@next` case * chore: fix indentation * test(cli): let the generated project install a fresh prerelease * test(cli): track the `next` tag instead of a pinned prerelease * chore: trim the changesets to one line each * chore: changeset wording --------- Co-authored-by: Scott Wu --- .changeset/nervous-poems-clap.md | 5 ++ .changeset/olive-geese-repeat.md | 5 ++ packages/sv-utils/api-surface.md | 15 +++++ packages/sv-utils/src/env.ts | 24 +++---- packages/sv-utils/src/index.ts | 9 +++ packages/sv-utils/src/kit3.ts | 43 ++++++++++++ packages/sv/src/addons/better-auth.ts | 12 ++-- packages/sv/src/addons/drizzle.ts | 37 ++++++++--- packages/sv/src/addons/experimental.ts | 66 ++++++++++++++++++- packages/sv/src/addons/paraglide.ts | 36 ++++++---- .../sv/src/addons/tests/experimental/test.ts | 28 ++++++-- packages/sv/src/cli/tests/cli.ts | 59 ++++++++++++++++- packages/sv/src/core/engine.ts | 36 ++++++++-- 13 files changed, 324 insertions(+), 51 deletions(-) create mode 100644 .changeset/nervous-poems-clap.md create mode 100644 .changeset/olive-geese-repeat.md create mode 100644 packages/sv-utils/src/kit3.ts diff --git a/.changeset/nervous-poems-clap.md b/.changeset/nervous-poems-clap.md new file mode 100644 index 000000000..ae2c939dc --- /dev/null +++ b/.changeset/nervous-poems-clap.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +fix(experimental): `@sveltejs/kit@next` projects now install, build and type-check - `#lib` imports instead of `$lib`, a `tsconfig` extending `$app/tsconfig`, and no options kit 3 removed diff --git a/.changeset/olive-geese-repeat.md b/.changeset/olive-geese-repeat.md new file mode 100644 index 000000000..3f9762e6c --- /dev/null +++ b/.changeset/olive-geese-repeat.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/sv-utils': patch +--- + +feat: add SvelteKit 3 helpers - `isKit3`, `resolveLibPrefix`, `libSubpathImports` diff --git a/packages/sv-utils/api-surface.md b/packages/sv-utils/api-surface.md index c34e1e572..919db761e 100644 --- a/packages/sv-utils/api-surface.md +++ b/packages/sv-utils/api-surface.md @@ -854,6 +854,16 @@ type DefineEnv = { }; declare function defineEnv({ sv, cwd, dependencyVersion }: DefineEnvContext): DefineEnv; + +declare function isKit3(kitRange: string | undefined): boolean; + +declare function resolveLibPrefix(kitRange: string | undefined): '#lib' | '$lib'; + +declare function libSubpathImports(libDir: string): Record; + +declare const KIT3_TSCONFIG = '$app/tsconfig'; + +declare const KIT3_TSCONFIG_DEFAULT: Record; type ColorInput = string | string[]; declare const color: { addon: (str: ColorInput) => string; @@ -886,6 +896,8 @@ export { COMMANDS, type Comments, type ConfigFileReader, + KIT3_TSCONFIG, + KIT3_TSCONFIG_DEFAULT, type Package, type SvelteAst, type SvelteConfigKind, @@ -905,9 +917,11 @@ export { downloadJson, fileExists, index_d_exports$2 as html, + isKit3, isVersionUnsupportedBelow, index_d_exports$3 as js, json_d_exports as json, + libSubpathImports, loadFile, loadPackageJson, minVersion, @@ -915,6 +929,7 @@ export { pnpm_d_exports as pnpm, resolveCommand, resolveCommandArray, + resolveLibPrefix, sanitizeName, saveFile, splitVersion, diff --git a/packages/sv-utils/src/env.ts b/packages/sv-utils/src/env.ts index 84a7d8c3e..1bca67158 100644 --- a/packages/sv-utils/src/env.ts +++ b/packages/sv-utils/src/env.ts @@ -1,4 +1,5 @@ import { fileExists } from './files.ts'; +import { isKit3 } from './kit3.ts'; import { coerceVersion } from './semver.ts'; import { svelteConfig, type ConfigFileReader, type SvFileApi } from './svelte-config.ts'; import type { AstTypes } from './tooling/index.ts'; @@ -15,10 +16,8 @@ export function resolveEnvMode({ explicitEnvFlag: boolean; }): EnvMode { if (!kitRange) return 'legacy'; - if (kitRange === 'next') return 'declared'; - const { major } = coerceVersion(kitRange); - if (major !== undefined && major >= 3) return 'declared'; - if (major === 2 && explicitEnvFlag) return 'declared'; + if (isKit3(kitRange)) return 'declared'; + if (coerceVersion(kitRange).major === 2 && explicitEnvFlag) return 'declared'; return 'legacy'; } @@ -110,23 +109,23 @@ function getOrCreateVariablesObject( * just call `define`/`reference` and never deal with the legacy-vs-declared distinction themselves. */ export function defineEnv({ sv, cwd, dependencyVersion }: DefineEnvContext): DefineEnv { - const mode = resolveEnvMode({ - kitRange: dependencyVersion('@sveltejs/kit'), - explicitEnvFlag: readExplicitEnvFlag(cwd) - }); + const kitRange = dependencyVersion('@sveltejs/kit'); + const mode = resolveEnvMode({ kitRange, explicitEnvFlag: readExplicitEnvFlag(cwd) }); const language = fileExists(cwd, 'tsconfig.json') ? 'ts' : 'js'; - return _bindEnv({ sv, mode, language }); + return _bindEnv({ sv, mode, language, kit3: isKit3(kitRange) }); } /** @internal The mode-resolved core, exported for filesystem-free tests. */ export function _bindEnv({ sv, mode, - language + language, + kit3 = false }: { sv: SvFileApi; mode: EnvMode; language: 'ts' | 'js'; + kit3?: boolean; }): DefineEnv { const declared = new Map(); @@ -138,7 +137,10 @@ export function _bindEnv({ const envPath = `src/env.${language}`; sv.file(envPath, (content) => transforms.script(({ ast, js }) => { - js.imports.addNamed(ast, { from: '@sveltejs/kit/hooks', imports: ['defineEnvVars'] }); + js.imports.addNamed(ast, { + from: kit3 ? '@sveltejs/kit/env' : '@sveltejs/kit/hooks', + imports: ['defineEnvVars'] + }); const variables = getOrCreateVariablesObject(ast, js); const entry = js.object.property(variables, { name: spec.name, diff --git a/packages/sv-utils/src/index.ts b/packages/sv-utils/src/index.ts index b91c7b553..1c9e286bd 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -87,6 +87,15 @@ export { // Env access (abstracts over legacy `$env/dynamic/*` vs declared `$app/env/*` + `src/env.ts`) export { defineEnv } from './env.ts'; +// Kit 3 specifics (version detection, `$lib` -> `#lib`, the generated tsconfig) +export { + KIT3_TSCONFIG, + KIT3_TSCONFIG_DEFAULT, + isKit3, + libSubpathImports, + resolveLibPrefix +} from './kit3.ts'; + // Terminal styling export { color } from './color.ts'; diff --git a/packages/sv-utils/src/kit3.ts b/packages/sv-utils/src/kit3.ts new file mode 100644 index 000000000..158751041 --- /dev/null +++ b/packages/sv-utils/src/kit3.ts @@ -0,0 +1,43 @@ +import { coerceVersion } from './semver.ts'; + +/** Whether a `@sveltejs/kit` range resolves to v3+, including the `next` dist-tag. */ +export function isKit3(kitRange: string | undefined): boolean { + if (!kitRange) return false; + if (kitRange === 'next') return true; + const { major } = coerceVersion(kitRange); + return major !== undefined && major >= 3; +} + +/** The prefix for `src/lib` imports. Kit 3 dropped the built-in `$lib` alias for `#lib` subpath imports. */ +export function resolveLibPrefix(kitRange: string | undefined): '#lib' | '$lib' { + return isKit3(kitRange) ? '#lib' : '$lib'; +} + +/** The `package.json#imports` entries backing `#lib`. `libDir` is workspace-relative, e.g. `src/lib`. */ +export function libSubpathImports(libDir: string): Record { + return { '#lib': `./${libDir}/index.js`, '#lib/*': `./${libDir}/*` }; +} + +/** The config kit 3 generates into `node_modules`, replacing `.svelte-kit/tsconfig.json`. */ +export const KIT3_TSCONFIG = '$app/tsconfig'; + +/** + * Options `$app/tsconfig` already sets. A local copy of the same value is noise, but a different + * value is a deliberate override and must stay - so only drop keys whose value matches. + */ +export const KIT3_TSCONFIG_DEFAULT: Record = { + allowImportingTsExtensions: true, + allowJs: true, + checkJs: true, + esModuleInterop: true, + forceConsistentCasingInFileNames: true, + isolatedModules: true, + module: 'esnext', + moduleDetection: 'force', + moduleResolution: 'bundler', + noEmit: true, + resolveJsonModule: true, + skipLibCheck: true, + target: 'esnext', + verbatimModuleSyntax: true +}; diff --git a/packages/sv/src/addons/better-auth.ts b/packages/sv/src/addons/better-auth.ts index ef5d21dc8..ea1910e24 100644 --- a/packages/sv/src/addons/better-auth.ts +++ b/packages/sv/src/addons/better-auth.ts @@ -1,5 +1,6 @@ import { log } from '@clack/prompts'; import { + resolveLibPrefix, type AstTypes, Walker, color, @@ -44,6 +45,7 @@ export default defineAddon({ runsAfter('experimental'); }, run: ({ sv, cwd, language, options, directory, dependencyVersion, file }) => { + const lib = resolveLibPrefix(dependencyVersion('@sveltejs/kit')); const svelteVersion = dependencyVersion('svelte'); const svelte5 = !!svelteVersion && coerceVersion(svelteVersion).major === 5; const [ts, s5] = createPrinter(language === 'ts', svelte5); @@ -122,7 +124,7 @@ export default defineAddon({ sv.file( `${directory.lib}/server/auth.${language}`, transforms.script(({ ast, comments, js }) => { - js.imports.addNamed(ast, { from: '$lib/server/db', imports: [d1 ? 'getDb' : 'db'] }); + js.imports.addNamed(ast, { from: `${lib}/server/db`, imports: [d1 ? 'getDb' : 'db'] }); js.imports.addNamed(ast, { from: '$app/server', imports: ['getRequestEvent'] }); js.imports.addNamed(ast, { from: 'better-auth/svelte-kit', @@ -238,7 +240,7 @@ export default defineAddon({ sv.file( 'src/app.d.ts', transforms.script(({ ast, comments, js }) => { - if (d1) js.imports.addNamed(ast, { imports: ['createAuth'], from: '$lib/server/auth' }); + if (d1) js.imports.addNamed(ast, { imports: ['createAuth'], from: `${lib}/server/auth` }); js.imports.addNamed(ast, { imports: ['User', 'Session'], from: 'better-auth', @@ -286,7 +288,7 @@ export default defineAddon({ }); js.imports.addNamed(ast, { imports: [d1 ? 'createAuth' : 'auth'], - from: '$lib/server/auth' + from: `${lib}/server/auth` }); env.importEnv(ast, js, ['building']); @@ -413,7 +415,7 @@ export default defineAddon({ import { fail, redirect } from '@sveltejs/kit'; ${ts("import type { Actions } from './$types';")} ${ts("import type { PageServerLoad } from './$types';")} - ${!d1 ? "import { auth } from '$lib/server/auth';" : ''} + ${!d1 ? `import { auth } from '${lib}/server/auth';` : ''} ${needsAPIError ? "import { APIError } from 'better-auth/api';" : ''} export const load${ts(': PageServerLoad')} = (event) => { @@ -502,7 +504,7 @@ export default defineAddon({ import { redirect } from '@sveltejs/kit'; ${ts("import type { Actions } from './$types';")} ${ts("import type { PageServerLoad } from './$types';")} - ${!d1 ? "import { auth } from '$lib/server/auth';" : ''} + ${!d1 ? `import { auth } from '${lib}/server/auth';` : ''} export const load${ts(': PageServerLoad')} = (event) => { if (!event.locals.user) { diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index 1ff089565..f064b0bac 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -8,7 +8,8 @@ import { fileExists, createPrinter, svelteConfig, - defineEnv + defineEnv, + isKit3 } from '@sveltejs/sv-utils'; import crypto from 'node:crypto'; import fs from 'node:fs'; @@ -300,15 +301,33 @@ export default defineAddon({ }) ); - svelteConfig.edit({ sv, cwd }, ({ override, js }) => { - override({ - typescript: { - config: js.common.parseExpression( - `(config) => { config.include.push('../drizzle.config.${language}')}` - ) - } + // kit 3 dropped the `typescript.config` hook's `include` (and deprecates the hook itself), + // so the project's own ts/jsconfig has to cover the drizzle config + if (isKit3(dependencyVersion('@sveltejs/kit'))) { + const configFile = language === 'ts' ? 'tsconfig.json' : 'jsconfig.json'; + if (fileExists(cwd, configFile)) { + sv.file( + configFile, + transforms.json(({ data }) => { + const include: string[] = (data.include ??= ['src']); + if (!include.includes(`drizzle.config.${language}`)) { + include.push(`drizzle.config.${language}`); + } + }) + ); + } + } else { + // prior to kit 3 + svelteConfig.edit({ sv, cwd }, ({ override, js }) => { + override({ + typescript: { + config: js.common.parseExpression( + `(config) => { config.include.push('../drizzle.config.${language}')}` + ) + } + }); }); - }); + } sv.file( paths['database schema'], diff --git a/packages/sv/src/addons/experimental.ts b/packages/sv/src/addons/experimental.ts index dc07d1654..9cef4ad0e 100644 --- a/packages/sv/src/addons/experimental.ts +++ b/packages/sv/src/addons/experimental.ts @@ -1,4 +1,15 @@ -import { isVersionUnsupportedBelow, loadPackageJson, svelteConfig } from '@sveltejs/sv-utils'; +import { + KIT3_TSCONFIG, + KIT3_TSCONFIG_DEFAULT, + fileExists, + isVersionUnsupportedBelow, + libSubpathImports, + loadPackageJson, + svelteConfig, + transforms +} from '@sveltejs/sv-utils'; +import fs from 'node:fs'; +import path from 'node:path'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; // Single source of truth, keyed by flag name. `path` defaults to `experimental.`; `off` opts out @@ -8,10 +19,13 @@ const FEATURES: Record = { async: { label: 'async', hint: 'await in components', path: 'compilerOptions.experimental.async' }, // prettier-ignore remoteFunctions: { label: 'remote functions' }, explicitEnvironmentVariables: { label: 'explicit environment variables', hint: 'kit ^2 only', inNext: false }, // prettier-ignore - handleRenderingErrors: { label: 'rendering error boundaries' }, + handleRenderingErrors: { label: 'rendering error boundaries', hint: 'kit ^2 only', inNext: false }, // prettier-ignore forkPreloads: { label: 'forked preloading', off: true } }; +// files whose `$lib` imports are rewritten to `#lib` +const SOURCE_EXTENSIONS = ['.svelte', '.svelte.ts', '.svelte.js', '.ts', '.js', '.svx', '.md']; + // kit 3 raises these peer floors; bump only when the project is below them (never downgrade). const KIT3_PEERS = { vite: '^8.0.0', @@ -47,7 +61,7 @@ export default defineAddon({ setup: ({ runsAfter }) => runsAfter('sveltekitAdapter'), - run: ({ sv, cwd, options, language, dependencyVersion }) => { + run: ({ sv, cwd, options, language, directory, dependencyVersion }) => { const kitNext = options.versions.includes('kit'); if (kitNext) { @@ -65,6 +79,38 @@ export default defineAddon({ } } + if (kitNext) { + // kit 3 serves the generated config from `$app/tsconfig` and no longer supplies `include` + for (const name of ['tsconfig.json', 'jsconfig.json']) { + if (!fileExists(cwd, name)) continue; + sv.file( + name, + transforms.json(({ data }) => { + data.extends = KIT3_TSCONFIG; + data.include ??= [directory.src]; + for (const [key, value] of Object.entries(data.compilerOptions ?? {})) { + // a differing value is a deliberate override and stays + if (KIT3_TSCONFIG_DEFAULT[key] === value) delete data.compilerOptions[key]; + } + }) + ); + } + + // `$lib` is gone in favour of `#lib` subpath imports, which Vite resolves from `package.json` + sv.file( + 'package.json', + transforms.json(({ data }) => { + data.imports = { ...libSubpathImports(directory.lib), ...data.imports }; + }) + ); + // safe here: templates are already written and every add-on emitting `$lib` runs later + for (const relative of sourceFiles(cwd, directory.src)) { + sv.file(relative, (content) => + content.includes('$lib') ? content.replaceAll('$lib', '#lib') : false + ); + } + } + const config: Record = {}; for (const [name, f] of Object.entries(FEATURES)) { if (!options.features.includes(name)) continue; @@ -79,3 +125,17 @@ export default defineAddon({ svelteConfig.edit({ sv, cwd }, ({ override }) => override(config)); } }); + +/** Workspace-relative source files under `src`, for the `$lib` -> `#lib` rewrite. */ +function sourceFiles(cwd: string, src: string): string[] { + const root = path.resolve(cwd, src); + if (!fs.existsSync(root)) return []; + return fs + .readdirSync(root, { recursive: true }) + .map((entry) => path.join(src, entry as string)) + .filter( + (relative) => + SOURCE_EXTENSIONS.some((ext) => relative.endsWith(ext)) && + fs.statSync(path.resolve(cwd, relative)).isFile() + ); +} diff --git a/packages/sv/src/addons/paraglide.ts b/packages/sv/src/addons/paraglide.ts index 12048cf09..ccbfe1ddd 100644 --- a/packages/sv/src/addons/paraglide.ts +++ b/packages/sv/src/addons/paraglide.ts @@ -1,5 +1,13 @@ import { log } from '@clack/prompts'; -import { color, createPrinter, dedent, type SvelteAst, transforms } from '@sveltejs/sv-utils'; +import { + color, + createPrinter, + dedent, + isKit3, + resolveLibPrefix, + type SvelteAst, + transforms +} from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; import { addToDemoPage } from './common.ts'; @@ -50,11 +58,17 @@ export default defineAddon({ shortDescription: 'i18n', homepage: 'https://inlang.com/m/gerre34r/library-inlang-paraglideJs', options, - setup: ({ isKit, unsupported }) => { + setup: ({ isKit, unsupported, runsAfter }) => { if (!isKit) unsupported('Requires SvelteKit'); + // it picks the kit-3 shape off the version `experimental` writes + runsAfter('experimental'); }, - run: ({ sv, options, file, language, directory }) => { + run: ({ sv, options, file, language, directory, dependencyVersion }) => { const [ts] = createPrinter(language === 'ts'); + const kitRange = dependencyVersion('@sveltejs/kit'); + const lib = resolveLibPrefix(kitRange); + // kit 3 renamed the `Pathname` route type to `Path` + const pathType = isKit3(kitRange) ? 'Path' : 'Pathname'; const paraglideOutDir = `${directory.lib}/paraglide`; sv.devDependency('@inlang/paraglide-js', '^2.18.2'); @@ -80,7 +94,7 @@ export default defineAddon({ `src/hooks.${language}`, transforms.script(({ ast, comments, js }) => { js.imports.addNamed(ast, { - from: '$lib/paraglide/runtime', + from: `${lib}/paraglide/runtime`, imports: ['deLocalizeUrl'] }); @@ -120,11 +134,11 @@ export default defineAddon({ `src/hooks.server.${language}`, transforms.script(({ ast, comments, js }) => { js.imports.addNamed(ast, { - from: '$lib/paraglide/server', + from: `${lib}/paraglide/server`, imports: ['paraglideMiddleware'] }); js.imports.addNamed(ast, { - from: '$lib/paraglide/runtime', + from: `${lib}/paraglide/runtime`, imports: ['getTextDirection'] }); @@ -196,13 +210,13 @@ export default defineAddon({ transforms.svelteScript({ language }, ({ ast, svelte, js }) => { js.imports.addNamed(ast.instance.content, { imports: ['locales', 'localizeHref'], - from: '$lib/paraglide/runtime' + from: `${lib}/paraglide/runtime` }); js.imports.addNamed(ast.instance.content, { imports: ['page'], from: '$app/state' }); js.imports.addNamed(ast.instance.content, { imports: ['resolve'], from: '$app/paths' }); if (language === 'ts') { js.imports.addNamed(ast.instance.content, { - imports: ['Pathname'], + imports: [pathType], from: '$app/types', isType: true }); @@ -212,7 +226,7 @@ export default defineAddon({ dedent`
    {#each locales as locale (locale)} - {locale} + {locale} {/each}
    `, { language } @@ -229,13 +243,13 @@ export default defineAddon({ transforms.svelteScript({ language }, ({ ast, svelte, js }) => { js.imports.addNamed(ast.instance.content, { imports: { m: 'm' }, - from: '$lib/paraglide/messages.js' + from: `${lib}/paraglide/messages.js` }); js.imports.addNamed(ast.instance.content, { imports: { setLocale: 'setLocale' }, - from: '$lib/paraglide/runtime' + from: `${lib}/paraglide/runtime` }); // add localized message diff --git a/packages/sv/src/addons/tests/experimental/test.ts b/packages/sv/src/addons/tests/experimental/test.ts index d42df2dbc..c849dc9d7 100644 --- a/packages/sv/src/addons/tests/experimental/test.ts +++ b/packages/sv/src/addons/tests/experimental/test.ts @@ -1,3 +1,4 @@ +import { parse } from '@sveltejs/sv-utils'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { expect } from 'vitest'; @@ -10,7 +11,7 @@ const { test, testCases } = setupTest( { kinds: [ { - // kit@next selected + every feature: explicitEnvironmentVariables must be dropped (gone in kit 3) + // kit@next selected + every feature: the flags removed in kit 3 must be dropped type: 'next-all', options: { [addonId]: { @@ -50,23 +51,42 @@ test.concurrent.for(testCases)('experimental $kind.type $variant', (testCase, { const source = readFileSync(config, 'utf8'); const pkg = readFileSync(join(cwd, 'package.json'), 'utf8'); + const tsconfigPath = ['tsconfig.json', 'jsconfig.json'] + .map((name) => join(cwd, name)) + .find((file) => existsSync(file)); + const tsconfig = tsconfigPath ? parse.json(readFileSync(tsconfigPath, 'utf8')).data : undefined; + if (testCase.kind.type === 'next-all') { expect(JSON.parse(pkg).devDependencies['@sveltejs/kit']).toBe('next'); + if (tsconfig) { + expect(tsconfig.extends).toBe('$app/tsconfig'); + expect(tsconfig.include).toStrictEqual(['src']); + expect(tsconfig.compilerOptions).not.toHaveProperty('checkJs'); + } // the adapter must follow kit onto its `next` line (it peers on kit's major) expect(JSON.parse(pkg).devDependencies['@sveltejs/adapter-auto']).toBe('next'); expect(source).toMatch('async: true'); expect(source).toMatch('remoteFunctions: true'); - expect(source).toMatch('handleRenderingErrors: true'); expect(source).toMatch('forkPreloads: true'); - // removed from experimental in kit 3, so it must be skipped when kit@next is chosen + // kit 3 no longer provides `$lib` on its own - sources move to `#lib` subpath imports + expect(JSON.parse(pkg).imports).toMatchObject({ '#lib': expect.any(String) }); + const libIndex = ['src/lib/index.ts', 'src/lib/index.js'] + .map((name) => join(cwd, name)) + .find((file) => existsSync(file))!; + expect(readFileSync(libIndex, 'utf8')).not.toMatch('$lib'); + expect(source).not.toMatch('alias'); + // removed from experimental in kit 3, so they must be skipped when kit@next is chosen expect(source).not.toMatch('explicitEnvironmentVariables'); + expect(source).not.toMatch('handleRenderingErrors'); } else if (testCase.kind.type === 'kit2-defaults') { expect(JSON.parse(pkg).devDependencies['@sveltejs/kit']).not.toBe('next'); + if (tsconfig) expect(tsconfig.extends).toBe('./.svelte-kit/tsconfig.json'); expect(source).toMatch('async: true'); expect(source).toMatch('remoteFunctions: true'); expect(source).toMatch('explicitEnvironmentVariables: true'); + // kit 2 provides `$lib` itself, so nothing is rewritten + expect(JSON.parse(pkg).imports).toBeUndefined(); // not selected -> absent expect(source).not.toMatch('forkPreloads'); - expect(source).not.toMatch('handleRenderingErrors'); } }); diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 1fcb40ccf..0bac5f647 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -46,6 +46,17 @@ describe('cli', () => { 'experimental=versions:+features:explicitEnvironmentVariables' ] }, + { + // guards the `kit@next` shape against upstream churn: no snapshot (the point is that it + // installs, builds and type-checks, not what it looks like) + projectName: 'create-experimental-next', + snapshot: false, + args: [ + '--add', + 'drizzle=database:sqlite+sqlite:libsql', + 'experimental=versions:kit+features:async,remoteFunctions' + ] + }, { projectName: '@my-org/sv', template: 'addon', @@ -57,7 +68,17 @@ describe('cli', () => { 'should create a new project with name $projectName', { timeout: 240_000 }, async (testCase) => { - const { projectName, args, template = 'minimal' } = testCase; + const { + projectName, + args, + template = 'minimal', + snapshot = true + } = testCase as { + projectName: string; + args: string[]; + template?: string; + snapshot?: boolean; + }; const testOutputPath = path.relative( monoRepoPath, @@ -102,7 +123,9 @@ describe('cli', () => { 'snapshots', projectName ); - const relativeFiles = fs.readdirSync(testOutputPath, { recursive: true }) as string[]; + const relativeFiles = snapshot + ? (fs.readdirSync(testOutputPath, { recursive: true }) as string[]) + : []; // Files from ai-tools repo (skills, agents) change independently - // snapshot only file listings, not content @@ -200,6 +223,38 @@ describe('cli', () => { ).toBe(0); } + // `kit@next` moves fast - only a real install/build/check catches options it removed + if (projectName === 'create-experimental-next' && process.platform !== 'win32') { + const run = (cmd: string, cmdArgs: string[]) => + exec(cmd, cmdArgs, { nodeOptions: { stdio: 'pipe', cwd: testOutputPath } }); + + const install = await run('pnpm', [ + 'install', + '--no-frozen-lockfile', + // without this pnpm walks up and installs the sv monorepo instead of this project + '--ignore-workspace', + // ...which also loses the workspace's `minimumReleaseAgeExclude`, so a prerelease + // published in the last day would be refused + '--config.minimumReleaseAge=0' + ]); + expect( + install.exitCode, + `pnpm install failed:\n stdout: ${install.stdout}\n stderr: ${install.stderr}` + ).toBe(0); + + const build = await run('pnpm', ['build']); + expect( + build.exitCode, + `build failed on kit@next:\n stdout: ${build.stdout}\n stderr: ${build.stderr}` + ).toBe(0); + + const check = await run('pnpm', ['check']); + expect( + check.exitCode, + `svelte-check failed on kit@next:\n stdout: ${check.stdout}\n stderr: ${check.stderr}` + ).toBe(0); + } + if (projectName === 'create-experimental') { const read = (p: string) => fs.readFileSync(path.resolve(testOutputPath, p), 'utf-8'); const envFile = read('src/env.ts'); diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 38d0bf554..8540c56ba 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -319,14 +319,38 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } }; } -// orders addons by putting addons that don't require any other addon in the front. -// This is a drastic simplification, as this could still cause some inconvenient circumstances, -// but works for now in contrary to the previous implementation +/** + * Orders add-ons so every `runsAfter` is honoured, keeping the original order between add-ons that + * don't constrain each other. Cycles and unknown ids are ignored rather than fatal - an add-on that + * can't be placed simply keeps its position. + */ export function orderAddons( addons: Array>, setupResults: Record ): Array> { - return addons.sort((a, b) => { - return setupResults[a.id]?.runsAfter?.length - setupResults[b.id]?.runsAfter?.length; - }); + const byId = new Map(addons.map((addon) => [addon.id, addon])); + const ordered: Array> = []; + const placed = new Set(); + const visiting = new Set(); + + const place = (addon: Addon) => { + if (placed.has(addon.id) || visiting.has(addon.id)) return; + visiting.add(addon.id); + for (const id of setupResults[addon.id]?.runsAfter ?? []) { + const dependency = byId.get(id); + if (dependency) place(dependency); + } + visiting.delete(addon.id); + placed.add(addon.id); + ordered.push(addon); + }; + + // seeded with the "fewest constraints first" order this used to rely on, so add-ons that don't + // constrain each other keep the relative order they already had + const seeded = [...addons].sort( + (a, b) => + (setupResults[a.id]?.runsAfter?.length ?? 0) - (setupResults[b.id]?.runsAfter?.length ?? 0) + ); + for (const addon of seeded) place(addon); + return ordered; } From a1aba835b0836762d12e4903ca57072c6b97f0ed Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Fri, 31 Jul 2026 21:20:40 +0800 Subject: [PATCH 09/32] fix(drizzle): `remove better-sqlite3` from `allowBuilds` (#1205) * remove `better-sqlite3` from `allowBuilds` * changeset * Update packages/sv/src/addons/drizzle.ts Co-authored-by: CokaKoala <31664583+AdrianGonz97@users.noreply.github.com> * changeset --------- Co-authored-by: CokaKoala <31664583+AdrianGonz97@users.noreply.github.com> --- .changeset/tall-experts-open.md | 6 ++++++ packages/sv/src/addons/drizzle.ts | 18 ++---------------- 2 files changed, 8 insertions(+), 16 deletions(-) create mode 100644 .changeset/tall-experts-open.md diff --git a/.changeset/tall-experts-open.md b/.changeset/tall-experts-open.md new file mode 100644 index 000000000..2b00c3b53 --- /dev/null +++ b/.changeset/tall-experts-open.md @@ -0,0 +1,6 @@ +--- +'sv': patch +--- + +fix(drizzle): `remove better-sqlite3` from `allowBuilds` +chore(drizzle): bump `better-sqlite3` to `^13.0.2` diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index f064b0bac..5b08a7267 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -3,7 +3,6 @@ import { dedent, type TransformFn, transforms, - pnpm, resolveCommandArray, fileExists, createPrinter, @@ -94,17 +93,7 @@ export default defineAddon({ if (!isKit) return unsupported('Requires SvelteKit'); }, - run: ({ - sv, - language, - options, - directory, - dependencyVersion, - cwd, - cancel, - file, - packageManager - }) => { + run: ({ sv, language, options, directory, dependencyVersion, cwd, cancel, file }) => { const [ts] = createPrinter(language === 'ts'); const baseDBPath = path.resolve(cwd, directory.lib, 'server', 'db'); const paths = { @@ -133,11 +122,8 @@ export default defineAddon({ // SQLite if (options.sqlite === 'better-sqlite3') { // not a devDependency due to bundling issues - sv.dependency('better-sqlite3', '^12.10.0'); + sv.dependency('better-sqlite3', '^13.0.2'); sv.devDependency('@types/better-sqlite3', '^7.6.13'); - if (packageManager === 'pnpm') { - sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.allowBuilds('better-sqlite3')); - } } if (options.sqlite === 'libsql' || options.sqlite === 'turso') From 1a67d262a82c9a17e26aade3a770725460dc8cd7 Mon Sep 17 00:00:00 2001 From: "jyc.dev" Date: Fri, 31 Jul 2026 16:03:42 +0200 Subject: [PATCH 10/32] fix(better-auth): use the `auth` CLI binary in the auth:schema script (#1201) --- .changeset/tame-pandas-shout.md | 5 +++++ packages/sv/src/addons/better-auth.ts | 4 ++-- .../src/cli/tests/snapshots/create-experimental/package.json | 2 +- .../cli/tests/snapshots/create-with-all-addons/package.json | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .changeset/tame-pandas-shout.md diff --git a/.changeset/tame-pandas-shout.md b/.changeset/tame-pandas-shout.md new file mode 100644 index 000000000..b38a7ce68 --- /dev/null +++ b/.changeset/tame-pandas-shout.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +fix(better-auth): use the `auth` CLI binary in the `auth:schema` script diff --git a/packages/sv/src/addons/better-auth.ts b/packages/sv/src/addons/better-auth.ts index ea1910e24..60f8bef61 100644 --- a/packages/sv/src/addons/better-auth.ts +++ b/packages/sv/src/addons/better-auth.ts @@ -183,7 +183,7 @@ export default defineAddon({ /** * DO NOT USE! * - * This instance is used by the \`better-auth\` CLI for schema generation ONLY. + * This instance is used by the \`auth\` CLI for schema generation ONLY. * To access \`auth\` at runtime, use \`event.locals.auth\`. */ export const auth = createAuth(null${ts('!')});`; @@ -214,7 +214,7 @@ export default defineAddon({ json.packageScriptsUpsert( data, 'auth:schema', - `better-auth generate --config ${authConfigPath} --output ${authSchemaPath} --yes` + `auth generate --config ${authConfigPath} --output ${authSchemaPath} --yes` ); }) ); diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/package.json b/packages/sv/src/cli/tests/snapshots/create-experimental/package.json index 2c990aa76..9b83a264b 100644 --- a/packages/sv/src/cli/tests/snapshots/create-experimental/package.json +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/package.json @@ -15,7 +15,7 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", - "auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes" + "auth:schema": "auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes" }, "devDependencies": { "@libsql/client": "^0.17.3", diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/package.json b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/package.json index a5e8490c0..81fc5184f 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/package.json +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/package.json @@ -19,7 +19,7 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", - "auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes" + "auth:schema": "auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes" }, "devDependencies": { "@eslint/js": "^10.0.1", From 78f6b7af93c9ae61cb8ae2eb3293a560298cbc75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:17:25 +0200 Subject: [PATCH 11/32] Version Packages (#1200) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/great-pans-shave.md | 5 ----- .changeset/nervous-poems-clap.md | 5 ----- .changeset/olive-geese-repeat.md | 5 ----- .changeset/remove-mcp-addon.md | 5 ----- .changeset/tall-experts-open.md | 6 ------ .changeset/tame-pandas-shout.md | 5 ----- packages/sv-utils/CHANGELOG.md | 6 ++++++ packages/sv-utils/package.json | 2 +- packages/sv/CHANGELOG.md | 24 ++++++++++++++++++++++++ packages/sv/package.json | 2 +- 10 files changed, 32 insertions(+), 33 deletions(-) delete mode 100644 .changeset/great-pans-shave.md delete mode 100644 .changeset/nervous-poems-clap.md delete mode 100644 .changeset/olive-geese-repeat.md delete mode 100644 .changeset/remove-mcp-addon.md delete mode 100644 .changeset/tall-experts-open.md delete mode 100644 .changeset/tame-pandas-shout.md diff --git a/.changeset/great-pans-shave.md b/.changeset/great-pans-shave.md deleted file mode 100644 index 164ead88a..000000000 --- a/.changeset/great-pans-shave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'sv': patch ---- - -fix: run prettier directly instead of through the package manager, and stop allowing builds for packages that no longer have install scripts (`@tailwindcss/oxide`, `sharp`) diff --git a/.changeset/nervous-poems-clap.md b/.changeset/nervous-poems-clap.md deleted file mode 100644 index ae2c939dc..000000000 --- a/.changeset/nervous-poems-clap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'sv': patch ---- - -fix(experimental): `@sveltejs/kit@next` projects now install, build and type-check - `#lib` imports instead of `$lib`, a `tsconfig` extending `$app/tsconfig`, and no options kit 3 removed diff --git a/.changeset/olive-geese-repeat.md b/.changeset/olive-geese-repeat.md deleted file mode 100644 index 3f9762e6c..000000000 --- a/.changeset/olive-geese-repeat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@sveltejs/sv-utils': patch ---- - -feat: add SvelteKit 3 helpers - `isKit3`, `resolveLibPrefix`, `libSubpathImports` diff --git a/.changeset/remove-mcp-addon.md b/.changeset/remove-mcp-addon.md deleted file mode 100644 index 73672764c..000000000 --- a/.changeset/remove-mcp-addon.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'sv': minor ---- - -feat(ai-tools): replace `mcp` add-on with `ai-tools` add-on - set up the Svelte plugin (Claude Code, opencode) or pick individual tools (MCP server, skills, sub-agents) per client diff --git a/.changeset/tall-experts-open.md b/.changeset/tall-experts-open.md deleted file mode 100644 index 2b00c3b53..000000000 --- a/.changeset/tall-experts-open.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'sv': patch ---- - -fix(drizzle): `remove better-sqlite3` from `allowBuilds` -chore(drizzle): bump `better-sqlite3` to `^13.0.2` diff --git a/.changeset/tame-pandas-shout.md b/.changeset/tame-pandas-shout.md deleted file mode 100644 index b38a7ce68..000000000 --- a/.changeset/tame-pandas-shout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'sv': patch ---- - -fix(better-auth): use the `auth` CLI binary in the `auth:schema` script diff --git a/packages/sv-utils/CHANGELOG.md b/packages/sv-utils/CHANGELOG.md index a34b5afb8..c7153b580 100644 --- a/packages/sv-utils/CHANGELOG.md +++ b/packages/sv-utils/CHANGELOG.md @@ -1,5 +1,11 @@ # @sveltejs/sv-utils +## 0.3.3 +### Patch Changes + + +- feat: add SvelteKit 3 helpers - `isKit3`, `resolveLibPrefix`, `libSubpathImports` ([#1199](https://github.com/sveltejs/cli/pull/1199)) + ## 0.3.2 ### Patch Changes diff --git a/packages/sv-utils/package.json b/packages/sv-utils/package.json index 98074a7ba..1e35691f0 100644 --- a/packages/sv-utils/package.json +++ b/packages/sv-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sveltejs/sv-utils", - "version": "0.3.2", + "version": "0.3.3", "type": "module", "description": "Utility functions for sv", "license": "MIT", diff --git a/packages/sv/CHANGELOG.md b/packages/sv/CHANGELOG.md index 34bc6f9cf..23e898c1e 100644 --- a/packages/sv/CHANGELOG.md +++ b/packages/sv/CHANGELOG.md @@ -1,5 +1,29 @@ # sv +## 0.17.0 +### Minor Changes + + +- feat(ai-tools): replace `mcp` add-on with `ai-tools` add-on - set up the Svelte plugin (Claude Code, opencode) or pick individual tools (MCP server, skills, sub-agents) per client ([#1050](https://github.com/sveltejs/cli/pull/1050)) + + +### Patch Changes + + +- fix: run prettier directly instead of through the package manager, and stop allowing builds for packages that no longer have install scripts (`@tailwindcss/oxide`, `sharp`) ([#1198](https://github.com/sveltejs/cli/pull/1198)) + + +- fix(experimental): `@sveltejs/kit@next` projects now install, build and type-check - `#lib` imports instead of `$lib`, a `tsconfig` extending `$app/tsconfig`, and no options kit 3 removed ([#1199](https://github.com/sveltejs/cli/pull/1199)) + + +- fix(drizzle): `remove better-sqlite3` from `allowBuilds` ([#1205](https://github.com/sveltejs/cli/pull/1205)) + chore(drizzle): bump `better-sqlite3` to `^13.0.2` + +- fix(better-auth): use the `auth` CLI binary in the `auth:schema` script ([#1201](https://github.com/sveltejs/cli/pull/1201)) + +- Updated dependencies [[`86a4cce`](https://github.com/sveltejs/cli/commit/86a4cced4b8be35cff5b46d67a09839fa6eca775)]: + - @sveltejs/sv-utils@0.3.3 + ## 0.16.6 ### Patch Changes diff --git a/packages/sv/package.json b/packages/sv/package.json index 08ae3f2de..ddb46782b 100644 --- a/packages/sv/package.json +++ b/packages/sv/package.json @@ -1,6 +1,6 @@ { "name": "sv", - "version": "0.16.6", + "version": "0.17.0", "type": "module", "description": "A command line interface (CLI) for creating and maintaining Svelte applications", "license": "MIT", From fdcf48f47fc382777af5c9d8fb15891039660c53 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:47:00 +0800 Subject: [PATCH 12/32] drop in replacement --- packages/sv/src/addons/tests/better-auth/test.ts | 6 +++--- packages/sv/src/addons/tests/drizzle/test.ts | 12 ++++++------ packages/sv/src/addons/tests/eslint/test.ts | 8 ++++---- packages/sv/src/addons/tests/prettier/test.ts | 8 ++++---- packages/sv/src/cli/check.ts | 6 +++--- packages/sv/src/cli/migrate.ts | 5 +++-- packages/sv/src/core/formatFiles.ts | 2 +- packages/sv/src/core/verifiers.ts | 9 ++++----- packages/sv/src/create/tests/check.ts | 12 ++++-------- packages/sv/src/testing.ts | 8 +++++--- 10 files changed, 37 insertions(+), 39 deletions(-) diff --git a/packages/sv/src/addons/tests/better-auth/test.ts b/packages/sv/src/addons/tests/better-auth/test.ts index 4954877ad..4be8b5c3e 100644 --- a/packages/sv/src/addons/tests/better-auth/test.ts +++ b/packages/sv/src/addons/tests/better-auth/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import betterAuth from '../../better-auth.ts'; import drizzle from '../../drizzle.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -37,7 +37,7 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, fs.writeFileSync(envPath, envContent, 'utf8'); // Generate auth schema using better-auth CLI - execSync('npm run auth:schema', { cwd, stdio: 'pipe' }); + execSync('npm', ['run', 'auth:schema'], { nodeOptions: { cwd } }); // Verify schema has auth tables const schemaPath = path.resolve(cwd, `src/lib/server/db/schema.${language}`); @@ -46,7 +46,7 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, expect(schemaContent).toContain('./auth.schema'); // Push schema to DB - execSync('npm run db:push -- --force', { cwd, stdio: 'pipe' }); + execSync('npm', ['run', 'db:push', '--', '--force'], { nodeOptions: { cwd } }); /** ----- BROWSER SECTION ----- */ const { url, close } = await prepareServer({ cwd, page }); diff --git a/packages/sv/src/addons/tests/drizzle/test.ts b/packages/sv/src/addons/tests/drizzle/test.ts index 4ba45200c..258a5bcb8 100644 --- a/packages/sv/src/addons/tests/drizzle/test.ts +++ b/packages/sv/src/addons/tests/drizzle/test.ts @@ -1,4 +1,4 @@ -import { execSync } from 'node:child_process'; +import { execSync } from 'tinyexec'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -42,21 +42,21 @@ beforeAll(() => { const cwd = path.dirname(fileURLToPath(import.meta.url)); try { - execSync('docker --version', { cwd, stdio: 'pipe' }); + execSync('docker', ['--version'], { nodeOptions: { cwd } }); dockerInstalled = true; } catch { dockerInstalled = false; } - if (dockerInstalled) execSync('docker compose up --detach', { cwd, stdio: 'pipe' }); + if (dockerInstalled) execSync('docker', ['compose', 'up', '--detach'], { nodeOptions: { cwd } }); // cleans up the containers on interrupts (ctrl+c) process.addListener('SIGINT', () => { - if (dockerInstalled) execSync('docker compose down --volumes', { cwd, stdio: 'pipe' }); + if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }); return () => { - if (dockerInstalled) execSync('docker compose down --volumes', { cwd, stdio: 'pipe' }); + if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }; }); @@ -92,7 +92,7 @@ test.concurrent.for(testCases)( const pageServerPath = path.resolve(routes, `+page.server.${ts ? 'ts' : 'js'}`); fs.writeFileSync(pageServerPath, pageServer, 'utf8'); - execSync('npm run db:push', { cwd, stdio: 'pipe' }); + execSync('npm', ['run', 'db:push'], { nodeOptions: { cwd } }); const { close } = await prepareServer({ cwd, page }); // kill server process when we're done diff --git a/packages/sv/src/addons/tests/eslint/test.ts b/packages/sv/src/addons/tests/eslint/test.ts index c458f5b3c..7dda9a5a0 100644 --- a/packages/sv/src/addons/tests/eslint/test.ts +++ b/packages/sv/src/addons/tests/eslint/test.ts @@ -1,6 +1,6 @@ -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import eslint from '../../eslint.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -15,9 +15,9 @@ test.concurrent.for(testCases)('eslint $variant', (testCase, { expect, ...ctx }) const unlintedFile = 'let foo = "";\nif (Boolean(foo)) {\n//\n}'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unlintedFile, 'utf8'); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); - expect(() => execSync('pnpm eslint --fix .', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['eslint', '--fix', '.'], { nodeOptions: { cwd } })).not.toThrow(); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); }); diff --git a/packages/sv/src/addons/tests/prettier/test.ts b/packages/sv/src/addons/tests/prettier/test.ts index 9af677bcc..096b0776e 100644 --- a/packages/sv/src/addons/tests/prettier/test.ts +++ b/packages/sv/src/addons/tests/prettier/test.ts @@ -1,7 +1,7 @@ import { log } from '@clack/prompts'; -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import { vi } from 'vitest'; import { ESLINT_VERSION } from '../../common.ts'; import prettier from '../../prettier.ts'; @@ -48,11 +48,11 @@ test.concurrent.for(testCases)('prettier $kind.type $variant', (testCase, { expe const unformattedFile = 'const foo = "bar"'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unformattedFile, 'utf8'); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); - expect(() => execSync('pnpm format', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['format'], { nodeOptions: { cwd } })).not.toThrow(); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); } else if (testCase.kind.type === 'supported-eslint') { expect(fs.existsSync(path.resolve(cwd, 'eslint.config.js'))).toBe(true); diff --git a/packages/sv/src/cli/check.ts b/packages/sv/src/cli/check.ts index fd5f2dfa0..721470dd9 100644 --- a/packages/sv/src/cli/check.ts +++ b/packages/sv/src/cli/check.ts @@ -1,8 +1,8 @@ import { color, resolveCommandArray } from '@sveltejs/sv-utils'; import { Command } from 'commander'; import * as resolve from 'empathic/resolve'; -import { execSync } from 'node:child_process'; import process from 'node:process'; +import { execSync } from 'tinyexec'; import { forwardExitCode } from '../core/common.ts'; import { detectPackageManager } from '../core/package-manager.ts'; @@ -39,8 +39,8 @@ async function runCheck(cwd: string, args: string[]) { // avoids printing the stack trace for `sv` when `svelte-check` exits with an error code try { - const cmd = resolveCommandArray(pm, 'execute-local', ['svelte-check', ...args]).join(' '); - execSync(cmd, { stdio: 'inherit', cwd }); + const [cmd, ...cmdArgs] = resolveCommandArray(pm, 'execute-local', ['svelte-check', ...args]); + execSync(cmd, cmdArgs, { nodeOptions: { cwd, stdio: 'inherit' } }); } catch (error) { forwardExitCode(error); } finally { diff --git a/packages/sv/src/cli/migrate.ts b/packages/sv/src/cli/migrate.ts index 22886dc60..8545af19c 100644 --- a/packages/sv/src/cli/migrate.ts +++ b/packages/sv/src/cli/migrate.ts @@ -1,7 +1,7 @@ import { resolveCommandArray } from '@sveltejs/sv-utils'; import { Command } from 'commander'; -import { execSync } from 'node:child_process'; import process from 'node:process'; +import { execSync } from 'tinyexec'; import { forwardExitCode } from '../core/common.ts'; import { detectPackageManager } from '../core/package-manager.ts'; @@ -23,7 +23,8 @@ async function runMigrate(cwd: string, args: string[]) { // skips the download confirmation prompt for `npx` if (pm === 'npm') cmdArgs.unshift('--yes'); - execSync(resolveCommandArray(pm, 'execute', cmdArgs).join(' '), { stdio: 'inherit', cwd }); + const cmd = resolveCommand(pm, 'execute', cmdArgs)!; + execSync(cmd.command, cmd.args, { nodeOptions: { cwd, stdio: 'inherit' } }); } catch (error) { forwardExitCode(error); } diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts index 932fe8024..d19ca06a4 100644 --- a/packages/sv/src/core/formatFiles.ts +++ b/packages/sv/src/core/formatFiles.ts @@ -36,7 +36,7 @@ async function run( cwd: string ): Promise<{ error?: string; notFound?: boolean }> { try { - await exec(command, args, { nodeOptions: { cwd, stdio: 'pipe' }, throwOnError: true }); + await exec(command, args, { nodeOptions: { cwd }, throwOnError: true }); return {}; } catch (e) { // @ts-expect-error tinyexec rethrows the spawn error as-is diff --git a/packages/sv/src/core/verifiers.ts b/packages/sv/src/core/verifiers.ts index 884ef20de..1fdc5a425 100644 --- a/packages/sv/src/core/verifiers.ts +++ b/packages/sv/src/core/verifiers.ts @@ -1,5 +1,4 @@ -import { exec } from 'node:child_process'; -import { promisify } from 'node:util'; +import { exec } from 'tinyexec'; import type { AddonDefinition, SetupResult, Verification } from './config.ts'; import { UnsupportedError } from './errors.ts'; @@ -17,9 +16,9 @@ export function verifyCleanWorkingDirectory(cwd: string, gitCheck: boolean) { // there are no pending changes. If the below command is run outside of a git repository, // git will exit with a failing exit code, which will trigger the catch statement. // also see https://remarkablemark.org/blog/2017/10/12/check-git-dirty/#git-status - const asyncExec = promisify(exec); - const { stdout } = await asyncExec('git status --short', { - cwd + const { stdout } = await exec('git', ['status', '--short'], { + nodeOptions: { cwd }, + throwOnError: true }); if (stdout) { diff --git a/packages/sv/src/create/tests/check.ts b/packages/sv/src/create/tests/check.ts index 9aa03af48..7c9eac824 100644 --- a/packages/sv/src/create/tests/check.ts +++ b/packages/sv/src/create/tests/check.ts @@ -1,9 +1,7 @@ -import { type PromiseWithChild, exec as nodeExec } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; -import { exec } from 'tinyexec'; +import { exec, type Result } from 'tinyexec'; import { beforeAll, describe, expect, test } from 'vitest'; import { add, officialAddons } from '../../../../sv/src/index.ts'; import { createProject } from '../../cli/create.ts'; @@ -21,11 +19,9 @@ fs.mkdirSync(test_workspace_dir, { recursive: true }); fs.writeFileSync(path.join(test_workspace_dir, 'pnpm-workspace.yaml'), 'packages:\n - ./*\n'); -const exec_async = promisify(nodeExec); - beforeAll(async () => { const install = await exec('pnpm', ['install', '--no-frozen-lockfile'], { - nodeOptions: { cwd: test_workspace_dir, stdio: 'pipe' } + nodeOptions: { cwd: test_workspace_dir } }); if (install.exitCode !== 0) { throw new Error( @@ -38,7 +34,7 @@ beforeAll(async () => { * Tests in different templates can be run concurrently for a nice speedup locally, but tests within a template must be run sequentially. * It'd be better to group tests by template, but vitest doesn't support that yet. */ -const script_test_map = new Map PromiseWithChild]>>(); +const script_test_map = new Map Result]>>(); const templates = fs.readdirSync(resolve_path('../templates/')) as TemplateType[]; @@ -85,7 +81,7 @@ for (const template of templates.filter((t) => t !== 'addon')) { for (const script of scripts_to_test) { const tests = script_test_map.get(script) ?? []; - tests.push([`${template}-${types}`, () => exec_async(`pnpm ${script}`, { cwd })]); + tests.push([`${template}-${types}`, () => exec('pnpm', [script], { nodeOptions: { cwd } })]); script_test_map.set(script, tests); } diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 5301b2516..74712e4a0 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -1,10 +1,9 @@ import type { Page } from '@playwright/test'; -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import pstree, { type PS } from 'ps-tree'; -import { exec, x } from 'tinyexec'; +import { exec, execSync, x } from 'tinyexec'; import type { TestProject } from 'vitest/node'; import { add, type AddonMap, type OptionMap } from './core/engine.ts'; import { addPnpmAllowBuilds } from './core/package-manager.ts'; @@ -228,7 +227,10 @@ export async function prepareServer({ expect }: PrepareServerOptions): Promise { // build project - if (buildCommand) execSync(buildCommand, { cwd, stdio: 'pipe' }); + if (buildCommand) { + const [cmd, ...args] = buildCommand.split(' '); + execSync(cmd, args, { nodeOptions: { cwd }, throwOnError: true }); + } // start preview server const { url, close } = await startPreview({ cwd, command: previewCommand }); From c791d830430449b1d7a7fb6ae2117587a5212d81 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:48:01 +0800 Subject: [PATCH 13/32] add run helper that defaults cwd to testOutputPath --- packages/sv/src/cli/tests/cli.ts | 37 ++++++++++++++------------------ 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 0bac5f647..6b776988e 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -96,9 +96,15 @@ describe('cli', () => { ...args ]; + /** + * Same as `exec`. Defaults `cwd` to `testOutputPath` + */ + const run = (...args: Parameters) => + exec(args[0], args[1], { nodeOptions: { cwd: testOutputPath }, ...args[2] }); + // useful for debugging // console.log(`command`, `node ${allArgs.join(' ')}`); - const result = await exec('node', allArgs, { nodeOptions: { stdio: 'pipe' } }); + const result = await exec('node', allArgs); // cli finished well expect( @@ -199,24 +205,18 @@ describe('cli', () => { if (projectName === 'create-with-all-addons' && process.platform !== 'win32') { // the generated project lives inside this repo, so it must not join its workspace - const installResult = await exec( - 'pnpm', - ['install', '--no-frozen-lockfile', '--ignore-workspace'], - { nodeOptions: { stdio: 'pipe', cwd: testOutputPath } } - ); + const installResult = await run('pnpm', [ + 'install', + '--no-frozen-lockfile', + '--ignore-workspace' + ]); expect( installResult.exitCode, `pnpm install failed:\n stdout: ${installResult.stdout}\n stderr: ${installResult.stderr}` ).toBe(0); - await exec('pnpm', ['build'], { - nodeOptions: { stdio: 'pipe', cwd: testOutputPath } - }); - await exec('pnpm', ['auth:schema'], { - nodeOptions: { stdio: 'pipe', cwd: testOutputPath } - }); - const check = await exec('pnpm', ['check'], { - nodeOptions: { stdio: 'pipe', cwd: testOutputPath } - }); + await run('pnpm', ['build']); + await run('pnpm', ['auth:schema']); + const check = await run('pnpm', ['check']); expect( check.exitCode, `svelte-check failed:\n stdout: ${check.stdout}\n stderr: ${check.stderr}` @@ -225,9 +225,6 @@ describe('cli', () => { // `kit@next` moves fast - only a real install/build/check catches options it removed if (projectName === 'create-experimental-next' && process.platform !== 'win32') { - const run = (cmd: string, cmdArgs: string[]) => - exec(cmd, cmdArgs, { nodeOptions: { stdio: 'pipe', cwd: testOutputPath } }); - const install = await run('pnpm', [ 'install', '--no-frozen-lockfile', @@ -287,10 +284,8 @@ describe('cli', () => { for (const cmd of cmds) { // use npm here so the install doesn't walk up into the monorepo's // pnpm workspace and try to resolve packages from there - const res = await exec('npm', cmd, { + const res = await run('npm', cmd, { nodeOptions: { - stdio: 'pipe', - cwd: testOutputPath, env: { ...process.env, // allow npm under a repo whose packageManager is pnpm From 2115be448097d82e9262f3904653738b204ec653 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:49:35 +0800 Subject: [PATCH 14/32] refactor out `constructCommand` --- packages/sv/src/core/package-manager.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/sv/src/core/package-manager.ts b/packages/sv/src/core/package-manager.ts index fece9f764..5c1fcc28d 100644 --- a/packages/sv/src/core/package-manager.ts +++ b/packages/sv/src/core/package-manager.ts @@ -1,13 +1,5 @@ import * as p from '@clack/prompts'; -import { - AGENTS, - type AgentName, - COMMANDS, - color, - constructCommand, - detect, - pnpm -} from '@sveltejs/sv-utils'; +import { AGENTS, type AgentName, color, detect, pnpm, resolveCommand } from '@sveltejs/sv-utils'; import { Option } from 'commander'; import * as find from 'empathic/find'; import fs from 'node:fs'; @@ -72,12 +64,9 @@ export async function installDependencies(agent: AgentName, cwd: string): Promis retainLog: true }); - const { command, args } = constructCommand(COMMANDS[agent].install, [])!; + const { command, args } = resolveCommand(agent, 'install', [])!; - const proc = exec(command, args, { - nodeOptions: { cwd, stdio: 'pipe' }, - throwOnError: false - }); + const proc = exec(command, args, { nodeOptions: { cwd }, throwOnError: false }); const output: string[] = []; try { From 1fc25b7cc33658633dc2e10c534c9af9a39e28d7 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 19:00:03 +0800 Subject: [PATCH 15/32] refactor to no throw --- packages/sv/src/core/verifiers.ts | 36 +++++++++++-------------------- 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/packages/sv/src/core/verifiers.ts b/packages/sv/src/core/verifiers.ts index 1fdc5a425..197503cda 100644 --- a/packages/sv/src/core/verifiers.ts +++ b/packages/sv/src/core/verifiers.ts @@ -9,26 +9,17 @@ export function verifyCleanWorkingDirectory(cwd: string, gitCheck: boolean) { verifications.push({ name: 'clean working directory', run: async () => { - try { - // If a user has pending git changes the output of the following command will list - // all files that have been added/modified/deleted and thus the output will not be empty. - // In case the output of the command below is an empty text, we can safely assume - // there are no pending changes. If the below command is run outside of a git repository, - // git will exit with a failing exit code, which will trigger the catch statement. - // also see https://remarkablemark.org/blog/2017/10/12/check-git-dirty/#git-status - const { stdout } = await exec('git', ['status', '--short'], { - nodeOptions: { cwd }, - throwOnError: true - }); - - if (stdout) { - return { success: false, message: 'Uncommited changes found' }; - } - - return { success: true, message: undefined }; - } catch { - return { success: true, message: 'Not a git repository' }; - } + // If a user has pending git changes the output of the following command will list + // all files that have been added/modified/deleted and thus the output will not be empty. + // In case the output of the command below is an empty text, we can safely assume + // there are no pending changes. If the below command is run outside of a git repository, + // git will exit with a failing exit code, which will trigger the catch statement. + // also see https://remarkablemark.org/blog/2017/10/12/check-git-dirty/#git-status + const result = await exec('git', ['status', '--short'], { nodeOptions: { cwd } }); + + if (result.exitCode !== 0) return { success: true, message: 'Not a git repository' }; + if (result.stdout) return { success: false, message: 'Uncommited changes found' }; + return { success: true, message: undefined }; } }); } @@ -49,10 +40,7 @@ export function verifyUnsupportedAddons( setupResults[a.id].unsupported.map((reason) => ({ id: a.id, reason })) ); - if (reasons.length === 0) { - return { success: true, message: undefined }; - } - + if (reasons.length === 0) return { success: true, message: undefined }; throw new UnsupportedError(reasons); } }); From 21bef9910ee42fd6909c5ff8eaa0ddc5d949d445 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 19:08:32 +0800 Subject: [PATCH 16/32] `tinyexec` doesn't throw by default --- packages/sv/src/addons/tests/eslint/test.ts | 15 ++++++++++++--- packages/sv/src/addons/tests/prettier/test.ts | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/sv/src/addons/tests/eslint/test.ts b/packages/sv/src/addons/tests/eslint/test.ts index 7dda9a5a0..43c92c831 100644 --- a/packages/sv/src/addons/tests/eslint/test.ts +++ b/packages/sv/src/addons/tests/eslint/test.ts @@ -15,9 +15,18 @@ test.concurrent.for(testCases)('eslint $variant', (testCase, { expect, ...ctx }) const unlintedFile = 'let foo = "";\nif (Boolean(foo)) {\n//\n}'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unlintedFile, 'utf8'); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should fail on unlinted file' + ).not.toBe(0); - expect(() => execSync('pnpm', ['eslint', '--fix', '.'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['eslint', '--fix', '.'], { nodeOptions: { cwd } }).exitCode, + 'eslint --fix should succeed' + ).toBe(0); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should pass after fix' + ).toBe(0); }); diff --git a/packages/sv/src/addons/tests/prettier/test.ts b/packages/sv/src/addons/tests/prettier/test.ts index 096b0776e..ae0379f97 100644 --- a/packages/sv/src/addons/tests/prettier/test.ts +++ b/packages/sv/src/addons/tests/prettier/test.ts @@ -48,11 +48,20 @@ test.concurrent.for(testCases)('prettier $kind.type $variant', (testCase, { expe const unformattedFile = 'const foo = "bar"'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unformattedFile, 'utf8'); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should fail on unformatted file' + ).not.toBe(0); - expect(() => execSync('pnpm', ['format'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['format'], { nodeOptions: { cwd } }).exitCode, + 'format should succeed' + ).toBe(0); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should pass after format' + ).toBe(0); } else if (testCase.kind.type === 'supported-eslint') { expect(fs.existsSync(path.resolve(cwd, 'eslint.config.js'))).toBe(true); From d7d20f9b35f55ae376619271898733ec3d29b23f Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:52:31 +0800 Subject: [PATCH 17/32] clean up --- packages/sv/src/cli/check.ts | 6 +++--- packages/sv/src/cli/migrate.ts | 18 +++++++++--------- packages/sv/src/core/engine.ts | 8 ++++---- packages/sv/src/testing.ts | 14 ++++---------- 4 files changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/sv/src/cli/check.ts b/packages/sv/src/cli/check.ts index 721470dd9..0df455a6e 100644 --- a/packages/sv/src/cli/check.ts +++ b/packages/sv/src/cli/check.ts @@ -1,4 +1,4 @@ -import { color, resolveCommandArray } from '@sveltejs/sv-utils'; +import { color, resolveCommand, resolveCommandArray } from '@sveltejs/sv-utils'; import { Command } from 'commander'; import * as resolve from 'empathic/resolve'; import process from 'node:process'; @@ -39,8 +39,8 @@ async function runCheck(cwd: string, args: string[]) { // avoids printing the stack trace for `sv` when `svelte-check` exits with an error code try { - const [cmd, ...cmdArgs] = resolveCommandArray(pm, 'execute-local', ['svelte-check', ...args]); - execSync(cmd, cmdArgs, { nodeOptions: { cwd, stdio: 'inherit' } }); + const cmd = resolveCommand(pm, 'execute-local', ['svelte-check', ...args])!; + execSync(cmd.command, cmd.args, { nodeOptions: { cwd, stdio: 'inherit' } }); } catch (error) { forwardExitCode(error); } finally { diff --git a/packages/sv/src/cli/migrate.ts b/packages/sv/src/cli/migrate.ts index 8545af19c..b62ef7896 100644 --- a/packages/sv/src/cli/migrate.ts +++ b/packages/sv/src/cli/migrate.ts @@ -1,4 +1,4 @@ -import { resolveCommandArray } from '@sveltejs/sv-utils'; +import { resolveCommand } from '@sveltejs/sv-utils'; import { Command } from 'commander'; import process from 'node:process'; import { execSync } from 'tinyexec'; @@ -9,21 +9,21 @@ export const migrate = new Command('migrate') .description('a CLI for migrating Svelte(Kit) codebases') .argument('[migration]', 'migration to run') .option('-C, --cwd ', 'path to working directory', process.cwd()) - .action(async (migration, options) => { - await runMigrate(options.cwd, [migration]); - }); + .action((migration, options) => runMigrate(options.cwd, [migration])); async function runMigrate(cwd: string, args: string[]) { const pm = await detectPackageManager(cwd); // avoids printing the stack trace for `sv` when `svelte-migrate` exits with an error code try { - const cmdArgs = ['svelte-migrate@latest', ...args]; + const newArgs = [ + // skips the download confirmation prompt for `npx` + ...(pm === 'npm' ? '--yes' : ''), + 'svelte-migrate@latest', + ...args + ]; - // skips the download confirmation prompt for `npx` - if (pm === 'npm') cmdArgs.unshift('--yes'); - - const cmd = resolveCommand(pm, 'execute', cmdArgs)!; + const cmd = resolveCommand(pm, 'execute', newArgs)!; execSync(cmd.command, cmd.args, { nodeOptions: { cwd, stdio: 'inherit' } }); } catch (error) { forwardExitCode(error); diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 8540c56ba..6c910b073 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -250,10 +250,10 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } } }, execute: async (commandArgs, stdio) => { - const { command, args } = resolveCommand(workspace.packageManager, 'execute', commandArgs)!; + const cmd = resolveCommand(workspace.packageManager, 'execute', commandArgs)!; const addonPrefix = multiple ? `${addon.id}: ` : ''; - const executedCommand = [command, ...args].join(' '); + const executedCommand = [cmd.command, ...cmd.args].join(' '); if (!TESTING) { p.log.step( `${addonPrefix}Running external command ${color.optional(`(${executedCommand})`)}` @@ -261,10 +261,10 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } } // adding --yes as the first parameter helps avoiding the "Need to install the following packages:" message - if (workspace.packageManager === 'npm') args.unshift('--yes'); + if (workspace.packageManager === 'npm') cmd.args.unshift('--yes'); try { - await exec(command, args, { + await exec(cmd.command, cmd.args, { nodeOptions: { cwd: workspace.cwd, stdio: TESTING ? 'pipe' : stdio }, throwOnError: true }); diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 74712e4a0..72fefe127 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import pstree, { type PS } from 'ps-tree'; -import { exec, execSync, x } from 'tinyexec'; +import { exec, execSync } from 'tinyexec'; import type { TestProject } from 'vitest/node'; import { add, type AddonMap, type OptionMap } from './core/engine.ts'; import { addPnpmAllowBuilds } from './core/package-manager.ts'; @@ -81,11 +81,7 @@ async function startPreview({ command = 'npm run preview' }: PreviewOptions): Promise<{ url: string; close: () => Promise }> { const [cmd, ...args] = command.split(' '); - const proc = exec(cmd, args, { - nodeOptions: { cwd, stdio: 'pipe' }, - throwOnError: true, - timeout: 66_999 - }); + const proc = exec(cmd, args, { nodeOptions: { cwd }, throwOnError: true, timeout: 66_999 }); const close = async () => { if (!proc.pid) return; @@ -128,7 +124,7 @@ async function getProcessTree(pid: number) { async function terminate(pid: number) { if (process.platform === 'win32') { // on windows, use taskkill to terminate the process tree - await x('taskkill', ['/PID', `${pid}`, '/T', '/F']); + await exec('taskkill', ['/PID', `${pid}`, '/T', '/F']); return; } const children = await getProcessTree(pid); @@ -367,9 +363,7 @@ export function createSetupTest( } const installDir = path.resolve(cwd, testName); - const install = await exec('pnpm', ['install'], { - nodeOptions: { cwd: installDir, stdio: 'pipe' } - }); + const install = await exec('pnpm', ['install'], { nodeOptions: { cwd: installDir } }); if (install.exitCode !== 0) { throw new Error( `pnpm install failed in ${installDir}\n stdout: ${install.stdout}\n stderr: ${install.stderr}` From 696efaa5b4d859739d4889ec9907b567cc541434 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:53:23 +0800 Subject: [PATCH 18/32] every `exec` on this page should `throw`? --- packages/sv/src/testing.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 72fefe127..229162e9f 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -124,7 +124,7 @@ async function getProcessTree(pid: number) { async function terminate(pid: number) { if (process.platform === 'win32') { // on windows, use taskkill to terminate the process tree - await exec('taskkill', ['/PID', `${pid}`, '/T', '/F']); + await exec('taskkill', ['/PID', `${pid}`, '/T', '/F'], { throwOnError: true }); return; } const children = await getProcessTree(pid); @@ -363,7 +363,10 @@ export function createSetupTest( } const installDir = path.resolve(cwd, testName); - const install = await exec('pnpm', ['install'], { nodeOptions: { cwd: installDir } }); + const install = await exec('pnpm', ['install'], { + nodeOptions: { cwd: installDir }, + throwOnError: true + }); if (install.exitCode !== 0) { throw new Error( `pnpm install failed in ${installDir}\n stdout: ${install.stdout}\n stderr: ${install.stderr}` From f44f36e0dbb62f54d084ed78efd10c63fbdf3996 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:53:59 +0800 Subject: [PATCH 19/32] not sure if this is a one to one refactor --- packages/sv/src/addons/tests/vitest/test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/sv/src/addons/tests/vitest/test.ts b/packages/sv/src/addons/tests/vitest/test.ts index b00440972..f299ec4d4 100644 --- a/packages/sv/src/addons/tests/vitest/test.ts +++ b/packages/sv/src/addons/tests/vitest/test.ts @@ -1,6 +1,6 @@ -import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import vitest from '../../vitest-addon.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -13,16 +13,17 @@ test.concurrent.for(testCases)('vitest $variant', (testCase, { expect, ...ctx }) const cwd = ctx.cwd(testCase); expect( - spawnSync('pnpm exec playwright install chromium', { - cwd, - stdio: 'pipe', - shell: true, - timeout: 2 * 60_000 - }).status + execSync('pnpm', ['exec', 'playwright', 'install', 'chromium'], { + nodeOptions: { + cwd, + timeout: 2 * 60_000, + shell: true + } + }).exitCode ).toBe(0); expect( - spawnSync('pnpm test', { cwd, stdio: 'pipe', shell: true, timeout: 2 * 60_000 }).status + execSync('pnpm', ['test'], { nodeOptions: { cwd, shell: true, timeout: 2 * 60_000 } }).exitCode ).toBe(0); const viteFile = ['vite.config.ts', 'vite.config.js'] From 390a6ea1f9b143b48e5a232246ded50089928353 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 19:15:46 +0800 Subject: [PATCH 20/32] lint --- packages/sv/src/addons/tests/drizzle/test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/sv/src/addons/tests/drizzle/test.ts b/packages/sv/src/addons/tests/drizzle/test.ts index 258a5bcb8..38e57b867 100644 --- a/packages/sv/src/addons/tests/drizzle/test.ts +++ b/packages/sv/src/addons/tests/drizzle/test.ts @@ -1,8 +1,8 @@ -import { execSync } from 'tinyexec'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { execSync } from 'tinyexec'; import { beforeAll, expect } from 'vitest'; import drizzle from '../../drizzle.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -52,11 +52,13 @@ beforeAll(() => { // cleans up the containers on interrupts (ctrl+c) process.addListener('SIGINT', () => { - if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); + if (dockerInstalled) + execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }); return () => { - if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); + if (dockerInstalled) + execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }; }); From 78030c6c2624617ac8905aaf88bb60d59df94459 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 21:06:27 +0800 Subject: [PATCH 21/32] fix --- packages/sv/src/cli/tests/cli.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 6b776988e..ac954f09e 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -97,10 +97,15 @@ describe('cli', () => { ]; /** - * Same as `exec`. Defaults `cwd` to `testOutputPath` + * Same as `exec`. but `cwd` defaults to `testOutputPath` */ - const run = (...args: Parameters) => - exec(args[0], args[1], { nodeOptions: { cwd: testOutputPath }, ...args[2] }); + const run = (...args: Parameters) => { + const opts = args[2] ?? {}; + return exec(args[0], args[1], { + nodeOptions: { cwd: testOutputPath, ...opts.nodeOptions }, + ...opts + }); + }; // useful for debugging // console.log(`command`, `node ${allArgs.join(' ')}`); From e48f361697526a66a855c5b1756c7fd911e9350a Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sun, 2 Aug 2026 01:30:49 +0800 Subject: [PATCH 22/32] fix --- packages/sv/src/cli/tests/cli.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index ac954f09e..7f4765326 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -101,9 +101,10 @@ describe('cli', () => { */ const run = (...args: Parameters) => { const opts = args[2] ?? {}; + const { nodeOptions, ...rest } = opts; return exec(args[0], args[1], { - nodeOptions: { cwd: testOutputPath, ...opts.nodeOptions }, - ...opts + nodeOptions: { cwd: testOutputPath, ...nodeOptions }, + ...rest }); }; From 20674ce260e3554b8d63e369ba74ee982f8662d1 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sun, 2 Aug 2026 02:01:22 +0800 Subject: [PATCH 23/32] nit --- packages/sv/src/cli/tests/cli.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 7f4765326..b99c7ebc0 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -99,13 +99,11 @@ describe('cli', () => { /** * Same as `exec`. but `cwd` defaults to `testOutputPath` */ - const run = (...args: Parameters) => { - const opts = args[2] ?? {}; - const { nodeOptions, ...rest } = opts; - return exec(args[0], args[1], { - nodeOptions: { cwd: testOutputPath, ...nodeOptions }, - ...rest - }); + const run = (...params: Parameters) => { + const [command, args, options = {}] = params ?? []; + options.nodeOptions ??= {}; + options.nodeOptions.cwd ??= testOutputPath; + return exec(command, args, options); }; // useful for debugging From 764cf6ab0bf0da293c1b063cabd470850dece029 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sun, 2 Aug 2026 02:16:38 +0800 Subject: [PATCH 24/32] rerun test From ea6eb5884f9b5ad9f04513d02d7ff871c978a8c0 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sun, 2 Aug 2026 20:48:14 +0800 Subject: [PATCH 25/32] Update packages/sv/src/core/verifiers.ts Co-authored-by: CokaKoala <31664583+AdrianGonz97@users.noreply.github.com> --- packages/sv/src/core/verifiers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sv/src/core/verifiers.ts b/packages/sv/src/core/verifiers.ts index 197503cda..914058369 100644 --- a/packages/sv/src/core/verifiers.ts +++ b/packages/sv/src/core/verifiers.ts @@ -13,7 +13,7 @@ export function verifyCleanWorkingDirectory(cwd: string, gitCheck: boolean) { // all files that have been added/modified/deleted and thus the output will not be empty. // In case the output of the command below is an empty text, we can safely assume // there are no pending changes. If the below command is run outside of a git repository, - // git will exit with a failing exit code, which will trigger the catch statement. + // git will exit with a failing exit code. // also see https://remarkablemark.org/blog/2017/10/12/check-git-dirty/#git-status const result = await exec('git', ['status', '--short'], { nodeOptions: { cwd } }); From 6901b16b9beb20c00a7a4632c36b055e9ccfe8cb Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 3 Aug 2026 16:03:28 +0800 Subject: [PATCH 26/32] adjust throws --- packages/sv/src/addons/tests/better-auth/test.ts | 7 +++++-- packages/sv/src/addons/tests/drizzle/test.ts | 4 ++-- packages/sv/src/testing.ts | 5 ++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/sv/src/addons/tests/better-auth/test.ts b/packages/sv/src/addons/tests/better-auth/test.ts index 4be8b5c3e..d29f06d0e 100644 --- a/packages/sv/src/addons/tests/better-auth/test.ts +++ b/packages/sv/src/addons/tests/better-auth/test.ts @@ -37,7 +37,7 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, fs.writeFileSync(envPath, envContent, 'utf8'); // Generate auth schema using better-auth CLI - execSync('npm', ['run', 'auth:schema'], { nodeOptions: { cwd } }); + execSync('npm', ['run', 'auth:schema'], { nodeOptions: { cwd }, throwOnError: true }); // Verify schema has auth tables const schemaPath = path.resolve(cwd, `src/lib/server/db/schema.${language}`); @@ -46,7 +46,10 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, expect(schemaContent).toContain('./auth.schema'); // Push schema to DB - execSync('npm', ['run', 'db:push', '--', '--force'], { nodeOptions: { cwd } }); + execSync('npm', ['run', 'db:push', '--', '--force'], { + nodeOptions: { cwd }, + throwOnError: true + }); /** ----- BROWSER SECTION ----- */ const { url, close } = await prepareServer({ cwd, page }); diff --git a/packages/sv/src/addons/tests/drizzle/test.ts b/packages/sv/src/addons/tests/drizzle/test.ts index 38e57b867..2c374adf1 100644 --- a/packages/sv/src/addons/tests/drizzle/test.ts +++ b/packages/sv/src/addons/tests/drizzle/test.ts @@ -42,7 +42,7 @@ beforeAll(() => { const cwd = path.dirname(fileURLToPath(import.meta.url)); try { - execSync('docker', ['--version'], { nodeOptions: { cwd } }); + execSync('docker', ['--version'], { nodeOptions: { cwd }, throwOnError: true }); dockerInstalled = true; } catch { dockerInstalled = false; @@ -94,7 +94,7 @@ test.concurrent.for(testCases)( const pageServerPath = path.resolve(routes, `+page.server.${ts ? 'ts' : 'js'}`); fs.writeFileSync(pageServerPath, pageServer, 'utf8'); - execSync('npm', ['run', 'db:push'], { nodeOptions: { cwd } }); + execSync('npm', ['run', 'db:push'], { nodeOptions: { cwd }, throwOnError: true }); const { close } = await prepareServer({ cwd, page }); // kill server process when we're done diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 229162e9f..18a835461 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -124,7 +124,7 @@ async function getProcessTree(pid: number) { async function terminate(pid: number) { if (process.platform === 'win32') { // on windows, use taskkill to terminate the process tree - await exec('taskkill', ['/PID', `${pid}`, '/T', '/F'], { throwOnError: true }); + await exec('taskkill', ['/PID', `${pid}`, '/T', '/F']); return; } const children = await getProcessTree(pid); @@ -364,8 +364,7 @@ export function createSetupTest( const installDir = path.resolve(cwd, testName); const install = await exec('pnpm', ['install'], { - nodeOptions: { cwd: installDir }, - throwOnError: true + nodeOptions: { cwd: installDir } }); if (install.exitCode !== 0) { throw new Error( From 584df66438b4fe780366ab567909409e73d1417b Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 3 Aug 2026 16:03:32 +0800 Subject: [PATCH 27/32] lint --- packages/sv/src/core/verifiers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/sv/src/core/verifiers.ts b/packages/sv/src/core/verifiers.ts index 914058369..465a26566 100644 --- a/packages/sv/src/core/verifiers.ts +++ b/packages/sv/src/core/verifiers.ts @@ -40,7 +40,10 @@ export function verifyUnsupportedAddons( setupResults[a.id].unsupported.map((reason) => ({ id: a.id, reason })) ); - if (reasons.length === 0) return { success: true, message: undefined }; + if (reasons.length === 0) { + return { success: true, message: undefined }; + } + throw new UnsupportedError(reasons); } }); From 4b0773d5009fe8e2252986172b79c65f05ca0979 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 3 Aug 2026 16:38:37 +0800 Subject: [PATCH 28/32] Clarify where error properties come from --- packages/sv/src/core/formatFiles.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts index d19ca06a4..9a9829004 100644 --- a/packages/sv/src/core/formatFiles.ts +++ b/packages/sv/src/core/formatFiles.ts @@ -1,6 +1,6 @@ import * as p from '@clack/prompts'; import { type AgentName, resolveCommand } from '@sveltejs/sv-utils'; -import { exec } from 'tinyexec'; +import { exec, NonZeroExitError } from 'tinyexec'; export async function formatFiles(options: { packageManager: AgentName; @@ -39,12 +39,17 @@ async function run( await exec(command, args, { nodeOptions: { cwd }, throwOnError: true }); return {}; } catch (e) { - // @ts-expect-error tinyexec rethrows the spawn error as-is - if (e?.code === 'ENOENT') return { notFound: true, error: `${command} not found` }; - // @ts-expect-error `output` is only present on tinyexec's `NonZeroExitError` - const output = e?.output as { stderr?: string; stdout?: string } | undefined; - // failures can land on either stream, so report both - const message = [output?.stderr, output?.stdout].filter(Boolean).join('\n').trim(); - return { error: message || (e instanceof Error ? e.message : 'unknown error') }; + // tinyexec rethrows the spawn error as-is + if ((e as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + return { notFound: true, error: `${command} not found` }; + } + if (e instanceof NonZeroExitError) { + // failures can land on either stream, so report both + const { stderr, stdout } = e.output ?? {}; + const message = [stderr, stdout].filter(Boolean).join('\n').trim(); + return { error: message || e.message }; + } + if (e instanceof Error) return { error: e.message }; + return { error: 'unknown error' }; } } From 9b31da4365fd6f44d5c327fb2dbb049ee959cec0 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 3 Aug 2026 21:41:26 +0800 Subject: [PATCH 29/32] throw --- packages/sv/src/addons/tests/drizzle/test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/sv/src/addons/tests/drizzle/test.ts b/packages/sv/src/addons/tests/drizzle/test.ts index 2c374adf1..372198c5e 100644 --- a/packages/sv/src/addons/tests/drizzle/test.ts +++ b/packages/sv/src/addons/tests/drizzle/test.ts @@ -48,7 +48,12 @@ beforeAll(() => { dockerInstalled = false; } - if (dockerInstalled) execSync('docker', ['compose', 'up', '--detach'], { nodeOptions: { cwd } }); + if (dockerInstalled) { + execSync('docker', ['compose', 'up', '--detach'], { + nodeOptions: { cwd }, + throwOnError: true + }); + } // cleans up the containers on interrupts (ctrl+c) process.addListener('SIGINT', () => { From 1ab1830738c64cd5455a7ec612d1b1f5c64e339b Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 3 Aug 2026 21:49:42 +0800 Subject: [PATCH 30/32] more throws --- packages/sv/src/cli/check.ts | 5 ++++- packages/sv/src/cli/migrate.ts | 5 ++++- packages/sv/src/cli/tests/cli.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/sv/src/cli/check.ts b/packages/sv/src/cli/check.ts index 0df455a6e..ae59243aa 100644 --- a/packages/sv/src/cli/check.ts +++ b/packages/sv/src/cli/check.ts @@ -40,7 +40,10 @@ async function runCheck(cwd: string, args: string[]) { // avoids printing the stack trace for `sv` when `svelte-check` exits with an error code try { const cmd = resolveCommand(pm, 'execute-local', ['svelte-check', ...args])!; - execSync(cmd.command, cmd.args, { nodeOptions: { cwd, stdio: 'inherit' } }); + execSync(cmd.command, cmd.args, { + nodeOptions: { cwd, stdio: 'inherit' }, + throwOnError: true + }); } catch (error) { forwardExitCode(error); } finally { diff --git a/packages/sv/src/cli/migrate.ts b/packages/sv/src/cli/migrate.ts index b62ef7896..c6b37ddae 100644 --- a/packages/sv/src/cli/migrate.ts +++ b/packages/sv/src/cli/migrate.ts @@ -24,7 +24,10 @@ async function runMigrate(cwd: string, args: string[]) { ]; const cmd = resolveCommand(pm, 'execute', newArgs)!; - execSync(cmd.command, cmd.args, { nodeOptions: { cwd, stdio: 'inherit' } }); + execSync(cmd.command, cmd.args, { + nodeOptions: { cwd, stdio: 'inherit' }, + throwOnError: true + }); } catch (error) { forwardExitCode(error); } diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index b99c7ebc0..fef3f6e7e 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -100,7 +100,7 @@ describe('cli', () => { * Same as `exec`. but `cwd` defaults to `testOutputPath` */ const run = (...params: Parameters) => { - const [command, args, options = {}] = params ?? []; + const [command, args, options = {}] = params; options.nodeOptions ??= {}; options.nodeOptions.cwd ??= testOutputPath; return exec(command, args, options); From 4b83db55bef15308573721dbdee50717fd0e3d30 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Wed, 5 Aug 2026 20:50:50 +0800 Subject: [PATCH 31/32] no type casting --- packages/sv/src/core/engine.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 6c910b073..35d864990 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -268,11 +268,14 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } nodeOptions: { cwd: workspace.cwd, stdio: TESTING ? 'pipe' : stdio }, throwOnError: true }); - } catch (error) { - const typedError = error as NonZeroExitError; - throw new Error(`Failed to execute scripts '${executedCommand}': ${typedError.message}`, { - cause: error - }); + } catch (e) { + let msg; + if (e instanceof NonZeroExitError || e instanceof Error) { + msg = `Failed to execute scripts '${executedCommand}': ${e.message}`; + } else { + msg = 'unknown error'; + } + throw new Error(msg, { cause: e }); } }, dependency: (pkg, version) => { From a0c77558e938bac6bc25da4e454bdb5eb93d0e8e Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Thu, 6 Aug 2026 03:14:36 +0800 Subject: [PATCH 32/32] add `isNodeError` --- packages/sv/src/core/common.ts | 4 ++++ packages/sv/src/core/formatFiles.ts | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/sv/src/core/common.ts b/packages/sv/src/core/common.ts index e9403ea19..75a311290 100644 --- a/packages/sv/src/core/common.ts +++ b/packages/sv/src/core/common.ts @@ -339,3 +339,7 @@ export const filePaths = { viteConfig: 'vite.config.js', viteConfigTS: 'vite.config.ts' } as const; + +export function isNodeError(e: unknown): e is Error & { code: string } { + return e instanceof Error && 'code' in e && typeof e.code === 'string'; +} diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts index 9a9829004..5d6abe57b 100644 --- a/packages/sv/src/core/formatFiles.ts +++ b/packages/sv/src/core/formatFiles.ts @@ -1,6 +1,7 @@ import * as p from '@clack/prompts'; import { type AgentName, resolveCommand } from '@sveltejs/sv-utils'; import { exec, NonZeroExitError } from 'tinyexec'; +import { isNodeError } from './common.ts'; export async function formatFiles(options: { packageManager: AgentName; @@ -39,8 +40,7 @@ async function run( await exec(command, args, { nodeOptions: { cwd }, throwOnError: true }); return {}; } catch (e) { - // tinyexec rethrows the spawn error as-is - if ((e as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + if (isNodeError(e) && e.code === 'ENOENT') { return { notFound: true, error: `${command} not found` }; } if (e instanceof NonZeroExitError) { @@ -49,7 +49,9 @@ async function run( const message = [stderr, stdout].filter(Boolean).join('\n').trim(); return { error: message || e.message }; } - if (e instanceof Error) return { error: e.message }; + if (e instanceof Error) { + return { error: e.message }; + } return { error: 'unknown error' }; } }