From 5f4e72188ae9413eb0feaefc59f97e40a9767aef Mon Sep 17 00:00:00 2001 From: David Kizivat Date: Mon, 8 Sep 2025 03:07:48 +0200 Subject: [PATCH 01/21] add --add option to create & extract question prompting for addons --- packages/cli/commands/add/index.ts | 488 +++++++++++++++++------------ packages/cli/commands/create.ts | 75 ++++- 2 files changed, 341 insertions(+), 222 deletions(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index e92e6d66..a789d5bb 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -1,23 +1,26 @@ +import * as p from '@clack/prompts'; +import { + communityAddonIds, + getAddonDetails, + getCommunityAddon, + officialAddons +} from '@sveltejs/addons'; +import type { + AddonSetupResult, + AddonWithoutExplicitArgs, + OptionValues, + PackageManager +} from '@sveltejs/cli-core'; +import { Command } from 'commander'; +import * as pkg from 'empathic/package'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { type AgentName } from 'package-manager-detector'; import pc from 'picocolors'; import * as v from 'valibot'; -import * as pkg from 'empathic/package'; -import * as p from '@clack/prompts'; -import { Command } from 'commander'; -import { - officialAddons, - getAddonDetails, - communityAddonIds, - getCommunityAddon -} from '@sveltejs/addons'; -import type { AgentName } from 'package-manager-detector'; -import type { AddonWithoutExplicitArgs, OptionValues, PackageManager } from '@sveltejs/cli-core'; +import { applyAddons, setupAddons, type AddonMap } from '../../lib/install.ts'; import * as common from '../../utils/common.ts'; -import { createWorkspace } from './workspace.ts'; -import { formatFiles, getHighlighter } from './utils.ts'; -import { Directive, downloadPackage, getPackageJSON } from './fetch-packages.ts'; import { addPnpmBuildDependencies, AGENT_NAMES, @@ -25,8 +28,9 @@ import { installOption, packageManagerPrompt } from '../../utils/package-manager.ts'; -import { verifyCleanWorkingDirectory, verifyUnsupportedAddons } from './verifiers.ts'; -import { type AddonMap, applyAddons, setupAddons } from '../../lib/install.ts'; +import { Directive, downloadPackage, getPackageJSON } from './fetch-packages.ts'; +import { formatFiles, getHighlighter } from './utils.ts'; +import { createWorkspace } from './workspace.ts'; const aliases = officialAddons.map((c) => c.alias).filter((v) => v !== undefined); const addonOptions = getAddonOptionFlags(); @@ -42,35 +46,16 @@ const OptionsSchema = v.strictObject({ }); type Options = v.InferOutput; -type AddonArgs = { id: string; options: string[] | undefined }; +export type AddonArgs = { id: string; options: string[] | undefined }; // infers the workspace cwd if a `package.json` resides in a parent directory const defaultPkgPath = pkg.up(); const defaultCwd = defaultPkgPath ? path.dirname(defaultPkgPath) : undefined; export const add = new Command('add') .description('applies specified add-ons into a project') - .argument('[add-on...]', `add-ons to install`, (value, prev: AddonArgs[] = []) => { - const [addonId, optionFlags] = value.split('=', 2); - - // validates that there are no repeated add-ons (e.g. `sv add foo=demo:yes foo=demo:no`) - const repeatedAddons = prev.find(({ id }) => id === addonId); - if (repeatedAddons) { - console.error(`Malformed arguments: Add-on '${addonId}' is repeated multiple times.`); - process.exit(1); - } - - try { - const options = common.parseAddonOptions(optionFlags); - prev.push({ id: addonId, options }); - } catch (error) { - if (error instanceof Error) { - console.error(error.message); - } - process.exit(1); - } - - return prev; - }) + .argument('[add-on...]', `add-ons to install`, (value: string, previous: AddonArgs[] = []) => + addonArgsHandler(previous, value) + ) .option('-C, --cwd ', 'path to working directory', defaultCwd) .option('--no-git-check', 'even if some files are dirty, no prompt will be shown') .option('--no-install', 'skip installing dependencies') @@ -177,57 +162,63 @@ export const add = new Command('add') process.exit(1); } - const addonIds = officialAddons.map((addon) => addon.id); - const invalidAddons = addonArgs - .filter(({ id }) => !addonIds.includes(id) && !aliases.includes(id)) - .map(({ id }) => id); - if (invalidAddons.length > 0) { - console.error(`Invalid add-ons specified: ${invalidAddons.join(', ')}`); - process.exit(1); - } + const selectedAddonArgs = sanitizeAddons(addonArgs); const options = v.parse(OptionsSchema, { ...opts, addons: {} }); - const selectedAddons = transformAliases(addonArgs); - selectedAddons.forEach((addon) => (options.addons[addon.id] = addon.options)); + selectedAddonArgs.forEach((addon) => (options.addons[addon.id] = addon.options)); common.runCommand(async () => { - const selectedAddonIds = selectedAddons.map(({ id }) => id); - const { nextSteps } = await runAddCommand(options, selectedAddonIds); + const selectedAddonIds = selectedAddonArgs.map(({ id }) => id); + + const { answersCommunity, answersOfficial, selectedAddons } = await promptAddonQuestions( + options, + selectedAddonIds + ); + + const { nextSteps } = await runAddonsApply( + { answersCommunity, answersOfficial }, + options, + selectedAddons + ); if (nextSteps.length > 0) { p.note(nextSteps.join('\n'), 'Next steps', { format: (line) => line }); } }); }); -type SelectedAddon = { type: 'official' | 'community'; addon: AddonWithoutExplicitArgs }; -export async function runAddCommand( - options: Options, - selectedAddonIds: string[] -): Promise<{ nextSteps: string[]; packageManager?: AgentName | null }> { - const selectedAddons: SelectedAddon[] = selectedAddonIds.map((id) => ({ - type: 'official', - addon: getAddonDetails(id) - })); +export type SelectedAddon = { type: 'official' | 'community'; addon: AddonWithoutExplicitArgs }; + +export async function promptAddonQuestions(options: Options, selectedAddonIds: string[]) { + const selectedOfficialAddons: Array = []; + + // Find which official addons were specified in the args + selectedAddonIds.map((id) => { + if (officialAddons.find((a) => a.id === id)) { + selectedOfficialAddons.push(getAddonDetails(id)); + } + }); - type AddonId = string; - type QuestionValues = OptionValues; - type AddonOption = Record; + const emptyAnswersReducer = (acc: Record>, id: string) => { + acc[id] = {}; + return acc; + }; - const official: AddonOption = {}; - const community: AddonOption = {}; + const answersOfficial: Record> = selectedOfficialAddons + .map(({ id }) => id) + .reduce(emptyAnswersReducer, {}); - // apply specified options from flags + // apply specified options from CLI, inquire about the rest for (const addonOption of addonOptions) { const addonId = addonOption.id; const specifiedOptions = options.addons[addonId]; if (!specifiedOptions) continue; const details = getAddonDetails(addonId); - if (!selectedAddons.find((d) => d.addon === details)) { - selectedAddons.push({ type: 'official', addon: details }); + if (!selectedOfficialAddons.find((d) => d === details)) { + selectedOfficialAddons.push(details); } - official[addonId] ??= {}; + answersOfficial[addonId] ??= {}; const optionEntries = Object.entries(details.options); for (const option of specifiedOptions) { @@ -250,7 +241,7 @@ export async function runAddCommand( if (question.type === 'multiselect' && optionValue === 'none') optionValue = ''; // validate that there are no conflicts - let existingOption = official[addonId][questionId]; + let existingOption = answersOfficial[addonId][questionId]; if (existingOption !== undefined) { if (typeof existingOption === 'boolean') { // need to transform the boolean back to `yes` or `no` @@ -262,24 +253,26 @@ export async function runAddCommand( } if (question.type === 'boolean') { - official[addonId][questionId] = optionValue === 'yes'; + answersOfficial[addonId][questionId] = optionValue === 'yes'; } else if (question.type === 'number') { - official[addonId][questionId] = Number(optionValue); + answersOfficial[addonId][questionId] = Number(optionValue); } else { - official[addonId][questionId] = optionValue; + answersOfficial[addonId][questionId] = optionValue; } } // apply defaults to unspecified options for (const [id, question] of Object.entries(details.options)) { // we'll only apply defaults to options that don't explicitly fail their conditions - if (question.condition?.(official[addonId]) !== false) { - official[addonId][id] ??= question.default; + if (question.condition?.(answersOfficial[addonId]) !== false) { + answersOfficial[addonId][id] ??= question.default; } else { // we'll also error out if a specified option is incompatible with other options. // (e.g. `libsql` isn't a valid client for a `mysql` database: `sv add drizzle=database:mysql2,client:libsql`) - if (official[addonId][id] !== undefined) { - throw new Error(`Incompatible '${addonId}' option specified: '${official[addonId][id]}'`); + if (answersOfficial[addonId][id] !== undefined) { + throw new Error( + `Incompatible '${addonId}' option specified: '${answersOfficial[addonId][id]}'` + ); } } } @@ -314,80 +307,34 @@ export async function runAddCommand( options.community = selected; } - // validate and download community addons - if (Array.isArray(options.community) && options.community.length > 0) { - // validate addons - const addons = options.community.map((id) => { - // ids with directives are passed unmodified so they can be processed during downloads - const hasDirective = Object.values(Directive).some((directive) => id.startsWith(directive)); - if (hasDirective) return id; - - const validAddon = communityAddonIds.includes(id); - if (!validAddon) { - throw new Error( - `Invalid community add-on specified: '${id}'\nAvailable options: ${communityAddonIds.join(', ')}` - ); - } - return id; - }); - - // get addon details from remote addons - const { start, stop } = p.spinner(); - try { - start('Resolving community add-on packages'); - const pkgs = await Promise.all( - addons.map(async (id) => { - return await getPackageJSON({ cwd: options.cwd, packageName: id }); - }) - ); - stop('Resolved community add-on packages'); - - p.log.warn( - 'The Svelte maintainers have not reviewed community add-ons for malicious code. Use at your discretion.' - ); - - const paddingName = common.getPadding(pkgs.map(({ pkg }) => pkg.name)); - const paddingVersion = common.getPadding(pkgs.map(({ pkg }) => `(v${pkg.version})`)); - - const packageInfos = pkgs.map(({ pkg, repo: _repo }) => { - const name = pc.yellowBright(pkg.name.padEnd(paddingName)); - const version = pc.dim(`(v${pkg.version})`.padEnd(paddingVersion)); - const repo = pc.dim(`(${_repo})`); - return `${name} ${version} ${repo}`; - }); - p.log.message(packageInfos.join('\n')); - - const confirm = await p.confirm({ message: 'Would you like to continue?' }); - if (confirm !== true) { - p.cancel('Operation cancelled.'); - process.exit(1); - } + // we'll prepare empty answers for selected community addons + const selectedCommunityAddons: Array = []; + const answersCommunity: Record> = selectedCommunityAddons + .map(({ id }) => id) + .reduce(emptyAnswersReducer, {}); - start('Downloading community add-on packages'); - const details = await Promise.all(pkgs.map(async (opts) => downloadPackage(opts))); - for (const addon of details) { - const id = addon.id; - community[id] ??= {}; - communityDetails.push(addon); - selectedAddons.push({ type: 'community', addon }); - } - stop('Downloaded community add-on packages'); - } catch (err) { - stop('Failed to resolve community add-on packages', 1); - throw err; - } + // Find community addons specified in the --community option as well as + // the ones selected above + if (Array.isArray(options.community) && options.community.length > 0) { + selectedCommunityAddons.push(...(await resolveCommunityAddons(options.cwd, options.community))); } - // prepare official addons - let workspace = await createWorkspace({ cwd: options.cwd }); - const setups = selectedAddons.length ? selectedAddons.map(({ addon }) => addon) : officialAddons; - const addonSetupResults = setupAddons(setups, workspace); + const selectedAddons: SelectedAddon[] = [ + ...selectedOfficialAddons.map((addon) => ({ type: 'official' as const, addon })), + ...selectedCommunityAddons.map((addon) => ({ type: 'community' as const, addon })) + ]; + + // TODO: run setup if we have access to workspace + // let workspace = await createWorkspace({ cwd: options.cwd }); + // const setups = selectedAddons.length ? selectedAddons.map(({ addon }) => addon) : officialAddons; + // const addonSetupResults = setupAddons(setups, workspace); // prompt which addons to apply if (selectedAddons.length === 0) { const addonOptions = officialAddons + // TODO: do the filter if we have access to workspace // only display supported addons relative to the current environment - .filter(({ id }) => addonSetupResults[id].unsupported.length === 0) + // .filter(({ id }) => addonSetupResults[id].unsupported.length === 0) .map(({ id, homepage, shortDescription }) => ({ label: id, value: id, @@ -410,75 +357,77 @@ export async function runAddCommand( } } - // add inter-addon dependencies - for (const { addon } of selectedAddons) { - workspace = await createWorkspace(workspace); - - const setupResult = addonSetupResults[addon.id]; - const missingDependencies = setupResult.dependsOn.filter( - (depId) => !selectedAddons.some((a) => a.addon.id === depId) - ); - - for (const depId of missingDependencies) { - // TODO: this will have to be adjusted when we work on community add-ons - const dependency = officialAddons.find((a) => a.id === depId); - if (!dependency) throw new Error(`'${addon.id}' depends on an invalid add-on: '${depId}'`); - - // prompt to install the dependent - const install = await p.confirm({ - message: `The ${pc.bold(pc.cyan(addon.id))} add-on requires ${pc.bold(pc.cyan(depId))} to also be setup. ${pc.green('Include it?')}` - }); - if (install !== true) { - p.cancel('Operation cancelled.'); - process.exit(1); - } - selectedAddons.push({ type: 'official', addon: dependency }); - } - } - - // run verifications - const addons = selectedAddons.map(({ addon }) => addon); - const verifications = [ - ...verifyCleanWorkingDirectory(options.cwd, options.gitCheck), - ...verifyUnsupportedAddons(addons, addonSetupResults) - ]; - - const fails: Array<{ name: string; message?: string }> = []; - for (const verification of verifications) { - const { message, success } = await verification.run(); - if (!success) fails.push({ name: verification.name, message }); - } - - if (fails.length > 0) { - const message = fails - .map(({ name, message }) => pc.yellow(`${name} (${message})`)) - .join('\n- '); - - p.note(`- ${message}`, 'Verifications not met', { format: (line) => line }); - - const force = await p.confirm({ - message: 'Verifications failed. Do you wish to continue?', - initialValue: false - }); - if (p.isCancel(force) || !force) { - p.cancel('Operation cancelled.'); - process.exit(1); - } - } + // TODO: add verifications and inter-addon deps + + // // add inter-addon dependencies + // for (const { addon } of selectedAddons) { + // workspace = await createWorkspace(workspace); + + // const setupResult = addonSetupResults[addon.id]; + // const missingDependencies = setupResult.dependsOn.filter( + // (depId) => !selectedAddons.some((a) => a.addon.id === depId) + // ); + + // for (const depId of missingDependencies) { + // // TODO: this will have to be adjusted when we work on community add-ons + // const dependency = officialAddons.find((a) => a.id === depId); + // if (!dependency) throw new Error(`'${addon.id}' depends on an invalid add-on: '${depId}'`); + + // // prompt to install the dependent + // const install = await p.confirm({ + // message: `The ${pc.bold(pc.cyan(addon.id))} add-on requires ${pc.bold(pc.cyan(depId))} to also be setup. ${pc.green('Include it?')}` + // }); + // if (install !== true) { + // p.cancel('Operation cancelled.'); + // process.exit(1); + // } + // selectedAddons.push({ type: 'official', addon: dependency }); + // } + // } + + // // run verifications + // const addons = selectedAddons.map(({ addon }) => addon); + // const verifications = [ + // ...verifyCleanWorkingDirectory(options.cwd, options.gitCheck), + // ...verifyUnsupportedAddons(addons, addonSetupResults) + // ]; + + // const fails: Array<{ name: string; message?: string }> = []; + // for (const verification of verifications) { + // const { message, success } = await verification.run(); + // if (!success) fails.push({ name: verification.name, message }); + // } + + // if (fails.length > 0) { + // const message = fails + // .map(({ name, message }) => pc.yellow(`${name} (${message})`)) + // .join('\n- '); + + // p.note(`- ${message}`, 'Verifications not met', { format: (line) => line }); + + // const force = await p.confirm({ + // message: 'Verifications failed. Do you wish to continue?', + // initialValue: false + // }); + // if (p.isCancel(force) || !force) { + // p.cancel('Operation cancelled.'); + // process.exit(1); + // } + // } // ask remaining questions for (const { addon, type } of selectedAddons) { const addonId = addon.id; const questionPrefix = selectedAddons.length > 1 ? `${addon.id}: ` : ''; - let values: QuestionValues = {}; + let values: OptionValues = {}; if (type === 'official') { - official[addonId] ??= {}; - values = official[addonId]; + answersOfficial[addonId] ??= {}; + values = answersOfficial[addonId]; } if (type === 'community') { - community[addonId] ??= {}; - values = community[addonId]; + answersCommunity[addonId] ??= {}; + values = answersCommunity[addonId]; } for (const [questionId, question] of Object.entries(addon.options)) { @@ -525,13 +474,35 @@ export async function runAddCommand( } } + return { selectedAddons, answersOfficial, answersCommunity }; +} + +export async function runAddonsApply( + { + answersOfficial, + answersCommunity + }: { + answersOfficial: Record>; + answersCommunity: Record>; + }, + options: Options, + selectedAddons: SelectedAddon[], + addonSetupResults?: Record +): Promise<{ nextSteps: string[]; packageManager?: AgentName | null }> { + let workspace = await createWorkspace({ cwd: options.cwd }); + if (!addonSetupResults) { + const setups = selectedAddons.length + ? selectedAddons.map(({ addon }) => addon) + : officialAddons; + addonSetupResults = setupAddons(setups, workspace); + } // we'll return early when no addons are selected, // indicating that installing deps was skipped and no PM was selected if (selectedAddons.length === 0) return { packageManager: null, nextSteps: [] }; // apply addons - const officialDetails = Object.keys(official).map((id) => getAddonDetails(id)); - const commDetails = Object.keys(community).map( + const officialDetails = Object.keys(answersOfficial).map((id) => getAddonDetails(id)); + const commDetails = Object.keys(answersCommunity).map( (id) => communityDetails.find((a) => a.id === id)! ); const details = officialDetails.concat(commDetails); @@ -541,7 +512,7 @@ export async function runAddCommand( workspace, addonSetupResults, addons: addonMap, - options: official + options: answersOfficial }); p.log.success('Successfully setup add-ons'); @@ -586,7 +557,7 @@ export async function runAddCommand( if (!addon.nextSteps) return; let addonMessage = `${pc.green(addon.id)}:\n`; - const options = official[addon.id]; + const options = answersOfficial[addon.id]; const addonNextSteps = addon.nextSteps({ ...workspace, options, highlighter }); addonMessage += ` - ${addonNextSteps.join('\n - ')}`; return addonMessage; @@ -596,6 +567,49 @@ export async function runAddCommand( return { nextSteps, packageManager }; } +/** + * Sanitizes the add-on arguments by checking for invalid add-ons and transforming aliases. + * @param addonArgs The add-on arguments to sanitize. + * @returns The sanitized add-on arguments. + */ +export function sanitizeAddons(addonArgs: AddonArgs[]): AddonArgs[] { + const officialAddonIds = officialAddons.map((addon) => addon.id); + const invalidAddons = addonArgs + .filter(({ id }) => !officialAddonIds.includes(id) && !aliases.includes(id)) + .map(({ id }) => id); + if (invalidAddons.length > 0) { + console.error(`Invalid add-ons specified: ${invalidAddons.join(', ')}`); + process.exit(1); + } + return transformAliases(addonArgs); +} + +/** + * Handles passed add-on arguments, accumulating them into an array of {@link AddonArgs}. + */ +export function addonArgsHandler(acc: AddonArgs[], current: string): AddonArgs[] { + const [addonId, optionFlags] = current.split('=', 2); + + // validates that there are no repeated add-ons (e.g. `sv add foo=demo:yes foo=demo:no`) + const repeatedAddons = acc.find(({ id }) => id === addonId); + if (repeatedAddons) { + console.error(`Malformed arguments: Add-on '${addonId}' is repeated multiple times.`); + process.exit(1); + } + + try { + const options = common.parseAddonOptions(optionFlags); + acc.push({ id: addonId, options }); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } + process.exit(1); + } + + return acc; +} + /** * Dedupes and transforms aliases into their respective addon id */ @@ -675,3 +689,63 @@ function getOptionChoices(details: AddonWithoutExplicitArgs) { } return { choices, defaults, groups }; } + +async function resolveCommunityAddons(cwd: string, community: string[]) { + const selectedAddons: Array = []; + const addons = community.map((id) => { + // ids with directives are passed unmodified so they can be processed during downloads + const hasDirective = Object.values(Directive).some((directive) => id.startsWith(directive)); + if (hasDirective) return id; + + const validAddon = communityAddonIds.includes(id); + if (!validAddon) { + throw new Error( + `Invalid community add-on specified: '${id}'\nAvailable options: ${communityAddonIds.join(', ')}` + ); + } + return id; + }); + const { start, stop } = p.spinner(); + try { + start('Resolving community add-on packages'); + const pkgs = await Promise.all( + addons.map(async (id) => { + return await getPackageJSON({ cwd, packageName: id }); + }) + ); + stop('Resolved community add-on packages'); + + p.log.warn( + 'The Svelte maintainers have not reviewed community add-ons for malicious code. Use at your discretion.' + ); + + const paddingName = common.getPadding(pkgs.map(({ pkg }) => pkg.name)); + const paddingVersion = common.getPadding(pkgs.map(({ pkg }) => `(v${pkg.version})`)); + + const packageInfos = pkgs.map(({ pkg, repo: _repo }) => { + const name = pc.yellowBright(pkg.name.padEnd(paddingName)); + const version = pc.dim(`(v${pkg.version})`.padEnd(paddingVersion)); + const repo = pc.dim(`(${_repo})`); + return `${name} ${version} ${repo}`; + }); + p.log.message(packageInfos.join('\n')); + + const confirm = await p.confirm({ message: 'Would you like to continue?' }); + if (confirm !== true) { + p.cancel('Operation cancelled.'); + process.exit(1); + } + + start('Downloading community add-on packages'); + const details = await Promise.all(pkgs.map(async (opts) => downloadPackage(opts))); + for (const addon of details) { + communityDetails.push(addon); + selectedAddons.push(addon); + } + stop('Downloaded community add-on packages'); + } catch (err) { + stop('Failed to resolve community add-on packages', 1); + throw err; + } + return selectedAddons; +} diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index e1ea5a16..85dc5460 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -1,19 +1,19 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import process from 'node:process'; -import * as v from 'valibot'; -import { Command, Option } from 'commander'; import * as p from '@clack/prompts'; -import pc from 'picocolors'; +import type { OptionValues } from '@sveltejs/cli-core'; import { create as createKit, templates, type LanguageType, type TemplateType } from '@sveltejs/create'; -import * as common from '../utils/common.ts'; -import { runAddCommand } from './add/index.ts'; +import { Command, Option } from 'commander'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; import { detect, resolveCommand, type AgentName } from 'package-manager-detector'; +import pc from 'picocolors'; +import * as v from 'valibot'; +import * as common from '../utils/common.ts'; import { addPnpmBuildDependencies, AGENT_NAMES, @@ -22,6 +22,13 @@ import { installOption, packageManagerPrompt } from '../utils/package-manager.ts'; +import { + addonArgsHandler, + promptAddonQuestions, + runAddonsApply, + sanitizeAddons, + type SelectedAddon +} from './add/index.ts'; const langs = ['ts', 'jsdoc'] as const; const langMap: Record = { @@ -34,6 +41,8 @@ const langOption = new Option('--types ', 'add type checking').choices(lan const templateOption = new Option('--template ', 'template to scaffold').choices( templateChoices ); +const noAddonsOption = new Option('--no-add-ons', 'do not prompt to add add-ons').conflicts('add'); +const addOption = new Option('--add ', 'add-on to include').default([]); const ProjectPathSchema = v.optional(v.string()); const OptionsSchema = v.strictObject({ @@ -42,6 +51,7 @@ const OptionsSchema = v.strictObject({ v.transform((lang) => langMap[String(lang)]) ), addOns: v.boolean(), + add: v.array(v.string()), install: v.union([v.boolean(), v.picklist(AGENT_NAMES)]), template: v.optional(v.picklist(templateChoices)) }); @@ -54,11 +64,13 @@ export const create = new Command('create') .addOption(templateOption) .addOption(langOption) .option('--no-types') - .option('--no-add-ons', 'skips interactive add-on installer') + .addOption(addOption) + .addOption(noAddonsOption) .option('--no-install', 'skip installing dependencies') .addOption(installOption) .configureHelp(common.helpConfig) .action((projectPath, opts) => { + console.log(opts); const cwd = v.parse(ProjectPathSchema, projectPath); const options = v.parse(OptionsSchema, opts); common.runCommand(async () => { @@ -163,6 +175,38 @@ async function createProject(cwd: ProjectPath, options: Options) { ); const projectPath = path.resolve(directory); + + let selectedAddons: SelectedAddon[] = []; + let answersOfficial: Record> = {}; + let answersCommunity: Record> = {}; + let sanitizedAddonsMap: Record = {}; + + if (options.addOns || options.add.length > 0) { + const addons = options.add.reduce(addonArgsHandler, []); + sanitizedAddonsMap = sanitizeAddons(addons).reduce>( + (acc, curr) => { + acc[curr.id] = curr.options; + return acc; + }, + {} + ); + + const result = await promptAddonQuestions( + { + cwd: projectPath, + install: false, + gitCheck: false, + community: [], + addons: sanitizedAddonsMap + }, + Object.keys(sanitizedAddonsMap) + ); + + selectedAddons = result.selectedAddons; + answersOfficial = result.answersOfficial; + answersCommunity = result.answersCommunity; + } + createKit(projectPath, { name: path.basename(projectPath), template, @@ -180,18 +224,19 @@ async function createProject(cwd: ProjectPath, options: Options) { if (packageManager) await installDependencies(packageManager, projectPath); }; - if (options.addOns) { - // `runAddCommand` includes installing dependencies - const { nextSteps, packageManager: pm } = await runAddCommand( + if (options.addOns || options.add.length > 0) { + const { nextSteps, packageManager: pm } = await runAddonsApply( + { answersOfficial, answersCommunity }, { cwd: projectPath, - install: options.install, + install: false, gitCheck: false, community: [], - addons: {} + addons: sanitizedAddonsMap }, - [] + selectedAddons ); + packageManager = pm; addOnNextSteps = nextSteps; } else if (options.install) { From 0848b54d75ad84634a66e887e757e98d76476962 Mon Sep 17 00:00:00 2001 From: jycouet Date: Fri, 3 Oct 2025 14:11:18 +0200 Subject: [PATCH 02/21] rmv log --- packages/cli/commands/create.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 85dc5460..2076b572 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -70,7 +70,6 @@ export const create = new Command('create') .addOption(installOption) .configureHelp(common.helpConfig) .action((projectPath, opts) => { - console.log(opts); const cwd = v.parse(ProjectPathSchema, projectPath); const options = v.parse(OptionsSchema, opts); common.runCommand(async () => { From 2b9a0a210921fcee30d0b5f7b78f297629806d64 Mon Sep 17 00:00:00 2001 From: jycouet Date: Fri, 3 Oct 2025 14:52:50 +0200 Subject: [PATCH 03/21] hisplay help after conflicts --- packages/cli/commands/create.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 2076b572..53951d17 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -64,8 +64,8 @@ export const create = new Command('create') .addOption(templateOption) .addOption(langOption) .option('--no-types') - .addOption(addOption) .addOption(noAddonsOption) + .addOption(addOption) .option('--no-install', 'skip installing dependencies') .addOption(installOption) .configureHelp(common.helpConfig) @@ -113,7 +113,8 @@ export const create = new Command('create') p.note(steps.join('\n'), "What's next?", { format: (line) => line }); }); - }); + }) + .showHelpAfterError(true); async function createProject(cwd: ProjectPath, options: Options) { const { directory, template, language } = await p.group( From b596a9778ad4cff9ddae078de4b9e5b37cef6743 Mon Sep 17 00:00:00 2001 From: jycouet Date: Fri, 3 Oct 2025 17:26:03 +0200 Subject: [PATCH 04/21] update // TODO_ONE --- packages/cli/commands/add/index.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index 95dd3cd7..c34c4162 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -326,7 +326,7 @@ export async function promptAddonQuestions(options: Options, selectedAddonIds: s ...selectedCommunityAddons.map((addon) => ({ type: 'community' as const, addon })) ]; - // TODO: run setup if we have access to workspace + // TODO_ONE: run setup if we have access to workspace // prepare official addons // let workspace = await createWorkspace({ cwd: options.cwd }); // const setups = selectedAddons.length ? selectedAddons.map(({ addon }) => addon) : officialAddons; @@ -336,7 +336,7 @@ export async function promptAddonQuestions(options: Options, selectedAddonIds: s if (selectedAddons.length === 0) { // const allSetupResults = setupAddons(officialAddons, workspace); const addonOptions = officialAddons - // TODO: do the filter if we have access to workspace + // TODO_ONE: do the filter if we have access to workspace // only display supported addons relative to the current environment // .filter(({ id }) => allSetupResults[id].unsupported.length === 0) .map(({ id, homepage, shortDescription }) => ({ @@ -361,28 +361,16 @@ export async function promptAddonQuestions(options: Options, selectedAddonIds: s } } - // TODO: add verifications and inter-addon deps + // TODO_ONE: add verifications and inter-addon deps // // add inter-addon dependencies // for (const { addon } of selectedAddons) { // workspace = await createWorkspace(workspace); - // const setups = selectedAddons.map(({ addon }) => addon); - // const setupResult = setupAddons(setups, workspace)[addon.id]; - // const missingDependencies = setupResult.dependsOn.filter( - // (depId) => !selectedAddons.some((a) => a.addon.id === depId) - // ); - - // TODO: CONFLICS // const setupResult = addonSetupResults[addon.id]; // const missingDependencies = setupResult.dependsOn.filter( // (depId) => !selectedAddons.some((a) => a.addon.id === depId) // ); - // for (const depId of missingDependencies) { - // // TODO: this will have to be adjusted when we work on community add-ons - // const dependency = getAddonDetails(depId); - // if (!dependency) throw new Error(`'${addon.id}' depends on an invalid add-on: '${depId}'`); - // TODO: CONFLICS // for (const depId of missingDependencies) { // // TODO: this will have to be adjusted when we work on community add-ons From c3b1145dcc69aa87d97758c698433ef2787c8694 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sun, 26 Oct 2025 16:23:46 +0100 Subject: [PATCH 05/21] createVirtualWorkspace first step --- packages/cli/commands/add/index.ts | 144 +++++++++++++------------ packages/cli/commands/add/workspace.ts | 2 +- packages/cli/commands/create.ts | 34 +++++- 3 files changed, 107 insertions(+), 73 deletions(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index 56bd2b6f..0e31d2e8 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -12,7 +12,8 @@ import type { AddonSetupResult, AddonWithoutExplicitArgs, OptionValues, - PackageManager + PackageManager, + Workspace } from '@sveltejs/cli-core'; import { Command } from 'commander'; import * as pkg from 'empathic/package'; @@ -22,6 +23,7 @@ import * as v from 'valibot'; import { applyAddons, setupAddons, type AddonMap } from '../../lib/install.ts'; import * as common from '../../utils/common.ts'; +import { verifyCleanWorkingDirectory, verifyUnsupportedAddons } from './verifiers.ts'; import { addPnpmBuildDependencies, AGENT_NAMES, @@ -190,7 +192,11 @@ export const add = new Command('add') export type SelectedAddon = { type: 'official' | 'community'; addon: AddonWithoutExplicitArgs }; -export async function promptAddonQuestions(options: Options, selectedAddonIds: string[]) { +export async function promptAddonQuestions( + options: Options, + selectedAddonIds: string[], + virtualWorkspace?: Workspace +) { const selectedOfficialAddons: Array = []; // Find which official addons were specified in the args @@ -326,19 +332,18 @@ export async function promptAddonQuestions(options: Options, selectedAddonIds: s ...selectedCommunityAddons.map((addon) => ({ type: 'community' as const, addon })) ]; - // TODO_ONE: run setup if we have access to workspace + // run setup if we have access to workspace // prepare official addons - // let workspace = await createWorkspace({ cwd: options.cwd }); - // const setups = selectedAddons.length ? selectedAddons.map(({ addon }) => addon) : officialAddons; - // const addonSetupResults = setupAddons(setups, workspace); + let workspace = virtualWorkspace || (await createWorkspace({ cwd: options.cwd })); + const setups = selectedAddons.length ? selectedAddons.map(({ addon }) => addon) : officialAddons; + const addonSetupResults = setupAddons(setups, workspace); // prompt which addons to apply if (selectedAddons.length === 0) { - // const allSetupResults = setupAddons(officialAddons, workspace); + const allSetupResults = setupAddons(officialAddons, workspace); const addonOptions = officialAddons - // TODO_ONE: do the filter if we have access to workspace // only display supported addons relative to the current environment - // .filter(({ id }) => allSetupResults[id].unsupported.length === 0) + .filter(({ id }) => allSetupResults[id].unsupported.length === 0) .map(({ id, homepage, shortDescription }) => ({ label: id, value: id, @@ -361,63 +366,63 @@ export async function promptAddonQuestions(options: Options, selectedAddonIds: s } } - // TODO_ONE: add verifications and inter-addon deps - - // // add inter-addon dependencies - // for (const { addon } of selectedAddons) { - // workspace = await createWorkspace(workspace); - - // const setupResult = addonSetupResults[addon.id]; - // const missingDependencies = setupResult.dependsOn.filter( - // (depId) => !selectedAddons.some((a) => a.addon.id === depId) - // ); - - // for (const depId of missingDependencies) { - // // TODO: this will have to be adjusted when we work on community add-ons - // const dependency = officialAddons.find((a) => a.id === depId); - // if (!dependency) throw new Error(`'${addon.id}' depends on an invalid add-on: '${depId}'`); - - // // prompt to install the dependent - // const install = await p.confirm({ - // message: `The ${pc.bold(pc.cyan(addon.id))} add-on requires ${pc.bold(pc.cyan(depId))} to also be setup. ${pc.green('Include it?')}` - // }); - // if (install !== true) { - // p.cancel('Operation cancelled.'); - // process.exit(1); - // } - // selectedAddons.push({ type: 'official', addon: dependency }); - // } - // } - - // // run all setups after inter-addon deps have been added - // const addons = selectedAddons.map(({ addon }) => addon); - // const verifications = [ - // ...verifyCleanWorkingDirectory(options.cwd, options.gitCheck), - // ...verifyUnsupportedAddons(addons, addonSetupResults) - // ]; - - // const fails: Array<{ name: string; message?: string }> = []; - // for (const verification of verifications) { - // const { message, success } = await verification.run(); - // if (!success) fails.push({ name: verification.name, message }); - // } - - // if (fails.length > 0) { - // const message = fails - // .map(({ name, message }) => pc.yellow(`${name} (${message})`)) - // .join('\n- '); - - // p.note(`- ${message}`, 'Verifications not met', { format: (line) => line }); - - // const force = await p.confirm({ - // message: 'Verifications failed. Do you wish to continue?', - // initialValue: false - // }); - // if (p.isCancel(force) || !force) { - // p.cancel('Operation cancelled.'); - // process.exit(1); - // } - // } + // add verifications and inter-addon deps + + // add inter-addon dependencies + for (const { addon } of selectedAddons) { + workspace = virtualWorkspace || (await createWorkspace({ ...workspace })); + + const setupResult = addonSetupResults[addon.id]; + const missingDependencies = setupResult.dependsOn.filter( + (depId) => !selectedAddons.some((a) => a.addon.id === depId) + ); + + for (const depId of missingDependencies) { + // TODO: this will have to be adjusted when we work on community add-ons + const dependency = officialAddons.find((a) => a.id === depId); + if (!dependency) throw new Error(`'${addon.id}' depends on an invalid add-on: '${depId}'`); + + // prompt to install the dependent + const install = await p.confirm({ + message: `The ${pc.bold(pc.cyan(addon.id))} add-on requires ${pc.bold(pc.cyan(depId))} to also be setup. ${pc.green('Include it?')}` + }); + if (install !== true) { + p.cancel('Operation cancelled.'); + process.exit(1); + } + selectedAddons.push({ type: 'official', addon: dependency }); + } + } + + // run all setups after inter-addon deps have been added + const addons = selectedAddons.map(({ addon }) => addon); + const verifications = [ + ...verifyCleanWorkingDirectory(options.cwd, options.gitCheck), + ...verifyUnsupportedAddons(addons, addonSetupResults) + ]; + + const fails: Array<{ name: string; message?: string }> = []; + for (const verification of verifications) { + const { message, success } = await verification.run(); + if (!success) fails.push({ name: verification.name, message }); + } + + if (fails.length > 0) { + const message = fails + .map(({ name, message }) => pc.yellow(`${name} (${message})`)) + .join('\n- '); + + p.note(`- ${message}`, 'Verifications not met', { format: (line) => line }); + + const force = await p.confirm({ + message: 'Verifications failed. Do you wish to continue?', + initialValue: false + }); + if (p.isCancel(force) || !force) { + p.cancel('Operation cancelled.'); + process.exit(1); + } + } // ask remaining questions for (const { addon, type } of selectedAddons) { @@ -491,9 +496,10 @@ export async function runAddonsApply( }, options: Options, selectedAddons: SelectedAddon[], - addonSetupResults?: Record + addonSetupResults?: Record, + virtualWorkspace?: Workspace ): Promise<{ nextSteps: string[]; packageManager?: AgentName | null }> { - let workspace = await createWorkspace({ cwd: options.cwd }); + let workspace = virtualWorkspace || (await createWorkspace({ cwd: options.cwd })); if (!addonSetupResults) { const setups = selectedAddons.length ? selectedAddons.map(({ addon }) => addon) @@ -556,7 +562,7 @@ export async function runAddonsApply( } // format modified/created files with prettier (if available) - workspace = await createWorkspace(workspace); + workspace = virtualWorkspace || (await createWorkspace({ ...workspace })); if (filesToFormat.length > 0 && packageManager && !!workspace.dependencyVersion('prettier')) { const { start, stop } = p.spinner(); start('Formatting modified files'); diff --git a/packages/cli/commands/add/workspace.ts b/packages/cli/commands/add/workspace.ts index a4a0578a..64c7f64a 100644 --- a/packages/cli/commands/add/workspace.ts +++ b/packages/cli/commands/add/workspace.ts @@ -10,8 +10,8 @@ import { getUserAgent } from '../../utils/package-manager.ts'; type CreateWorkspaceOptions = { cwd: string; - packageManager?: PackageManager; options?: OptionValues; + packageManager?: PackageManager; }; export async function createWorkspace({ cwd, diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index cf4c3144..5d452722 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import * as p from '@clack/prompts'; -import type { OptionValues } from '@sveltejs/cli-core'; +import type { OptionValues, PackageManager, Workspace } from '@sveltejs/cli-core'; import { create as createKit, templates, @@ -224,7 +224,8 @@ async function createProject(cwd: ProjectPath, options: Options) { community: [], addons: sanitizedAddonsMap }, - Object.keys(sanitizedAddonsMap) + Object.keys(sanitizedAddonsMap), + await createVirtualWorkspace(template, projectPath, 'npm') ); selectedAddons = result.selectedAddons; @@ -267,7 +268,9 @@ async function createProject(cwd: ProjectPath, options: Options) { community: [], addons: sanitizedAddonsMap }, - selectedAddons + selectedAddons, + undefined, + await createVirtualWorkspace(template, projectPath, 'npm') ); packageManager = pm; @@ -320,3 +323,28 @@ async function confirmExternalDependencies(dependencies: string[]): Promise> { + const workspace: Workspace = { + cwd: path.resolve(cwd), + options: {}, + packageManager: packageManager ?? (await detect({ cwd }))?.name ?? getUserAgent() ?? 'npm', + typescript: false, + viteConfigFile: 'vite.config.js', + kit: undefined, + dependencyVersion: () => undefined + }; + + if (template === 'minimal' || template === 'demo' || template === 'library') { + workspace.kit = { + routesDirectory: 'src/routes', + libDirectory: 'src/lib' + }; + } + + return workspace; +} From 1695e31f56218ddc2c83faf0037104c61f313ec8 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sun, 26 Oct 2025 16:26:58 +0100 Subject: [PATCH 06/21] args --- packages/cli/commands/create.ts | 36 ++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 5d452722..53d00c93 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -225,7 +225,12 @@ async function createProject(cwd: ProjectPath, options: Options) { addons: sanitizedAddonsMap }, Object.keys(sanitizedAddonsMap), - await createVirtualWorkspace(template, projectPath, 'npm') + await createVirtualWorkspace({ + cwd: projectPath, + template, + packageManager: 'npm', + type: language + }) ); selectedAddons = result.selectedAddons; @@ -270,7 +275,12 @@ async function createProject(cwd: ProjectPath, options: Options) { }, selectedAddons, undefined, - await createVirtualWorkspace(template, projectPath, 'npm') + await createVirtualWorkspace({ + cwd: projectPath, + template, + packageManager: 'npm', + type: language + }) ); packageManager = pm; @@ -324,17 +334,25 @@ async function confirmExternalDependencies(dependencies: string[]): Promise> { +interface CreateVirtualWorkspaceOptions { + cwd: string; + template: TemplateType; + packageManager?: PackageManager; + type?: LanguageType; +} + +export async function createVirtualWorkspace({ + cwd, + template, + packageManager, + type = 'none' +}: CreateVirtualWorkspaceOptions): Promise> { const workspace: Workspace = { cwd: path.resolve(cwd), options: {}, packageManager: packageManager ?? (await detect({ cwd }))?.name ?? getUserAgent() ?? 'npm', - typescript: false, - viteConfigFile: 'vite.config.js', + typescript: type === 'typescript', + viteConfigFile: type === 'typescript' ? 'vite.config.ts' : 'vite.config.js', kit: undefined, dependencyVersion: () => undefined }; From 4c425add668175b21adf1e0203680502739040ab Mon Sep 17 00:00:00 2001 From: jycouet Date: Sun, 26 Oct 2025 16:28:42 +0100 Subject: [PATCH 07/21] easier to read --- packages/cli/commands/add/index.ts | 35 ++++++++++++++++-------------- packages/cli/commands/create.ts | 13 ++++++----- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index 0e31d2e8..6938c574 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -179,11 +179,12 @@ export const add = new Command('add') selectedAddonIds ); - const { nextSteps } = await runAddonsApply( - { answersCommunity, answersOfficial }, + const { nextSteps } = await runAddonsApply({ + answersOfficial, + answersCommunity, options, selectedAddons - ); + }); if (nextSteps.length > 0) { p.note(nextSteps.join('\n'), 'Next steps', { format: (line) => line }); } @@ -486,19 +487,21 @@ export async function promptAddonQuestions( return { selectedAddons, answersOfficial, answersCommunity }; } -export async function runAddonsApply( - { - answersOfficial, - answersCommunity - }: { - answersOfficial: Record>; - answersCommunity: Record>; - }, - options: Options, - selectedAddons: SelectedAddon[], - addonSetupResults?: Record, - virtualWorkspace?: Workspace -): Promise<{ nextSteps: string[]; packageManager?: AgentName | null }> { +export async function runAddonsApply({ + answersOfficial, + answersCommunity, + options, + selectedAddons, + addonSetupResults, + virtualWorkspace +}: { + answersOfficial: Record>; + answersCommunity: Record>; + options: Options; + selectedAddons: SelectedAddon[]; + addonSetupResults?: Record; + virtualWorkspace?: Workspace; +}): Promise<{ nextSteps: string[]; packageManager?: AgentName | null }> { let workspace = virtualWorkspace || (await createWorkspace({ cwd: options.cwd })); if (!addonSetupResults) { const setups = selectedAddons.length diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 53d00c93..da9f5c53 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -264,9 +264,10 @@ async function createProject(cwd: ProjectPath, options: Options) { }; if (options.addOns || options.add.length > 0) { - const { nextSteps, packageManager: pm } = await runAddonsApply( - { answersOfficial, answersCommunity }, - { + const { nextSteps, packageManager: pm } = await runAddonsApply({ + answersOfficial, + answersCommunity, + options: { cwd: projectPath, install: false, gitCheck: false, @@ -274,14 +275,14 @@ async function createProject(cwd: ProjectPath, options: Options) { addons: sanitizedAddonsMap }, selectedAddons, - undefined, - await createVirtualWorkspace({ + addonSetupResults: undefined, + virtualWorkspace: await createVirtualWorkspace({ cwd: projectPath, template, packageManager: 'npm', type: language }) - ); + }); packageManager = pm; addOnNextSteps = nextSteps; From e66e86137c631859a3bd927d0b6ee60be160aa2b Mon Sep 17 00:00:00 2001 From: jycouet Date: Sun, 26 Oct 2025 16:40:10 +0100 Subject: [PATCH 08/21] feat(cli): `npx sv create` now supports a new argument `--add` --- .changeset/smooth-foxes-unite.md | 5 +++++ documentation/docs/20-commands/10-sv-create.md | 10 ++++++++++ 2 files changed, 15 insertions(+) create mode 100644 .changeset/smooth-foxes-unite.md diff --git a/.changeset/smooth-foxes-unite.md b/.changeset/smooth-foxes-unite.md new file mode 100644 index 00000000..47bbe1fa --- /dev/null +++ b/.changeset/smooth-foxes-unite.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +feat(cli): `npx sv create` now supports a new argument `--add` to add add-ons to the project in the same command. diff --git a/documentation/docs/20-commands/10-sv-create.md b/documentation/docs/20-commands/10-sv-create.md index 29f8aef1..be5d7163 100644 --- a/documentation/docs/20-commands/10-sv-create.md +++ b/documentation/docs/20-commands/10-sv-create.md @@ -41,6 +41,16 @@ Whether and how to add typechecking to the project: Prevent typechecking from being added. Not recommended! +### `--add [add-ons...]` + +Add add-ons to the project in the `create` command. Following the same format as [sv add](sv-add#Usage). + +Example: + +```sh +npx sv create --add eslint prettier +``` + ### `--no-add-ons` Run the command without the interactive add-ons prompt From a981ca276bdbc77481d99723e28fc4c806f28f89 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 19:56:59 +0100 Subject: [PATCH 09/21] the only easy stuff --- packages/cli/commands/add/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index 3efc443a..a2643654 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -392,8 +392,6 @@ export async function promptAddonQuestions( } } - // add verifications and inter-addon deps - // add inter-addon dependencies for (const { addon } of selectedAddons) { workspace = virtualWorkspace || (await createWorkspace({ ...workspace })); From f43b4f11763b1636923efe0e3fcc34b69ea4bb68 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 20:02:06 +0100 Subject: [PATCH 10/21] check --- packages/cli/commands/create.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index da9f5c53..87570968 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -37,6 +37,7 @@ import { sanitizeAddons, type SelectedAddon } from './add/index.ts'; +import { commonFilePaths } from './add/utils.ts'; const langs = ['ts', 'jsdoc'] as const; const langMap: Record = { @@ -353,7 +354,11 @@ export async function createVirtualWorkspace({ options: {}, packageManager: packageManager ?? (await detect({ cwd }))?.name ?? getUserAgent() ?? 'npm', typescript: type === 'typescript', - viteConfigFile: type === 'typescript' ? 'vite.config.ts' : 'vite.config.js', + files: { + viteConfig: type === 'typescript' ? commonFilePaths.viteConfigTS : commonFilePaths.viteConfig, + svelteConfig: + type === 'typescript' ? commonFilePaths.svelteConfigTS : commonFilePaths.svelteConfig + }, kit: undefined, dependencyVersion: () => undefined }; From d9d39fa8e4b66156b1111cdd9301d6e8b5f1c684 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 20:21:29 +0100 Subject: [PATCH 11/21] refacto workspace & workspaceOptions --- packages/cli/commands/add/index.ts | 41 ++++++++++++++------------ packages/cli/commands/add/utils.ts | 4 +-- packages/cli/commands/add/workspace.ts | 7 ++--- packages/cli/commands/create.ts | 34 ++++++++++----------- packages/cli/lib/install.ts | 18 ++++++----- packages/core/addon/config.ts | 10 +++---- packages/core/addon/processors.ts | 6 ++-- packages/core/addon/workspace.ts | 5 ++-- 8 files changed, 63 insertions(+), 62 deletions(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index a2643654..2ea5cd54 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -151,7 +151,7 @@ export const add = new Command('add') return output.join('\n'); } }) - .action((addonArgs: AddonArgs[], opts) => { + .action(async (addonArgs: AddonArgs[], opts) => { // validate workspace if (opts.cwd === undefined) { console.error( @@ -171,19 +171,23 @@ export const add = new Command('add') const options = v.parse(OptionsSchema, { ...opts, addons: {} }); selectedAddonArgs.forEach((addon) => (options.addons[addon.id] = addon.options)); + const workspace = await createWorkspace({ cwd: options.cwd }); + common.runCommand(async () => { const selectedAddonIds = selectedAddonArgs.map(({ id }) => id); - const { answersCommunity, answersOfficial, selectedAddons } = await promptAddonQuestions( + const { answersCommunity, answersOfficial, selectedAddons } = await promptAddonQuestions({ options, - selectedAddonIds - ); + selectedAddonIds, + workspace + }); const { nextSteps } = await runAddonsApply({ answersOfficial, answersCommunity, options, - selectedAddons + selectedAddons, + workspace }); if (nextSteps.length > 0) { p.note(nextSteps.join('\n'), 'Next steps', { format: (line) => line }); @@ -193,11 +197,15 @@ export const add = new Command('add') export type SelectedAddon = { type: 'official' | 'community'; addon: AddonWithoutExplicitArgs }; -export async function promptAddonQuestions( - options: Options, - selectedAddonIds: string[], - virtualWorkspace?: Workspace -) { +export async function promptAddonQuestions({ + options, + selectedAddonIds, + workspace +}: { + options: Options; + selectedAddonIds: string[]; + workspace: Workspace; +}) { const selectedOfficialAddons: Array = []; // Find which official addons were specified in the args @@ -360,7 +368,6 @@ export async function promptAddonQuestions( // run setup if we have access to workspace // prepare official addons - let workspace = virtualWorkspace || (await createWorkspace({ cwd: options.cwd })); const setups = selectedAddons.length ? selectedAddons.map(({ addon }) => addon) : officialAddons; const addonSetupResults = setupAddons(setups, workspace); @@ -394,8 +401,6 @@ export async function promptAddonQuestions( // add inter-addon dependencies for (const { addon } of selectedAddons) { - workspace = virtualWorkspace || (await createWorkspace({ ...workspace })); - const setupResult = addonSetupResults[addon.id]; const missingDependencies = setupResult.dependsOn.filter( (depId) => !selectedAddons.some((a) => a.addon.id === depId) @@ -516,16 +521,15 @@ export async function runAddonsApply({ options, selectedAddons, addonSetupResults, - virtualWorkspace + workspace }: { answersOfficial: Record>; answersCommunity: Record>; options: Options; selectedAddons: SelectedAddon[]; addonSetupResults?: Record; - virtualWorkspace?: Workspace; + workspace: Workspace; }): Promise<{ nextSteps: string[]; packageManager?: AgentName | null }> { - let workspace = virtualWorkspace || (await createWorkspace({ cwd: options.cwd })); if (!addonSetupResults) { const setups = selectedAddons.length ? selectedAddons.map(({ addon }) => addon) @@ -588,7 +592,6 @@ export async function runAddonsApply({ } // format modified/created files with prettier (if available) - workspace = virtualWorkspace || (await createWorkspace({ ...workspace })); if (filesToFormat.length > 0 && packageManager && !!workspace.dependencyVersion('prettier')) { const { start, stop } = p.spinner(); start('Formatting modified files'); @@ -607,8 +610,8 @@ export async function runAddonsApply({ const nextSteps = selectedAddons .map(({ addon }) => { if (!addon.nextSteps) return; - const options = answersOfficial[addon.id]; - const addonNextSteps = addon.nextSteps({ ...workspace, options, highlighter }); + const addonOptions = answersOfficial[addon.id]; + const addonNextSteps = addon.nextSteps({ ...workspace, options: addonOptions, highlighter }); if (addonNextSteps.length === 0) return; let addonMessage = `${pc.green(addon.id)}:\n`; diff --git a/packages/cli/commands/add/utils.ts b/packages/cli/commands/add/utils.ts index 76f4e1de..282d1f81 100644 --- a/packages/cli/commands/add/utils.ts +++ b/packages/cli/commands/add/utils.ts @@ -59,7 +59,7 @@ export function readFile(cwd: string, filePath: string): string { export function installPackages( dependencies: Array<{ pkg: string; version: string; dev: boolean }>, - workspace: Workspace + workspace: Workspace ): string { const { data, generateCode } = getPackageJson(workspace.cwd); @@ -89,7 +89,7 @@ function alphabetizeProperties(obj: Record) { return orderedObj; } -export function writeFile(workspace: Workspace, filePath: string, content: string): void { +export function writeFile(workspace: Workspace, filePath: string, content: string): void { const fullFilePath = path.resolve(workspace.cwd, filePath); const fullDirectoryPath = path.dirname(fullFilePath); diff --git a/packages/cli/commands/add/workspace.ts b/packages/cli/commands/add/workspace.ts index beac415a..9a705aba 100644 --- a/packages/cli/commands/add/workspace.ts +++ b/packages/cli/commands/add/workspace.ts @@ -4,20 +4,18 @@ import * as find from 'empathic/find'; import { common, object, type AstTypes } from '@sveltejs/cli-core/js'; import { parseScript } from '@sveltejs/cli-core/parsers'; import { detect } from 'package-manager-detector'; -import type { OptionValues, PackageManager, Workspace } from '@sveltejs/cli-core'; +import type { PackageManager, Workspace } from '@sveltejs/cli-core'; import { commonFilePaths, getPackageJson, readFile } from './utils.ts'; import { getUserAgent } from '../../utils/package-manager.ts'; type CreateWorkspaceOptions = { cwd: string; - options?: OptionValues; packageManager?: PackageManager; }; export async function createWorkspace({ cwd, - options = {}, packageManager -}: CreateWorkspaceOptions): Promise> { +}: CreateWorkspaceOptions): Promise { const resolvedCwd = path.resolve(cwd); // Will go up and prioritize jsconfig.json as it's first in the array @@ -63,7 +61,6 @@ export async function createWorkspace({ return { cwd: resolvedCwd, - options, packageManager: packageManager ?? (await detect({ cwd }))?.name ?? getUserAgent() ?? 'npm', typescript: usesTypescript, files: { viteConfig, svelteConfig }, diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 87570968..fb8c7462 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -207,6 +207,13 @@ async function createProject(cwd: ProjectPath, options: Options) { let answersCommunity: Record> = {}; let sanitizedAddonsMap: Record = {}; + const workspace = await createVirtualWorkspace({ + cwd: projectPath, + template, + packageManager: 'npm', + type: language + }); + if (options.addOns || options.add.length > 0) { const addons = options.add.reduce(addonArgsHandler, []); sanitizedAddonsMap = sanitizeAddons(addons).reduce>( @@ -217,22 +224,17 @@ async function createProject(cwd: ProjectPath, options: Options) { {} ); - const result = await promptAddonQuestions( - { + const result = await promptAddonQuestions({ + options: { cwd: projectPath, install: false, gitCheck: false, community: [], addons: sanitizedAddonsMap }, - Object.keys(sanitizedAddonsMap), - await createVirtualWorkspace({ - cwd: projectPath, - template, - packageManager: 'npm', - type: language - }) - ); + selectedAddonIds: Object.keys(sanitizedAddonsMap), + workspace + }); selectedAddons = result.selectedAddons; answersOfficial = result.answersOfficial; @@ -277,12 +279,7 @@ async function createProject(cwd: ProjectPath, options: Options) { }, selectedAddons, addonSetupResults: undefined, - virtualWorkspace: await createVirtualWorkspace({ - cwd: projectPath, - template, - packageManager: 'npm', - type: language - }) + workspace }); packageManager = pm; @@ -348,10 +345,9 @@ export async function createVirtualWorkspace({ template, packageManager, type = 'none' -}: CreateVirtualWorkspaceOptions): Promise> { - const workspace: Workspace = { +}: CreateVirtualWorkspaceOptions): Promise { + const workspace: Workspace = { cwd: path.resolve(cwd), - options: {}, packageManager: packageManager ?? (await detect({ cwd }))?.name ?? getUserAgent() ?? 'npm', typescript: type === 'typescript', files: { diff --git a/packages/cli/lib/install.ts b/packages/cli/lib/install.ts index e11c3057..bb7e76f1 100644 --- a/packages/cli/lib/install.ts +++ b/packages/cli/lib/install.ts @@ -43,7 +43,7 @@ export async function installAddon({ export type ApplyAddonOptions = { addons: AddonMap; options: OptionMap; - workspace: Workspace; + workspace: Workspace; addonSetupResults: Record; }; export async function applyAddons({ @@ -64,10 +64,11 @@ export async function applyAddons({ const ordered = orderAddons(mapped, addonSetupResults); for (const addon of ordered) { - workspace = await createWorkspace({ ...workspace, options: options[addon.id] }); + const workspaceOptions = options[addon.id] || {}; const { files, pnpmBuildDependencies, cancels } = await runAddon({ workspace, + workspaceOptions, addon, multiple: ordered.length > 1 }); @@ -90,7 +91,7 @@ export async function applyAddons({ export function setupAddons( addons: Array>, - workspace: Workspace + workspace: Workspace ): Record { const addonSetupResults: Record = {}; @@ -116,18 +117,20 @@ export function setupAddons( } type RunAddon = { - workspace: Workspace; + workspace: Workspace; + workspaceOptions: OptionValues; addon: Addon>; multiple: boolean; }; -async function runAddon({ addon, multiple, workspace }: RunAddon) { +async function runAddon({ addon, multiple, workspace, workspaceOptions }: RunAddon) { const files = new Set(); // apply default addon options + const options: OptionValues = { ...workspaceOptions }; for (const [id, question] of Object.entries(addon.options)) { // we'll only apply defaults to options that don't explicitly fail their conditions - if (question.condition?.(workspace.options) !== false) { - workspace.options[id] ??= question.default; + if (question.condition?.(options) !== false) { + options[id] ??= question.default; } } @@ -193,6 +196,7 @@ async function runAddon({ addon, multiple, workspace }: RunAddon) { cancels.push(reason); }, ...workspace, + options, sv }); diff --git a/packages/core/addon/config.ts b/packages/core/addon/config.ts index 9d846d26..3f4d9bd1 100644 --- a/packages/core/addon/config.ts +++ b/packages/core/addon/config.ts @@ -1,8 +1,8 @@ import type { OptionDefinition, OptionValues, Question } from './options.ts'; -import type { Workspace } from './workspace.ts'; +import type { Workspace, WorkspaceOptions } from './workspace.ts'; export type ConditionDefinition = ( - Workspace: Workspace + Workspace: Workspace ) => boolean; export type PackageDefinition = { @@ -34,19 +34,19 @@ export type Addon = { homepage?: string; options: Args; setup?: ( - workspace: Workspace & { + workspace: Workspace & { dependsOn: (name: string) => void; unsupported: (reason: string) => void; runsAfter: (addonName: string) => void; } ) => MaybePromise; run: ( - workspace: Workspace & { sv: SvApi; cancel: (reason: string) => void } + workspace: Workspace & { options: WorkspaceOptions; sv: SvApi; cancel: (reason: string) => void } ) => MaybePromise; nextSteps?: ( data: { highlighter: Highlighter; - } & Workspace + } & Workspace & { options: WorkspaceOptions } ) => string[]; }; diff --git a/packages/core/addon/processors.ts b/packages/core/addon/processors.ts index 39221009..47e15cf3 100644 --- a/packages/core/addon/processors.ts +++ b/packages/core/addon/processors.ts @@ -2,10 +2,10 @@ import type { ConditionDefinition } from './config.ts'; import type { OptionDefinition } from './options.ts'; import type { Workspace } from './workspace.ts'; -export type FileEditor = Workspace & { content: string }; +export type FileEditor = Workspace & { content: string }; export type FileType = { - name: (options: Workspace) => string; + name: (options: Workspace) => string; condition?: ConditionDefinition; - content: (editor: FileEditor) => string; + content: (editor: FileEditor) => string; }; diff --git a/packages/core/addon/workspace.ts b/packages/core/addon/workspace.ts index 824a34e4..6a4f3ea8 100644 --- a/packages/core/addon/workspace.ts +++ b/packages/core/addon/workspace.ts @@ -1,7 +1,8 @@ import type { OptionDefinition, OptionValues } from './options.ts'; -export type Workspace = { - options: OptionValues; +export type WorkspaceOptions = OptionValues; + +export type Workspace = { cwd: string; /** * Returns the dependency version declared in the package.json. From 7b502a8318f9b78d707461cdc7a5ee910731e4f9 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 20:28:02 +0100 Subject: [PATCH 12/21] lint & check --- packages/core/addon/config.ts | 18 ++++++++++-------- packages/core/addon/processors.ts | 5 ++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/core/addon/config.ts b/packages/core/addon/config.ts index 3f4d9bd1..f46394bc 100644 --- a/packages/core/addon/config.ts +++ b/packages/core/addon/config.ts @@ -1,22 +1,20 @@ import type { OptionDefinition, OptionValues, Question } from './options.ts'; import type { Workspace, WorkspaceOptions } from './workspace.ts'; -export type ConditionDefinition = ( - Workspace: Workspace -) => boolean; +export type ConditionDefinition = (Workspace: Workspace) => boolean; -export type PackageDefinition = { +export type PackageDefinition = { name: string; version: string; dev: boolean; - condition?: ConditionDefinition; + condition?: ConditionDefinition; }; -export type Scripts = { +export type Scripts = { description: string; args: string[]; stdio: 'inherit' | 'pipe'; - condition?: ConditionDefinition; + condition?: ConditionDefinition; }; export type SvApi = { @@ -41,7 +39,11 @@ export type Addon = { } ) => MaybePromise; run: ( - workspace: Workspace & { options: WorkspaceOptions; sv: SvApi; cancel: (reason: string) => void } + workspace: Workspace & { + options: WorkspaceOptions; + sv: SvApi; + cancel: (reason: string) => void; + } ) => MaybePromise; nextSteps?: ( data: { diff --git a/packages/core/addon/processors.ts b/packages/core/addon/processors.ts index 47e15cf3..bde28a7c 100644 --- a/packages/core/addon/processors.ts +++ b/packages/core/addon/processors.ts @@ -1,11 +1,10 @@ import type { ConditionDefinition } from './config.ts'; -import type { OptionDefinition } from './options.ts'; import type { Workspace } from './workspace.ts'; export type FileEditor = Workspace & { content: string }; -export type FileType = { +export type FileType = { name: (options: Workspace) => string; - condition?: ConditionDefinition; + condition?: ConditionDefinition; content: (editor: FileEditor) => string; }; From 7227e16c429045a9510881d7513f4eedce3b0614 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 21:09:07 +0100 Subject: [PATCH 13/21] fixing mcp issue --- packages/addons/mcp/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/addons/mcp/index.ts b/packages/addons/mcp/index.ts index 19472d51..bce500a9 100644 --- a/packages/addons/mcp/index.ts +++ b/packages/addons/mcp/index.ts @@ -101,6 +101,8 @@ export default defineAddon({ for (const ide of options.ide) { const value = configurator[ide]; + + if (value === undefined) continue; if ('other' in value) continue; const { mcpServersKey, filePath, typeLocal, typeRemote, env, schema, command, args } = value; From 15267210388841127bc38f8882b49ecaa04deb8d Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 21:10:05 +0100 Subject: [PATCH 14/21] fix packagemanager issue --- packages/cli/commands/add/index.ts | 13 ++++++------ packages/cli/commands/add/utils.ts | 1 + packages/cli/commands/create.ts | 30 ++++++++++----------------- packages/cli/utils/package-manager.ts | 6 ++++-- packages/core/addon/config.ts | 1 + 5 files changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index 2ea5cd54..eb19532f 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -529,7 +529,7 @@ export async function runAddonsApply({ selectedAddons: SelectedAddon[]; addonSetupResults?: Record; workspace: Workspace; -}): Promise<{ nextSteps: string[]; packageManager?: AgentName | null }> { +}): Promise<{ nextSteps: string[] }> { if (!addonSetupResults) { const setups = selectedAddons.length ? selectedAddons.map(({ addon }) => addon) @@ -538,7 +538,7 @@ export async function runAddonsApply({ } // we'll return early when no addons are selected, // indicating that installing deps was skipped and no PM was selected - if (selectedAddons.length === 0) return { packageManager: null, nextSteps: [] }; + if (selectedAddons.length === 0) return { nextSteps: [] }; // apply addons const officialDetails = Object.keys(answersOfficial).map((id) => getAddonDetails(id)); @@ -567,10 +567,11 @@ export async function runAddonsApply({ if (addonSuccess.length === 0) { p.cancel('All selected add-ons were canceled.'); process.exit(1); - } else if (addonSuccess.length === Object.entries(status).length) { - p.log.success('Successfully setup add-ons'); } else { - p.log.success(`Successfully setup: ${addonSuccess.join(', ')}`); + const highlighter = getHighlighter(); + p.log.success( + `Successfully setup add-ons: ${addonSuccess.map((c) => highlighter.addon(c)).join(', ')}` + ); } // prompt for package manager and install dependencies @@ -620,7 +621,7 @@ export async function runAddonsApply({ }) .filter((msg) => msg !== undefined); - return { nextSteps, packageManager }; + return { nextSteps }; } /** diff --git a/packages/cli/commands/add/utils.ts b/packages/cli/commands/add/utils.ts index 282d1f81..69cd4f55 100644 --- a/packages/cli/commands/add/utils.ts +++ b/packages/cli/commands/add/utils.ts @@ -119,6 +119,7 @@ export const commonFilePaths = { export function getHighlighter(): Highlighter { return { + addon: (str) => pc.green(str), command: (str) => pc.bold(pc.cyanBright(str)), env: (str) => pc.yellow(str), path: (str) => pc.green(str), diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index fb8c7462..3d3aa6f8 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -207,10 +207,17 @@ async function createProject(cwd: ProjectPath, options: Options) { let answersCommunity: Record> = {}; let sanitizedAddonsMap: Record = {}; + const packageManager = + options.install === false + ? null + : options.install === true + ? await packageManagerPrompt(projectPath) + : options.install; + const workspace = await createVirtualWorkspace({ cwd: projectPath, template, - packageManager: 'npm', + packageManager: packageManager ?? 'npm', type: language }); @@ -257,17 +264,9 @@ async function createProject(cwd: ProjectPath, options: Options) { p.log.success('Project created'); - let packageManager: AgentName | undefined | null; let addOnNextSteps: string[] = []; - - const installDeps = async (install: true | AgentName) => { - packageManager = install === true ? await packageManagerPrompt(projectPath) : install; - await addPnpmBuildDependencies(projectPath, packageManager, ['esbuild']); - if (packageManager) await installDependencies(packageManager, projectPath); - }; - if (options.addOns || options.add.length > 0) { - const { nextSteps, packageManager: pm } = await runAddonsApply({ + const { nextSteps } = await runAddonsApply({ answersOfficial, answersCommunity, options: { @@ -282,18 +281,11 @@ async function createProject(cwd: ProjectPath, options: Options) { workspace }); - packageManager = pm; addOnNextSteps = nextSteps; - } else if (options.install) { - // `--no-add-ons` was set, so we'll prompt to install deps manually - await installDeps(options.install); } - // no add-ons were selected (which means the install prompt was skipped in `runAddCommand`), - // so we'll prompt to install - if (packageManager === null && options.install) { - await installDeps(options.install); - } + await addPnpmBuildDependencies(projectPath, packageManager, ['esbuild']); + if (packageManager) await installDependencies(packageManager, projectPath); return { directory: projectPath, addOnNextSteps, packageManager }; } diff --git a/packages/cli/utils/package-manager.ts b/packages/cli/utils/package-manager.ts index a63ccbfe..bde8980f 100644 --- a/packages/cli/utils/package-manager.ts +++ b/packages/cli/utils/package-manager.ts @@ -14,6 +14,7 @@ import { } from 'package-manager-detector'; import { parseJson, parseYaml } from '@sveltejs/cli-core/parsers'; import { isVersionUnsupportedBelow } from '@sveltejs/cli-core'; +import { getHighlighter } from '../commands/add/utils.ts'; export const AGENT_NAMES: AgentName[] = AGENTS.filter( (agent): agent is AgentName => !agent.includes('@') @@ -49,8 +50,9 @@ export async function packageManagerPrompt(cwd: string): Promise { + const highlighter = getHighlighter(); const task = p.taskLog({ - title: `Installing dependencies with ${agent}...`, + title: `Installing dependencies with ${highlighter.command(agent)}...`, limit: Math.ceil(process.stdout.rows / 2), spacing: 0, retainLog: true @@ -69,7 +71,7 @@ export async function installDependencies(agent: AgentName, cwd: string): Promis task.message(line, { raw: true }); } - task.success('Successfully installed dependencies'); + task.success(`Successfully installed dependencies with ${highlighter.command(agent)}`); } catch { task.error('Failed to install dependencies'); p.cancel('Operation failed.'); diff --git a/packages/core/addon/config.ts b/packages/core/addon/config.ts index f46394bc..dbe9d09c 100644 --- a/packages/core/addon/config.ts +++ b/packages/core/addon/config.ts @@ -53,6 +53,7 @@ export type Addon = { }; export type Highlighter = { + addon: (str: string) => string; path: (str: string) => string; command: (str: string) => string; website: (str: string) => string; From 2fbfe0b703a1b3d47504c1515db4533aab75f415 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 21:31:54 +0100 Subject: [PATCH 15/21] linting power --- packages/cli/commands/add/index.ts | 1 - packages/cli/commands/create.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index eb19532f..0f2bda68 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -17,7 +17,6 @@ import type { } from '@sveltejs/cli-core'; import { Command } from 'commander'; import * as pkg from 'empathic/package'; -import { type AgentName } from 'package-manager-detector'; import pc from 'picocolors'; import * as v from 'valibot'; diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 3d3aa6f8..8eb84e5d 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -17,7 +17,7 @@ import { validatePlaygroundUrl } from '@sveltejs/create/playground'; import { Command, Option } from 'commander'; -import { detect, resolveCommand, type AgentName } from 'package-manager-detector'; +import { detect, resolveCommand } from 'package-manager-detector'; import pc from 'picocolors'; import * as v from 'valibot'; From 3614d0a9fc2a850c0937c74d0533c8602f9632f3 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 21:58:04 +0100 Subject: [PATCH 16/21] draft show args --- packages/cli/commands/add/index.ts | 33 +++++++++++++++--------------- packages/cli/commands/create.ts | 23 +++++++++++++++++++-- packages/cli/utils/common.ts | 12 +++++++++++ 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index 0f2bda68..5801f411 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -528,7 +528,7 @@ export async function runAddonsApply({ selectedAddons: SelectedAddon[]; addonSetupResults?: Record; workspace: Workspace; -}): Promise<{ nextSteps: string[] }> { +}): Promise<{ nextSteps: string[]; argsFormattedAddons: string[] }> { if (!addonSetupResults) { const setups = selectedAddons.length ? selectedAddons.map(({ addon }) => addon) @@ -537,7 +537,7 @@ export async function runAddonsApply({ } // we'll return early when no addons are selected, // indicating that installing deps was skipped and no PM was selected - if (selectedAddons.length === 0) return { nextSteps: [] }; + if (selectedAddons.length === 0) return { nextSteps: [], argsFormattedAddons: [] }; // apply addons const officialDetails = Object.keys(answersOfficial).map((id) => getAddonDetails(id)); @@ -573,22 +573,23 @@ export async function runAddonsApply({ ); } - // prompt for package manager and install dependencies - let packageManager: PackageManager | undefined; - if (options.install) { - packageManager = - options.install === true ? await packageManagerPrompt(options.cwd) : options.install; + const packageManager = + options.install === false + ? null + : options.install === true + ? await packageManagerPrompt(options.cwd) + : options.install; - if (packageManager) { - workspace.packageManager = packageManager; + await addPnpmBuildDependencies(workspace.cwd, packageManager, [ + 'esbuild', + ...pnpmBuildDependencies + ]); - await addPnpmBuildDependencies(workspace.cwd, packageManager, [ - 'esbuild', - ...pnpmBuildDependencies - ]); + const argsFormattedAddons = ['coucou']; - await installDependencies(packageManager, options.cwd); - } + if (packageManager) { + workspace.packageManager = packageManager; + await installDependencies(packageManager, options.cwd); } // format modified/created files with prettier (if available) @@ -620,7 +621,7 @@ export async function runAddonsApply({ }) .filter((msg) => msg !== undefined); - return { nextSteps }; + return { nextSteps, argsFormattedAddons }; } /** diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 8eb84e5d..ed0d170c 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -201,6 +201,7 @@ async function createProject(cwd: ProjectPath, options: Options) { ); const projectPath = path.resolve(directory); + const projectName = path.basename(projectPath); let selectedAddons: SelectedAddon[] = []; let answersOfficial: Record> = {}; @@ -249,7 +250,7 @@ async function createProject(cwd: ProjectPath, options: Options) { } createKit(projectPath, { - name: path.basename(projectPath), + name: projectName, template, types: language }); @@ -265,8 +266,9 @@ async function createProject(cwd: ProjectPath, options: Options) { p.log.success('Project created'); let addOnNextSteps: string[] = []; + let argsFormattedAddons: string[] = []; if (options.addOns || options.add.length > 0) { - const { nextSteps } = await runAddonsApply({ + const { nextSteps, argsFormattedAddons: tt } = await runAddonsApply({ answersOfficial, answersCommunity, options: { @@ -280,10 +282,27 @@ async function createProject(cwd: ProjectPath, options: Options) { addonSetupResults: undefined, workspace }); + argsFormattedAddons = tt; addOnNextSteps = nextSteps; } + // Build args for next time based on non-default options + const argsFormatted = [projectName]; + + argsFormatted.push('--template', template); + + if (language === 'typescript') argsFormatted.push('--types', 'ts'); + else if (language === 'checkjs') argsFormatted.push('--types', 'jsdoc'); + else if (language === 'none') argsFormatted.push('--no-types'); + + if (argsFormattedAddons.length > 0) argsFormatted.push('--add', ...argsFormattedAddons); + + if (packageManager === null || packageManager === undefined) argsFormatted.push('--no-install'); + else argsFormatted.push('--install', packageManager); + + common.logArgs(packageManager ?? 'npm', 'create', argsFormatted); + await addPnpmBuildDependencies(projectPath, packageManager, ['esbuild']); if (packageManager) await installDependencies(packageManager, projectPath); diff --git a/packages/cli/utils/common.ts b/packages/cli/utils/common.ts index a071909f..d75c3c72 100644 --- a/packages/cli/utils/common.ts +++ b/packages/cli/utils/common.ts @@ -5,6 +5,7 @@ import type { Argument, HelpConfiguration, Option } from 'commander'; import { UnsupportedError } from './errors.ts'; import process from 'node:process'; import { isVersionUnsupportedBelow } from '@sveltejs/cli-core'; +import type { AgentName } from 'package-manager-detector'; const NO_PREFIX = '--no-'; let options: readonly Option[] = []; @@ -135,3 +136,14 @@ export function parseAddonOptions(optionFlags: string | undefined): string[] | u return options; } + +export function logArgs(agent: AgentName, actionName: string, args: string[]) { + const agentCmd: Record = { + npm: 'npx sv', + pnpm: 'pnpm dlx sv', + bun: 'bunx sv', + deno: 'deno run npm:sv', + yarn: 'yarn dlx sv' + }; + p.log.message(pc.dim([agentCmd[agent], actionName, ...args].join(' '))); +} From ca855e2d54cd3ea242e12695d23f8bfe8978a780 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 22:11:59 +0100 Subject: [PATCH 17/21] add args --- packages/cli/commands/add/index.ts | 45 ++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index 5801f411..f2a47424 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -12,7 +12,6 @@ import type { AddonSetupResult, AddonWithoutExplicitArgs, OptionValues, - PackageManager, Workspace } from '@sveltejs/cli-core'; import { Command } from 'commander'; @@ -585,7 +584,49 @@ export async function runAddonsApply({ ...pnpmBuildDependencies ]); - const argsFormattedAddons = ['coucou']; + const argsFormattedAddons: string[] = []; + for (const { addon, type } of selectedAddons) { + const addonId = addon.id; + const answers = type === 'official' ? answersOfficial[addonId] : answersCommunity[addonId]; + if (!answers) continue; + + const addonDetails = type === 'official' ? getAddonDetails(addonId) : addon; + const optionParts: string[] = []; + + for (const [optionId, value] of Object.entries(answers)) { + if (value === undefined) continue; + + const question = addonDetails.options[optionId]; + if (!question) continue; + + let formattedValue: string; + if (question.type === 'boolean') { + formattedValue = value ? 'yes' : 'no'; + } else if (question.type === 'number') { + formattedValue = String(value); + } else if (question.type === 'multiselect') { + if (Array.isArray(value)) { + if (value.length === 0) { + formattedValue = 'none'; + } else { + formattedValue = value.join(','); + } + } else { + formattedValue = String(value); + } + } else { + formattedValue = String(value); + } + + optionParts.push(`${optionId}:${formattedValue}`); + } + + if (optionParts.length > 0) { + argsFormattedAddons.push(`${addonId}=${optionParts.join('+')}`); + } else { + argsFormattedAddons.push(addonId); + } + } if (packageManager) { workspace.packageManager = packageManager; From 5a01ce23491d4836bc8bbc04d2b570212e26b37b Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 22:45:15 +0100 Subject: [PATCH 18/21] better error escape --- packages/cli/commands/add/index.ts | 56 +++++++++++++++++++++--------- packages/cli/utils/common.ts | 6 ++++ 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index f2a47424..2cb8a119 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -152,16 +152,14 @@ export const add = new Command('add') .action(async (addonArgs: AddonArgs[], opts) => { // validate workspace if (opts.cwd === undefined) { - console.error( + common.errorAndExit( 'Invalid workspace: Please verify that you are inside of a Svelte project. You can also specify the working directory with `--cwd `' ); - process.exit(1); } else if (!fs.existsSync(path.resolve(opts.cwd, 'package.json'))) { // when `--cwd` is specified, we'll validate that it's a valid workspace - console.error( + common.errorAndExit( `Invalid workspace: Path '${path.resolve(opts.cwd)}' is not a valid workspace.` ); - process.exit(1); } const selectedAddonArgs = sanitizeAddons(addonArgs); @@ -240,7 +238,7 @@ export async function promptAddonQuestions({ specifiedOptions.map((option) => option.split(':', 2)) ); for (const option of specifiedOptions) { - let [optionId, optionValue] = option.split(':', 2); + const [optionId, optionValue] = option.split(':', 2); // validates that the option exists const optionEntry = optionEntries.find(([id, question]) => { @@ -250,9 +248,16 @@ export async function promptAddonQuestions({ // group match - need to check conditions and value validity if (question.group === optionId) { // does the value exist for this option? - if (question.type === 'select' || question.type === 'multiselect') { + if (question.type === 'select') { const isValidValue = question.options.some((opt) => opt.value === optionValue); if (!isValidValue) return false; + } else if (question.type === 'multiselect') { + // For multiselect, split by comma and validate each value + const values = optionValue === 'none' ? [] : optionValue.split(','); + const isValidValue = values.every((val) => + question.options.some((opt) => opt.value === val.trim()) + ); + if (!isValidValue) return false; } // if there's a condition, does it pass? @@ -270,15 +275,27 @@ export async function promptAddonQuestions({ if (!optionEntry) { const { choices } = getOptionChoices(details); - throw new Error( - `Invalid '${addonId}' option: '${option}'\nAvailable options: ${choices.join(', ')}` + common.errorAndExit( + `Invalid '${addonId}' add-on option: '${option}'\nAvailable options: ${choices.join(', ')}` ); + throw new Error(); } const [questionId, question] = optionEntry; - // multiselect options can be specified with a `none` option, which equates to an empty string - if (question.type === 'multiselect' && optionValue === 'none') optionValue = ''; + // Validate multiselect values for simple ID matches (already validated for group matches above) + if (question.type === 'multiselect' && questionId === optionId) { + const values = optionValue === 'none' || optionValue === '' ? [] : optionValue.split(','); + const invalidValues = values.filter( + (val) => !question.options.some((opt) => opt.value === val.trim()) + ); + if (invalidValues.length > 0) { + const validValues = question.options.map((opt) => opt.value).join(', '); + common.errorAndExit( + `Invalid '${addonId}' add-on option: '${option}'\nInvalid values: ${invalidValues.join(', ')}\nAvailable values: ${validValues}` + ); + } + } // validate that there are no conflicts let existingOption = answersOfficial[addonId][questionId]; @@ -287,7 +304,7 @@ export async function promptAddonQuestions({ // need to transform the boolean back to `yes` or `no` existingOption = existingOption ? 'yes' : 'no'; } - throw new Error( + common.errorAndExit( `Conflicting '${addonId}' option: '${option}' conflicts with '${questionId}:${existingOption}'` ); } @@ -296,6 +313,14 @@ export async function promptAddonQuestions({ answersOfficial[addonId][questionId] = optionValue === 'yes'; } else if (question.type === 'number') { answersOfficial[addonId][questionId] = Number(optionValue); + } else if (question.type === 'multiselect') { + // multiselect options can be specified with a `none` option, which equates to an empty array + if (optionValue === 'none' || optionValue === '') { + answersOfficial[addonId][questionId] = []; + } else { + // split by comma and trim each value + answersOfficial[addonId][questionId] = optionValue.split(',').map((v) => v.trim()); + } } else { answersOfficial[addonId][questionId] = optionValue; } @@ -676,8 +701,7 @@ export function sanitizeAddons(addonArgs: AddonArgs[]): AddonArgs[] { .filter(({ id }) => !officialAddonIds.includes(id) && !aliases.includes(id)) .map(({ id }) => id); if (invalidAddons.length > 0) { - console.error(`Invalid add-ons specified: ${invalidAddons.join(', ')}`); - process.exit(1); + common.errorAndExit(`Invalid add-ons specified: ${invalidAddons.join(', ')}`); } return transformAliases(addonArgs); } @@ -691,8 +715,7 @@ export function addonArgsHandler(acc: AddonArgs[], current: string): AddonArgs[] // validates that there are no repeated add-ons (e.g. `sv add foo=demo:yes foo=demo:no`) const repeatedAddons = acc.find(({ id }) => id === addonId); if (repeatedAddons) { - console.error(`Malformed arguments: Add-on '${addonId}' is repeated multiple times.`); - process.exit(1); + common.errorAndExit(`Malformed arguments: Add-on '${addonId}' is repeated multiple times.`); } try { @@ -700,8 +723,9 @@ export function addonArgsHandler(acc: AddonArgs[], current: string): AddonArgs[] acc.push({ id: addonId, options }); } catch (error) { if (error instanceof Error) { - console.error(error.message); + common.errorAndExit(error.message); } + console.error(error); process.exit(1); } diff --git a/packages/cli/utils/common.ts b/packages/cli/utils/common.ts index d75c3c72..e8623d70 100644 --- a/packages/cli/utils/common.ts +++ b/packages/cli/utils/common.ts @@ -147,3 +147,9 @@ export function logArgs(agent: AgentName, actionName: string, args: string[]) { }; p.log.message(pc.dim([agentCmd[agent], actionName, ...args].join(' '))); } + +export function errorAndExit(message: string) { + p.log.error(message); + p.cancel('Operation failed.'); + process.exit(1); +} From c78f68cb7a25b86f270b10b8691759ea5c456a85 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 22:51:26 +0100 Subject: [PATCH 19/21] okay delay some questions --- packages/cli/commands/create.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index ed0d170c..cff71071 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -208,17 +208,12 @@ async function createProject(cwd: ProjectPath, options: Options) { let answersCommunity: Record> = {}; let sanitizedAddonsMap: Record = {}; - const packageManager = - options.install === false - ? null - : options.install === true - ? await packageManagerPrompt(projectPath) - : options.install; - const workspace = await createVirtualWorkspace({ cwd: projectPath, template, - packageManager: packageManager ?? 'npm', + // When we create a virtual workspace it's not that important that we use the correct package manager + // so we'll just use npm for now like this we can delay the question after + packageManager: 'npm', type: language }); @@ -287,6 +282,13 @@ async function createProject(cwd: ProjectPath, options: Options) { addOnNextSteps = nextSteps; } + const packageManager = + options.install === false + ? null + : options.install === true + ? await packageManagerPrompt(projectPath) + : options.install; + // Build args for next time based on non-default options const argsFormatted = [projectName]; From e63ec9bf5fb35edc3be5d1b38302238df012f5f2 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 22:51:57 +0100 Subject: [PATCH 20/21] show args changeset --- .changeset/wide-ducks-judge.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wide-ducks-judge.md diff --git a/.changeset/wide-ducks-judge.md b/.changeset/wide-ducks-judge.md new file mode 100644 index 00000000..08e90179 --- /dev/null +++ b/.changeset/wide-ducks-judge.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +feat(cli): show args used so that you can run the cli without any prompt next time From 8a0608b41040ecbd90cf9ebe6697298daad19890 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 8 Nov 2025 22:57:47 +0100 Subject: [PATCH 21/21] also in add --- packages/cli/commands/add/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index 2cb8a119..2a841146 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -178,13 +178,16 @@ export const add = new Command('add') workspace }); - const { nextSteps } = await runAddonsApply({ + const { nextSteps, argsFormattedAddons } = await runAddonsApply({ answersOfficial, answersCommunity, options, selectedAddons, workspace }); + + common.logArgs(workspace.packageManager ?? 'npm', 'add', argsFormattedAddons); + if (nextSteps.length > 0) { p.note(nextSteps.join('\n'), 'Next steps', { format: (line) => line }); }