Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lovely-walls-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'sv': patch
---

feat(cli): `addOption` is now available in the setup phase to dynamically add options to your add-on
5 changes: 4 additions & 1 deletion documentation/docs/30-add-ons/99-community.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
37 changes: 35 additions & 2 deletions documentation/docs/50-api/10-sv.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()`.
Expand Down
12 changes: 6 additions & 6 deletions packages/sv/src/cli/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,15 +431,15 @@ 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)
// Only show selection prompt if no addons were specified at all
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)
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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
Expand Down
65 changes: 55 additions & 10 deletions packages/sv/src/core/config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -48,13 +56,16 @@ export type Addon<Args extends OptionDefinition, Id extends string = string> = {

/** 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<void>;
/** Run the addon. The actual execution of the addon... Add files, edit files, etc. */
run: (
workspace: Workspace & {
/** Add-on options */
options: WorkspaceOptions<Args>;
/** Add-on options (includes dynamically added options from setup) */
options: WorkspaceOptions<Args> & Record<string, unknown>;
/** Api to interact with the workspace. */
sv: SvApi;
/** Cancel the addon at any time!
Expand All @@ -65,16 +76,46 @@ export type Addon<Args extends OptionDefinition, Id extends string = string> = {
}
) => MaybePromise<void>;
/** Next steps to display after the addon is run. */
nextSteps?: (workspace: Workspace & { options: WorkspaceOptions<Args> }) => string[];
nextSteps?: (
workspace: Workspace & { options: WorkspaceOptions<Args> & Record<string, unknown> }
) => string[];
};

/** Maps value types to question definitions for dynamic setup options */
export type SetupOptions<T extends Record<string, unknown>> = {
[K in keyof T]: BaseQuestion<any> &
(T[K] extends boolean
? BooleanQuestion
: T[K] extends string
? StringQuestion
: T[K] extends number
? NumberQuestion
: Question<any>);
};

/**
* 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<const Id extends string, Args extends OptionDefinition>(
config: Addon<Args, Id>
): Addon<Args, Id> {
return config;
): Addon<Args, Id>;
export function defineAddon<SetupValues extends Record<string, unknown>>(): <
const Id extends string,
Args extends OptionDefinition
>(
config: Omit<Addon<Args & SetupOptions<SetupValues>, Id>, 'options'> & { options: Args }
) => Addon<Args & SetupOptions<SetupValues>, Id>;
export function defineAddon(...args: any[]): any {
if (args.length === 0) {
return (config: any) => config;
}
return args[0];
}

// ============================================================================
Expand All @@ -96,7 +137,7 @@ export function defineAddon<const Id extends string, Args extends OptionDefiniti
// │
// │ setupAddons()
// ▼
// PreparedAddon[] ── Stage 4: Setup done (dependencies resolved)
// PreparedAddon[] ── Stage 4: Setup done (dependencies resolved, dynamic options merged)
// │
// │ promptAddonQuestions()
// ▼
Expand Down Expand Up @@ -181,7 +222,12 @@ export function getErrorHint(source: AddonSource): string {
}
}

export type SetupResult = { dependsOn: string[]; unsupported: string[]; runsAfter: string[] };
export type SetupResult = {
dependsOn: string[];
unsupported: string[];
runsAfter: string[];
additionalOptions: Record<string, Question>;
};

export type AddonDefinition<Id extends string = string> = Addon<Record<string, Question<any>>, Id>;

Expand Down Expand Up @@ -256,8 +302,7 @@ export function defineAddonOptions(): OptionBuilder<{}> {
function createOptionBuilder<const T extends OptionDefinition>(options: T): OptionBuilder<T> {
return {
add(key, question) {
const newOptions = { ...options, [key]: question };
return createOptionBuilder(newOptions);
return createOptionBuilder({ ...options, [key]: question });
},
build() {
return options;
Expand Down
24 changes: 18 additions & 6 deletions packages/sv/src/core/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { addPnpmOnlyBuiltDependencies } from './package-manager.ts';
import { createWorkspace, type Workspace } from './workspace.ts';

Expand Down Expand Up @@ -89,7 +90,7 @@ export async function add<Addons extends AddonMap>({
createLoadedAddon(addon as AddonDefinition)
);

const setupResults = setupAddons(loadedAddons, workspace);
const setupResults = await setupAddons(loadedAddons, workspace);

return await applyAddons({ loadedAddons, workspace, options, setupResults });
}
Expand Down Expand Up @@ -152,28 +153,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<string, SetupResult> {
): Promise<Record<string, SetupResult>> {
const setupResults: Record<string, SetupResult> = {};

for (const loaded of loadedAddons) {
const addon = loaded.addon;
const additionalOptions: Record<string, Question> = {};
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);
Expand All @@ -182,6 +188,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;
}

Expand Down
4 changes: 3 additions & 1 deletion packages/sv/src/core/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,7 @@ export type OptionValues<Args extends OptionDefinition> = {
? Value
: Args[K] extends MultiSelectQuestion<infer Value>
? Value[]
: 'ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`.';
: Args[K] extends Question<any>
? unknown
: 'ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`.';
};
Loading
Loading