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/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/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 341a04976..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", @@ -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/CHANGELOG.md b/packages/sv-utils/CHANGELOG.md index fc1bd3b3f..c7153b580 100644 --- a/packages/sv-utils/CHANGELOG.md +++ b/packages/sv-utils/CHANGELOG.md @@ -1,5 +1,17 @@ # @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 + + +- 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/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/package.json b/packages/sv-utils/package.json index bc25c41f1..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.1", + "version": "0.3.3", "type": "module", "description": "Utility functions for sv", "license": "MIT", @@ -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/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/CHANGELOG.md b/packages/sv/CHANGELOG.md index 899fd5300..23e898c1e 100644 --- a/packages/sv/CHANGELOG.md +++ b/packages/sv/CHANGELOG.md @@ -1,5 +1,44 @@ # 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 + + +- 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/api-surface-testing.md b/packages/sv/api-surface-testing.md index e995f67d9..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; @@ -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..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; @@ -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/package.json b/packages/sv/package.json index b22995887..ddb46782b 100644 --- a/packages/sv/package.json +++ b/packages/sv/package.json @@ -1,6 +1,6 @@ { "name": "sv", - "version": "0.16.5", + "version": "0.17.0", "type": "module", "description": "A command line interface (CLI) for creating and maintaining Svelte applications", "license": "MIT", 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/better-auth.ts b/packages/sv/src/addons/better-auth.ts index ef5d21dc8..60f8bef61 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', @@ -181,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('!')});`; @@ -212,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` ); }) ); @@ -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..5b08a7267 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -3,12 +3,12 @@ import { dedent, type TransformFn, transforms, - pnpm, resolveCommandArray, fileExists, createPrinter, svelteConfig, - defineEnv + defineEnv, + isKit3 } from '@sveltejs/sv-utils'; import crypto from 'node:crypto'; import fs from 'node:fs'; @@ -93,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 = { @@ -132,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') @@ -300,15 +287,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/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/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/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/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/addons/tests/better-auth/test.ts b/packages/sv/src/addons/tests/better-auth/test.ts index 4954877ad..d29f06d0e 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 }, 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', { cwd, stdio: 'pipe' }); + 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 4ba45200c..372198c5e 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 'node:child_process'; 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'; @@ -42,21 +42,28 @@ beforeAll(() => { const cwd = path.dirname(fileURLToPath(import.meta.url)); try { - execSync('docker --version', { cwd, stdio: 'pipe' }); + execSync('docker', ['--version'], { nodeOptions: { cwd }, throwOnError: true }); dockerInstalled = true; } catch { dockerInstalled = false; } - if (dockerInstalled) execSync('docker compose up --detach', { cwd, stdio: 'pipe' }); + if (dockerInstalled) { + execSync('docker', ['compose', 'up', '--detach'], { + nodeOptions: { cwd }, + throwOnError: true + }); + } // 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 +99,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 }, throwOnError: true }); 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..43c92c831 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,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', { cwd, stdio: 'pipe' })).toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should fail on unlinted file' + ).not.toBe(0); - expect(() => execSync('pnpm eslint --fix .', { cwd, stdio: 'pipe' })).not.toThrow(); + expect( + execSync('pnpm', ['eslint', '--fix', '.'], { nodeOptions: { cwd } }).exitCode, + 'eslint --fix should succeed' + ).toBe(0); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).not.toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should pass after fix' + ).toBe(0); }); 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/addons/tests/prettier/test.ts b/packages/sv/src/addons/tests/prettier/test.ts index 9af677bcc..ae0379f97 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,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', { cwd, stdio: 'pipe' })).toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should fail on unformatted file' + ).not.toBe(0); - expect(() => execSync('pnpm format', { cwd, stdio: 'pipe' })).not.toThrow(); + expect( + execSync('pnpm', ['format'], { nodeOptions: { cwd } }).exitCode, + 'format should succeed' + ).toBe(0); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).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); 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'] diff --git a/packages/sv/src/cli/add.ts b/packages/sv/src/cli/add.ts index 783046cd5..cba90b999 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'; @@ -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 @@ -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/check.ts b/packages/sv/src/cli/check.ts index fd5f2dfa0..ae59243aa 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 { color, resolveCommand, 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,11 @@ 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 = resolveCommand(pm, 'execute-local', ['svelte-check', ...args])!; + execSync(cmd.command, cmd.args, { + nodeOptions: { cwd, stdio: 'inherit' }, + throwOnError: true + }); } catch (error) { forwardExitCode(error); } finally { diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index 67da051de..f112a8cc7 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', []))}`); } @@ -170,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.' @@ -405,12 +411,18 @@ 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) { + const filesToFormat = addOnSuccessfulAddons.some((addon) => addon.addon.id === 'prettier') + ? ['.'] + : addOnFilesToFormat; + await formatFiles({ packageManager, cwd: projectPath, filesToFormat }); + } } - 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/cli/migrate.ts b/packages/sv/src/cli/migrate.ts index 22886dc60..c6b37ddae 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 { resolveCommand } 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'; @@ -9,21 +9,25 @@ 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'); - - execSync(resolveCommandArray(pm, 'execute', cmdArgs).join(' '), { stdio: 'inherit', cwd }); + const cmd = resolveCommand(pm, 'execute', newArgs)!; + 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 c2f04fccd..fef3f6e7e 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! ] }, @@ -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, @@ -75,9 +96,19 @@ describe('cli', () => { ...args ]; + /** + * Same as `exec`. but `cwd` defaults to `testOutputPath` + */ + const run = (...params: Parameters) => { + const [command, args, options = {}] = params; + options.nodeOptions ??= {}; + options.nodeOptions.cwd ??= testOutputPath; + return exec(command, args, options); + }; + // 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( @@ -102,11 +133,32 @@ 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 + 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,29 +198,64 @@ 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 } - }); + // the generated project lives inside this repo, so it must not join its workspace + 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}` ).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 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'); @@ -201,10 +288,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 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-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/.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/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", 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/common.ts b/packages/sv/src/core/common.ts index dce24eab7..75a311290 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); @@ -334,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/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..35d864990 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; } @@ -238,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})`)}` @@ -249,18 +261,21 @@ 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 }); - } 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) => { @@ -307,14 +322,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; } diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts index 39d7eb2e3..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 } from 'tinyexec'; +import { exec, NonZeroExitError } from 'tinyexec'; +import { isNodeError } from './common.ts'; export async function formatFiles(options: { packageManager: AgentName; @@ -11,24 +12,46 @@ 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 }, throwOnError: true }); + return {}; + } catch (e) { + if (isNodeError(e) && e.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' }; + } +} 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/package-manager.ts b/packages/sv/src/core/package-manager.ts index 21376bf88..5c1fcc28d 100644 --- a/packages/sv/src/core/package-manager.ts +++ b/packages/sv/src/core/package-manager.ts @@ -1,32 +1,21 @@ 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'; 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 +24,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 +50,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), @@ -56,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 { @@ -77,7 +82,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 +90,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 +103,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 +113,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, 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']); + }); +}); diff --git a/packages/sv/src/core/verifiers.ts b/packages/sv/src/core/verifiers.ts index 884ef20de..465a26566 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'; @@ -10,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 asyncExec = promisify(exec); - const { stdout } = await asyncExec('git status --short', { - cwd - }); - - 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. + // 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 }; } }); } 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/packages/sv/src/create/tests/check.ts b/packages/sv/src/create/tests/check.ts index 455a2a3a9..7c9eac824 100644 --- a/packages/sv/src/create/tests/check.ts +++ b/packages/sv/src/create/tests/check.ts @@ -1,11 +1,10 @@ -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'; import { type LanguageType, type TemplateType, create } from '../index.ts'; // Resolve the given path relative to the current file @@ -20,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( @@ -37,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[]; @@ -48,8 +45,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')); @@ -61,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..18a835461 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 } 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'; @@ -82,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; @@ -129,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); @@ -228,7 +223,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 }); @@ -366,7 +364,7 @@ export function createSetupTest( const installDir = path.resolve(cwd, testName); const install = await exec('pnpm', ['install'], { - nodeOptions: { cwd: installDir, stdio: 'pipe' } + nodeOptions: { cwd: installDir } }); if (install.exitCode !== 0) { throw new Error( 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: 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()); + } +}