From fd236f633744b674f91d3e2217c8b84347aee9f3 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 4 Apr 2026 09:48:14 +0200 Subject: [PATCH 1/5] feat: addOption in setup phase --- .changeset/lovely-walls-retire.md | 5 + documentation/docs/30-add-ons/99-community.md | 5 +- documentation/docs/50-api/10-sv.md | 35 +++- packages/sv/src/cli/add.ts | 12 +- packages/sv/src/core/config.ts | 64 +++++-- packages/sv/src/core/engine.ts | 24 ++- packages/sv/src/core/options.ts | 4 +- packages/sv/src/core/tests/setup.ts | 167 ++++++++++++++++++ 8 files changed, 290 insertions(+), 26 deletions(-) create mode 100644 .changeset/lovely-walls-retire.md create mode 100644 packages/sv/src/core/tests/setup.ts diff --git a/.changeset/lovely-walls-retire.md b/.changeset/lovely-walls-retire.md new file mode 100644 index 000000000..d4ebb9473 --- /dev/null +++ b/.changeset/lovely-walls-retire.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +feat(cli): `addOption` is now available in the setup phase to dynamically add options to your add-on diff --git a/documentation/docs/30-add-ons/99-community.md b/documentation/docs/30-add-ons/99-community.md index a7e8d1a1b..fd5ba251a 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 70809023a..012b79b7b 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,29 @@ export default defineAddon({ The `sv` object in `run` provides `file`, `dependency`, `devDependency`, `execute`, and `pnpmBuildDependency`. For file transforms (AST-based editing of scripts, Svelte components, CSS, JSON, etc.), 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 +const addon = defineAddon<{ theme: string }>()({ + id: 'my-addon', + options: defineAddonOptions().build(), + setup: ({ addOption }) => { + addOption('theme', { + question: 'Which theme?', + type: 'string', + default: 'dark' + }); + }, + run: ({ options }) => { + options.theme; // string + } +}); +``` + +The type parameter maps value types (`boolean`, `string`, `number`) to question definitions. Without it, `defineAddon` stays strict and only allows statically defined options. + ## `defineAddonOptions` Builder for add-on options. Chained with `.add()` and finalized with `.build()`. diff --git a/packages/sv/src/cli/add.ts b/packages/sv/src/cli/add.ts index 466613ab6..111784faf 100644 --- a/packages/sv/src/cli/add.ts +++ b/packages/sv/src/cli/add.ts @@ -431,7 +431,7 @@ export async function promptAddonQuestions({ // If we have selected addons, run setup on them (regardless of official status) if (addons.length > 0) { - setupResults = setupAddons(addons, workspace); + setupResults = await setupAddons(addons, workspace); } // prompt which addons to apply (only when no addons were specified) @@ -439,7 +439,7 @@ export async function promptAddonQuestions({ if (addons.length === 0) { // For the prompt, we only show official addons const officialLoaded = officialAddons.map((a) => createLoadedAddon(a)); - const results = setupAddons(officialLoaded, workspace); + const results = await setupAddons(officialLoaded, workspace); const addonOptions = officialAddons // only display supported addons relative to the current environment .filter(({ id, hidden }) => results[id].unsupported.length === 0 && !hidden) @@ -467,14 +467,14 @@ export async function promptAddonQuestions({ } // Re-run setup for all selected addons (including any that were added via CLI options) - setupResults = setupAddons(addons, workspace); + setupResults = await setupAddons(addons, workspace); } // Ensure all selected addons have setup results // This should always be the case, but we add a safeguard const missingSetupResults = addons.filter((a) => !setupResults[a.addon.id]); if (missingSetupResults.length > 0) { - const additionalSetupResults = setupAddons(missingSetupResults, workspace); + const additionalSetupResults = await setupAddons(missingSetupResults, workspace); Object.assign(setupResults, additionalSetupResults); } @@ -547,7 +547,7 @@ export async function promptAddonQuestions({ // Run setup for any newly added dependencies const newlyAddedAddons = addons.filter((a) => !setupResults[a.addon.id]); if (newlyAddedAddons.length > 0) { - const newSetupResults = setupAddons(newlyAddedAddons, workspace); + const newSetupResults = await setupAddons(newlyAddedAddons, workspace); Object.assign(setupResults, newSetupResults); } } @@ -664,7 +664,7 @@ export async function runAddonsApply({ const setups = loadedAddons.length ? loadedAddons : officialAddons.map((a) => createLoadedAddon(a)); - setupResults = setupAddons(setups, workspace); + setupResults = await setupAddons(setups, workspace); } // we'll return early when no addons are selected, // indicating that installing deps was skipped and no PM was selected diff --git a/packages/sv/src/core/config.ts b/packages/sv/src/core/config.ts index 3d00c1412..2a5b6158b 100644 --- a/packages/sv/src/core/config.ts +++ b/packages/sv/src/core/config.ts @@ -1,5 +1,13 @@ import type { officialAddons } from '../addons/index.ts'; -import type { OptionDefinition, OptionValues, Question } from './options.ts'; +import type { + BaseQuestion, + BooleanQuestion, + NumberQuestion, + OptionDefinition, + OptionValues, + Question, + StringQuestion +} from './options.ts'; import type { Workspace, WorkspaceOptions } from './workspace.ts'; export type { OptionValues } from './options.ts'; @@ -62,13 +70,16 @@ 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: string, question: Question) => 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! @@ -79,16 +90,45 @@ 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 + : 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< + SetupValues extends Record +>(): ( + config: Omit, Id>, 'options'> & { options: Args } +) => Addon, Id>; +export function defineAddon(...args: any[]): any { + if (args.length === 0) { + return (config: any) => config; + } + return args[0]; } // ============================================================================ @@ -110,7 +150,7 @@ export function defineAddon; +}; export type AddonDefinition = Addon>, Id>; @@ -276,8 +321,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 b0bc4d053..986c14ec4 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -20,6 +20,7 @@ import { type SvApi } from './config.ts'; import { TESTING } from './env.ts'; +import type { Question } from './options.ts'; import { createWorkspace, type Workspace } from './workspace.ts'; export type InstallOptions = { @@ -53,7 +54,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 }); } @@ -120,28 +121,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); @@ -150,6 +156,12 @@ export function setupAddons( { cause: err } ); } + + // Merge dynamic options into the addon's options + if (Object.keys(additionalOptions).length > 0) { + Object.assign(addon.options, additionalOptions); + } + setupResults[addon.id] = setupResult; } diff --git a/packages/sv/src/core/options.ts b/packages/sv/src/core/options.ts index f110400ac..6f2069932 100644 --- a/packages/sv/src/core/options.ts +++ b/packages/sv/src/core/options.ts @@ -62,5 +62,7 @@ export type OptionValues = { ? Value : Args[K] extends MultiSelectQuestion ? Value[] - : 'ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`.'; + : Args[K] extends Question + ? unknown + : 'ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`.'; }; diff --git a/packages/sv/src/core/tests/setup.ts b/packages/sv/src/core/tests/setup.ts new file mode 100644 index 000000000..80822f35e --- /dev/null +++ b/packages/sv/src/core/tests/setup.ts @@ -0,0 +1,167 @@ +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: () => '' + }, + isKit: false, + directory: { src: 'src', lib: 'src/lib', kitRoutes: 'src/routes' }, + packageManager: 'npm' +}; + +function toLoaded(addon: Addon): LoadedAddon { + return createLoadedAddon(addon as AddonDefinition); +} + +describe('setupAddons', () => { + it('should return setup results with empty additionalOptions', async () => { + const addon: AddonDefinition = { + id: 'test-addon', + options: {}, + setup: () => { }, + run: () => { } + }; + + const results = await setupAddons([toLoaded(addon)], workspace); + + expect(results['test-addon']).toBeDefined(); + expect(results['test-addon'].additionalOptions).toEqual({}); + expect(results['test-addon'].dependsOn).toEqual([]); + expect(results['test-addon'].unsupported).toEqual([]); + expect(results['test-addon'].runsAfter).toEqual([]); + }); + + it('should collect dynamic options via addOption', async () => { + const addon: AddonDefinition = { + id: 'dynamic-addon', + options: {}, + setup: ({ addOption }) => { + addOption('org', { + question: 'Which org?', + type: 'string', + default: 'my-org' + }); + }, + run: () => { } + }; + + const results = await setupAddons([toLoaded(addon)], workspace); + + expect(results['dynamic-addon'].additionalOptions).toEqual({ + org: { question: 'Which org?', type: 'string', default: 'my-org' } + }); + }); + + it('should preserve strong typing with defineAddon and defineAddonOptions', async () => { + const options = defineAddonOptions() + .add('plugins', { + type: 'string', + question: 'Which Tailwind plugins do you want to use?', + default: 'hello' + }) + .build(); + + const addon = defineAddon<{ extra: boolean }>()({ + id: 'typed-addon', + options, + setup: ({ addOption }) => { + addOption('extra', { + question: 'Extra?', + type: 'boolean', + default: false + }); + }, + run: ({ options }) => { + // strong typing: these would fail at compile time + // if options.plugins wasn't string or options.extra wasn't boolean + expect(options.plugins).toBeDefined(); + expect(options.extra).toBeDefined(); + } + }); + + const results = await setupAddons([toLoaded(addon)], workspace); + + // static options are strongly typed + expect(addon.options.plugins.default).toBe('hello'); + + // dynamic options from defineAddon<{ extra: boolean }>() are also strongly typed + expect(addon.options.extra.default).toBe(false); + + // dynamic options are available via setup result too + expect(results['typed-addon'].additionalOptions).toHaveProperty('extra'); + }); + + it('should await async setup', async () => { + const addon: AddonDefinition = { + id: 'async-addon', + options: {}, + setup: async ({ addOption }) => { + await Promise.resolve(); + addOption('fetched', { + question: 'Fetched option?', + type: 'boolean', + default: true + }); + }, + run: () => { } + }; + + const results = await setupAddons([toLoaded(addon)], workspace); + + expect(results['async-addon'].additionalOptions).toHaveProperty('fetched'); + expect(addon.options).toHaveProperty('fetched'); + }); + + it('should collect multiple dynamic options', async () => { + const addon: AddonDefinition = { + id: 'multi-addon', + options: {}, + setup: ({ addOption }) => { + addOption('org', { + question: 'Which org?', + type: 'string', + default: '' + }); + addOption('theme', { + question: 'Pick theme', + type: 'select', + default: 'dark', + options: [ + { value: 'dark', label: 'Dark' }, + { value: 'light', label: 'Light' } + ] + }); + }, + run: () => { } + }; + + const results = await setupAddons([toLoaded(addon)], workspace); + + expect(Object.keys(results['multi-addon'].additionalOptions)).toEqual(['org', 'theme']); + expect(Object.keys(addon.options)).toEqual(['org', 'theme']); + }); +}); From c08077957848c2a2c56ccbb94b4b36feaa98ed39 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sun, 5 Apr 2026 09:35:20 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fmt=C2=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/sv/src/core/config.ts | 7 ++++--- packages/sv/src/core/tests/setup.ts | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/sv/src/core/config.ts b/packages/sv/src/core/config.ts index 2a5b6158b..876f3e7c0 100644 --- a/packages/sv/src/core/config.ts +++ b/packages/sv/src/core/config.ts @@ -119,9 +119,10 @@ export type SetupOptions> = { export function defineAddon( config: Addon ): Addon; -export function defineAddon< - SetupValues extends Record ->(): ( +export function defineAddon>(): < + const Id extends string, + Args extends OptionDefinition +>( config: Omit, Id>, 'options'> & { options: Args } ) => Addon, Id>; export function defineAddon(...args: any[]): any { diff --git a/packages/sv/src/core/tests/setup.ts b/packages/sv/src/core/tests/setup.ts index 80822f35e..7e09d7141 100644 --- a/packages/sv/src/core/tests/setup.ts +++ b/packages/sv/src/core/tests/setup.ts @@ -42,8 +42,8 @@ describe('setupAddons', () => { const addon: AddonDefinition = { id: 'test-addon', options: {}, - setup: () => { }, - run: () => { } + setup: () => {}, + run: () => {} }; const results = await setupAddons([toLoaded(addon)], workspace); @@ -66,7 +66,7 @@ describe('setupAddons', () => { default: 'my-org' }); }, - run: () => { } + run: () => {} }; const results = await setupAddons([toLoaded(addon)], workspace); @@ -127,7 +127,7 @@ describe('setupAddons', () => { default: true }); }, - run: () => { } + run: () => {} }; const results = await setupAddons([toLoaded(addon)], workspace); @@ -156,7 +156,7 @@ describe('setupAddons', () => { ] }); }, - run: () => { } + run: () => {} }; const results = await setupAddons([toLoaded(addon)], workspace); From de598f53de7fec9ecabbedfed9d27702c27e66ca Mon Sep 17 00:00:00 2001 From: jycouet Date: Sun, 5 Apr 2026 15:20:30 +0200 Subject: [PATCH 3/5] okay cut --- documentation/docs/50-api/10-sv.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/docs/50-api/10-sv.md b/documentation/docs/50-api/10-sv.md index 012b79b7b..bfa896fbd 100644 --- a/documentation/docs/50-api/10-sv.md +++ b/documentation/docs/50-api/10-sv.md @@ -63,6 +63,8 @@ The `sv` object in `run` provides `file`, `dependency`, `devDependency`, `execut 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(), From ceee0be91b6fce1a1d03b539570f868c3865538c Mon Sep 17 00:00:00 2001 From: jycouet Date: Wed, 8 Apr 2026 08:41:14 +0200 Subject: [PATCH 4/5] humm --- packages/sv/src/core/tests/setup.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/sv/src/core/tests/setup.ts b/packages/sv/src/core/tests/setup.ts index 7e09d7141..476657b61 100644 --- a/packages/sv/src/core/tests/setup.ts +++ b/packages/sv/src/core/tests/setup.ts @@ -26,7 +26,8 @@ const workspace: Workspace = { eslintConfig: 'eslint.config.js', vscodeSettings: '.vscode/settings.json', vscodeExtensions: '.vscode/extensions.json', - getRelative: () => '' + getRelative: () => '', + findUp: () => '' }, isKit: false, directory: { src: 'src', lib: 'src/lib', kitRoutes: 'src/routes' }, @@ -42,8 +43,8 @@ describe('setupAddons', () => { const addon: AddonDefinition = { id: 'test-addon', options: {}, - setup: () => {}, - run: () => {} + setup: () => { }, + run: () => { } }; const results = await setupAddons([toLoaded(addon)], workspace); @@ -66,7 +67,7 @@ describe('setupAddons', () => { default: 'my-org' }); }, - run: () => {} + run: () => { } }; const results = await setupAddons([toLoaded(addon)], workspace); @@ -127,7 +128,7 @@ describe('setupAddons', () => { default: true }); }, - run: () => {} + run: () => { } }; const results = await setupAddons([toLoaded(addon)], workspace); @@ -156,7 +157,7 @@ describe('setupAddons', () => { ] }); }, - run: () => {} + run: () => { } }; const results = await setupAddons([toLoaded(addon)], workspace); From d1152c226cf34f1fcd03713a061ab76dd7a7608d Mon Sep 17 00:00:00 2001 From: jycouet Date: Wed, 8 Apr 2026 08:44:55 +0200 Subject: [PATCH 5/5] fmt --- packages/sv/src/core/tests/setup.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/sv/src/core/tests/setup.ts b/packages/sv/src/core/tests/setup.ts index 476657b61..1442c303e 100644 --- a/packages/sv/src/core/tests/setup.ts +++ b/packages/sv/src/core/tests/setup.ts @@ -43,8 +43,8 @@ describe('setupAddons', () => { const addon: AddonDefinition = { id: 'test-addon', options: {}, - setup: () => { }, - run: () => { } + setup: () => {}, + run: () => {} }; const results = await setupAddons([toLoaded(addon)], workspace); @@ -67,7 +67,7 @@ describe('setupAddons', () => { default: 'my-org' }); }, - run: () => { } + run: () => {} }; const results = await setupAddons([toLoaded(addon)], workspace); @@ -128,7 +128,7 @@ describe('setupAddons', () => { default: true }); }, - run: () => { } + run: () => {} }; const results = await setupAddons([toLoaded(addon)], workspace); @@ -157,7 +157,7 @@ describe('setupAddons', () => { ] }); }, - run: () => { } + run: () => {} }; const results = await setupAddons([toLoaded(addon)], workspace);