diff --git a/.changeset/smooth-foxes-unite.md b/.changeset/smooth-foxes-unite.md new file mode 100644 index 000000000..47bbe1faa --- /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/.changeset/wide-ducks-judge.md b/.changeset/wide-ducks-judge.md new file mode 100644 index 000000000..08e90179f --- /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 diff --git a/documentation/docs/20-commands/10-sv-create.md b/documentation/docs/20-commands/10-sv-create.md index 29f8aef15..be5d71637 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 diff --git a/packages/addons/mcp/index.ts b/packages/addons/mcp/index.ts index 19472d51c..bce500a97 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; diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index e207df5c2..2a8411469 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -1,23 +1,27 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; -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 as _officialAddons, - getAddonDetails, communityAddonIds, + getAddonDetails, getCommunityAddon } from '@sveltejs/addons'; -import type { AgentName } from 'package-manager-detector'; -import type { AddonWithoutExplicitArgs, OptionValues, PackageManager } from '@sveltejs/cli-core'; +import type { + AddonSetupResult, + AddonWithoutExplicitArgs, + OptionValues, + Workspace +} from '@sveltejs/cli-core'; +import { Command } from 'commander'; +import * as pkg from 'empathic/package'; +import pc from 'picocolors'; +import * as v from 'valibot'; + +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 { verifyCleanWorkingDirectory, verifyUnsupportedAddons } from './verifiers.ts'; import { addPnpmBuildDependencies, AGENT_NAMES, @@ -25,8 +29,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 officialAddons = Object.values(_officialAddons); const aliases = officialAddons.map((c) => c.alias).filter((v) => v !== undefined); @@ -43,35 +48,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') @@ -163,79 +149,99 @@ 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( + 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 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)); + + const workspace = await createWorkspace({ cwd: options.cwd }); 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, + workspace + }); + + 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 }); } }); }); -type SelectedAddon = { type: 'official' | 'community'; addon: AddonWithoutExplicitArgs }; -export async function runAddCommand( - options: Options, - selectedAddonIds: string[] -): Promise<{ nextSteps: string[]; packageManager?: AgentName | null }> { - let selectedAddons: SelectedAddon[] = selectedAddonIds.map((id) => ({ - type: 'official', - addon: getAddonDetails(id) - })); +export type SelectedAddon = { type: 'official' | 'community'; addon: AddonWithoutExplicitArgs }; + +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 + 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); const specifiedOptionsObject = Object.fromEntries( 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]) => { @@ -245,9 +251,16 @@ export async function runAddCommand( // 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? @@ -265,47 +278,69 @@ export async function runAddCommand( 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 = 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` existingOption = existingOption ? 'yes' : 'no'; } - throw new Error( + common.errorAndExit( `Conflicting '${addonId}' option: '${option}' conflicts with '${questionId}:${existingOption}'` ); } 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 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 { - 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]}'` + ); } } } @@ -340,72 +375,27 @@ 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'); + // we'll prepare empty answers for selected community addons + const selectedCommunityAddons: Array = []; + const answersCommunity: Record> = selectedCommunityAddons + .map(({ id }) => id) + .reduce(emptyAnswersReducer, {}); - 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) { - 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))); } + const selectedAddons: SelectedAddon[] = [ + ...selectedOfficialAddons.map((addon) => ({ type: 'official' as const, addon })), + ...selectedCommunityAddons.map((addon) => ({ type: 'community' as const, addon })) + ]; + + // 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); // prompt which addons to apply if (selectedAddons.length === 0) { @@ -437,18 +427,14 @@ export async function runAddCommand( // 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 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); + 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 @@ -465,9 +451,6 @@ export async function runAddCommand( // run all setups after inter-addon deps have been added const addons = selectedAddons.map(({ addon }) => addon); - const addonSetupResults = setupAddons(addons, workspace); - - // run verifications const verifications = [ ...verifyCleanWorkingDirectory(options.cwd, options.gitCheck), ...verifyUnsupportedAddons(addons, addonSetupResults) @@ -501,14 +484,14 @@ export async function runAddCommand( 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)) { @@ -555,13 +538,37 @@ export async function runAddCommand( } } + return { selectedAddons, answersOfficial, answersCommunity }; +} + +export async function runAddonsApply({ + answersOfficial, + answersCommunity, + options, + selectedAddons, + addonSetupResults, + workspace +}: { + answersOfficial: Record>; + answersCommunity: Record>; + options: Options; + selectedAddons: SelectedAddon[]; + addonSetupResults?: Record; + workspace: Workspace; +}): Promise<{ nextSteps: string[]; argsFormattedAddons: string[] }> { + 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: [] }; + if (selectedAddons.length === 0) return { nextSteps: [], argsFormattedAddons: [] }; // 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); @@ -571,7 +578,7 @@ export async function runAddCommand( workspace, addonSetupResults, addons: addonMap, - options: official + options: answersOfficial }); const addonSuccess: string[] = []; @@ -586,32 +593,75 @@ export async function runAddCommand( 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 - 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; + + await addPnpmBuildDependencies(workspace.cwd, packageManager, [ + 'esbuild', + ...pnpmBuildDependencies + ]); + + 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; - if (packageManager) { - workspace.packageManager = packageManager; + 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); + } - await addPnpmBuildDependencies(workspace.cwd, packageManager, [ - 'esbuild', - ...pnpmBuildDependencies - ]); + optionParts.push(`${optionId}:${formattedValue}`); + } - await installDependencies(packageManager, options.cwd); + if (optionParts.length > 0) { + argsFormattedAddons.push(`${addonId}=${optionParts.join('+')}`); + } else { + argsFormattedAddons.push(addonId); } } + if (packageManager) { + workspace.packageManager = packageManager; + await installDependencies(packageManager, options.cwd); + } + // format modified/created files with prettier (if available) - workspace = await createWorkspace(workspace); if (filesToFormat.length > 0 && packageManager && !!workspace.dependencyVersion('prettier')) { const { start, stop } = p.spinner(); start('Formatting modified files'); @@ -630,8 +680,8 @@ export async function runAddCommand( const nextSteps = selectedAddons .map(({ addon }) => { if (!addon.nextSteps) return; - const options = official[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`; @@ -640,7 +690,49 @@ export async function runAddCommand( }) .filter((msg) => msg !== undefined); - return { nextSteps, packageManager }; + return { nextSteps, argsFormattedAddons }; +} + +/** + * 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) { + common.errorAndExit(`Invalid add-ons specified: ${invalidAddons.join(', ')}`); + } + 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) { + common.errorAndExit(`Malformed arguments: Add-on '${addonId}' is repeated multiple times.`); + } + + try { + const options = common.parseAddonOptions(optionFlags); + acc.push({ id: addonId, options }); + } catch (error) { + if (error instanceof Error) { + common.errorAndExit(error.message); + } + console.error(error); + process.exit(1); + } + + return acc; } /** @@ -722,3 +814,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/add/utils.ts b/packages/cli/commands/add/utils.ts index 76f4e1de7..69cd4f557 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); @@ -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/add/workspace.ts b/packages/cli/commands/add/workspace.ts index 385293794..9a705aba5 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; packageManager?: PackageManager; - options?: OptionValues; }; 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 93ba0e43a..cff710715 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -1,10 +1,8 @@ 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, PackageManager, Workspace } from '@sveltejs/cli-core'; import { create as createKit, templates, @@ -12,15 +10,18 @@ import { type TemplateType } from '@sveltejs/create'; import { + detectPlaygroundDependencies, downloadPlaygroundData, parsePlaygroundUrl, setupPlaygroundProject, - validatePlaygroundUrl, - detectPlaygroundDependencies + validatePlaygroundUrl } from '@sveltejs/create/playground'; +import { Command, Option } from 'commander'; +import { detect, resolveCommand } from 'package-manager-detector'; +import pc from 'picocolors'; +import * as v from 'valibot'; + import * as common from '../utils/common.ts'; -import { runAddCommand } from './add/index.ts'; -import { detect, resolveCommand, type AgentName } from 'package-manager-detector'; import { addPnpmBuildDependencies, AGENT_NAMES, @@ -29,6 +30,14 @@ import { installOption, packageManagerPrompt } from '../utils/package-manager.ts'; +import { + addonArgsHandler, + promptAddonQuestions, + runAddonsApply, + sanitizeAddons, + type SelectedAddon +} from './add/index.ts'; +import { commonFilePaths } from './add/utils.ts'; const langs = ['ts', 'jsdoc'] as const; const langMap: Record = { @@ -41,6 +50,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({ @@ -49,6 +60,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)), fromPlayground: v.optional(v.string()) @@ -62,7 +74,8 @@ export const create = new Command('create') .addOption(templateOption) .addOption(langOption) .option('--no-types') - .option('--no-add-ons', 'skips interactive add-on installer') + .addOption(noAddonsOption) + .addOption(addOption) .option('--no-install', 'skip installing dependencies') .option('--from-playground ', 'create a project from the svelte playground') .addOption(installOption) @@ -117,7 +130,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) { if (options.fromPlayground) { @@ -187,8 +201,51 @@ async function createProject(cwd: ProjectPath, options: Options) { ); const projectPath = path.resolve(directory); + const projectName = path.basename(projectPath); + + let selectedAddons: SelectedAddon[] = []; + let answersOfficial: Record> = {}; + let answersCommunity: Record> = {}; + let sanitizedAddonsMap: Record = {}; + + const workspace = await createVirtualWorkspace({ + cwd: projectPath, + template, + // 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 + }); + + 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({ + options: { + cwd: projectPath, + install: false, + gitCheck: false, + community: [], + addons: sanitizedAddonsMap + }, + selectedAddonIds: Object.keys(sanitizedAddonsMap), + workspace + }); + + selectedAddons = result.selectedAddons; + answersOfficial = result.answersOfficial; + answersCommunity = result.answersCommunity; + } + createKit(projectPath, { - name: path.basename(projectPath), + name: projectName, template, types: language }); @@ -203,39 +260,53 @@ 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) { - // `runAddCommand` includes installing dependencies - const { nextSteps, packageManager: pm } = await runAddCommand( - { + let argsFormattedAddons: string[] = []; + if (options.addOns || options.add.length > 0) { + const { nextSteps, argsFormattedAddons: tt } = await runAddonsApply({ + answersOfficial, + answersCommunity, + options: { cwd: projectPath, - install: options.install, + install: false, gitCheck: false, community: [], - addons: {} + addons: sanitizedAddonsMap }, - [] - ); - packageManager = pm; + selectedAddons, + addonSetupResults: undefined, + workspace + }); + argsFormattedAddons = tt; + 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); - } + 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]; + + 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); return { directory: projectPath, addOnNextSteps, packageManager }; } @@ -274,3 +345,39 @@ async function confirmExternalDependencies(dependencies: string[]): Promise { + const workspace: Workspace = { + cwd: path.resolve(cwd), + packageManager: packageManager ?? (await detect({ cwd }))?.name ?? getUserAgent() ?? 'npm', + typescript: type === 'typescript', + files: { + viteConfig: type === 'typescript' ? commonFilePaths.viteConfigTS : commonFilePaths.viteConfig, + svelteConfig: + type === 'typescript' ? commonFilePaths.svelteConfigTS : commonFilePaths.svelteConfig + }, + kit: undefined, + dependencyVersion: () => undefined + }; + + if (template === 'minimal' || template === 'demo' || template === 'library') { + workspace.kit = { + routesDirectory: 'src/routes', + libDirectory: 'src/lib' + }; + } + + return workspace; +} diff --git a/packages/cli/lib/install.ts b/packages/cli/lib/install.ts index e11c30574..bb7e76f14 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/cli/utils/common.ts b/packages/cli/utils/common.ts index a071909fb..e8623d709 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,20 @@ 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(' '))); +} + +export function errorAndExit(message: string) { + p.log.error(message); + p.cancel('Operation failed.'); + process.exit(1); +} diff --git a/packages/cli/utils/package-manager.ts b/packages/cli/utils/package-manager.ts index a63ccbfec..bde8980f4 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 9d846d260..dbe9d09c4 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 } from './workspace.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 = { @@ -34,23 +32,28 @@ 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[]; }; export type Highlighter = { + addon: (str: string) => string; path: (str: string) => string; command: (str: string) => string; website: (str: string) => string; diff --git a/packages/core/addon/processors.ts b/packages/core/addon/processors.ts index 392210095..bde28a7c1 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 FileEditor = Workspace & { content: string }; -export type FileType = { - name: (options: Workspace) => string; - condition?: ConditionDefinition; - content: (editor: FileEditor) => string; +export type FileType = { + name: (options: Workspace) => string; + condition?: ConditionDefinition; + content: (editor: FileEditor) => string; }; diff --git a/packages/core/addon/workspace.ts b/packages/core/addon/workspace.ts index 824a34e45..6a4f3ea83 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.