From 5f4e72188ae9413eb0feaefc59f97e40a9767aef Mon Sep 17 00:00:00 2001 From: David Kizivat Date: Mon, 8 Sep 2025 03:07:48 +0200 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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