diff --git a/knip.config.ts b/knip.config.ts index ea670ccbf..ce1cf5364 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -3,8 +3,9 @@ import {type KnipConfig} from 'knip' const project = ['src/**/*.{js,jsx,ts,tsx}', '!**/docs/**'] const baseConfig = { - // For now only care about cli package ignore: [ + 'tmp/**', + 'packages/@sanity/cli-test/fixtures/**', // See `helpClass` in `oclif.config.js` @@ -72,6 +73,8 @@ const baseConfig = { project, }, 'packages/create-sanity': { + // @sanity/cli is imported via relative source paths (../../@sanity/cli/src/...) + // for bundling, but the workspace link is still needed for resolution ignoreDependencies: ['@sanity/cli'], }, }, diff --git a/packages/@sanity/cli-core/package.json b/packages/@sanity/cli-core/package.json index 1154e0dbb..c0bc24a99 100644 --- a/packages/@sanity/cli-core/package.json +++ b/packages/@sanity/cli-core/package.json @@ -25,12 +25,16 @@ ], "type": "module", "sideEffects": false, - "main": "./dist/index.js", - "types": "dist/index.d.ts", + "main": "./dist/_exports/index.js", + "types": "dist/_exports/index.d.ts", "exports": { ".": { - "source": "./src/index.ts", - "default": "./dist/index.js" + "source": "./src/_exports/index.ts", + "default": "./dist/_exports/index.js" + }, + "./errors": { + "source": "./src/_exports/errors.ts", + "default": "./dist/_exports/errors.js" }, "./ux": { "source": "./src/_exports/ux.ts", @@ -68,6 +72,7 @@ "@sanity/client": "catalog:", "babel-plugin-react-compiler": "^1.0.0", "boxen": "^8.0.1", + "clean-stack": "^6.0.0", "configstore": "^7.0.0", "debug": "catalog:", "get-it": "^8.7.0", @@ -82,6 +87,7 @@ "tsx": "catalog:", "vite": "catalog:", "vite-node": "^5.3.0", + "wrap-ansi": "^10.0.0", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/@sanity/cli-core/src/_exports/errors.ts b/packages/@sanity/cli-core/src/_exports/errors.ts new file mode 100644 index 000000000..49494248b --- /dev/null +++ b/packages/@sanity/cli-core/src/_exports/errors.ts @@ -0,0 +1,5 @@ +export * from '../errors/CLIError.js' +export * from '../errors/CLIWarning.js' +export * from '../errors/NonInteractiveError.js' +export * from '../errors/NotFoundError.js' +export * from '../errors/ProjectRootNotFoundError.js' diff --git a/packages/@sanity/cli-core/src/_exports/index.ts b/packages/@sanity/cli-core/src/_exports/index.ts new file mode 100644 index 000000000..4a9fb6f85 --- /dev/null +++ b/packages/@sanity/cli-core/src/_exports/index.ts @@ -0,0 +1,67 @@ +import {deprecate} from 'node:util' + +import {NonInteractiveError as _NonInteractiveError} from '../errors/NonInteractiveError.js' +import {NotFoundError as _NotFoundError} from '../errors/NotFoundError.js' +import {ProjectRootNotFoundError as _ProjectRootNotFoundError} from '../errors/ProjectRootNotFoundError.js' + +export * from '../config/cli/getCliConfig.js' +export * from '../config/cli/getCliConfigSync.js' +export {type CliConfig} from '../config/cli/types/cliConfig.js' +export {type UserViteConfig} from '../config/cli/types/userViteConfig.js' +export * from '../config/findProjectRoot.js' +export * from '../config/findProjectRootSync.js' +export * from '../config/studio/getStudioConfig.js' +export * from '../config/studio/getStudioWorkspaces.js' +export * from '../config/studio/isStudioConfig.js' +export * from '../config/util/findConfigsPaths.js' +export * from '../config/util/findStudioConfigPath.js' +export {type ProjectRootResult} from '../config/util/recursivelyResolveProjectRoot.js' +export * from '../debug.js' +export * from '../loaders/studio/studioWorkerTask.js' +export * from '../loaders/tsx/tsxWorkerTask.js' +export * from '../SanityCommand.js' +export * from '../services/apiClient.js' +export * from '../services/cliUserConfig.js' +export * from '../services/getCliToken.js' +export * from '../telemetry/getTelemetryBaseInfo.js' +export { + type CLITelemetryStore, + type ConsentInformation, + type TelemetryUserProperties, +} from '../telemetry/types.js' +export {type Output, type SanityOrgUser} from '../types.js' +export {doImport} from '../util/doImport.js' +export * from '../util/environment/mockBrowserEnvironment.js' +export { + clearCliTelemetry, + CLI_TELEMETRY_SYMBOL, + getCliTelemetry, + setCliTelemetry, +} from '../util/getCliTelemetry.js' +export * from '../util/getSanityEnvVar.js' +export * from '../util/getSanityUrl.js' +export * from '../util/getUserConfig.js' +export * from '../util/importModule.js' +export * from '../util/isCi.js' +export * from '../util/isInteractive.js' +export * from '../util/isStaging.js' +export * from '../util/normalizePath.js' +export * from '../util/promisifyWorker.js' +export * from '../util/readPackageJson.js' +export * from '../util/resolveLocalPackage.js' +export * from '../util/safeStructuredClone.js' +export * from '../ux/colorizeJson.js' +export * from '../ux/timer.js' + +export const NonInteractiveError = deprecate( + _NonInteractiveError, + 'Import `NonInteractiveError` from `@sanity/cli-core/errors`', +) +export const NotFoundError = deprecate( + _NotFoundError, + 'Import `NotFoundError` from `@sanity/cli-core/errors`', +) +export const ProjectRootNotFoundError = deprecate( + _ProjectRootNotFoundError, + 'Import `ProjectRootNotFoundError` from `@sanity/cli-core/errors`', +) diff --git a/packages/@sanity/cli-core/src/_exports/ux.ts b/packages/@sanity/cli-core/src/_exports/ux.ts index fc826aff7..bee8e415b 100644 --- a/packages/@sanity/cli-core/src/_exports/ux.ts +++ b/packages/@sanity/cli-core/src/_exports/ux.ts @@ -1,5 +1,7 @@ export {NonInteractiveError} from '../errors/NonInteractiveError.js' export * from '../ux/boxen.js' +export * from '../ux/errors.js' export * from '../ux/logSymbols.js' +export * from '../ux/output.js' export * from '../ux/prompts.js' export * from '../ux/spinner.js' diff --git a/packages/@sanity/cli-core/src/errors/CLIError.ts b/packages/@sanity/cli-core/src/errors/CLIError.ts new file mode 100644 index 000000000..20fbb869a --- /dev/null +++ b/packages/@sanity/cli-core/src/errors/CLIError.ts @@ -0,0 +1,46 @@ +import {styleText} from 'node:util' + +import {type PrettyPrintableError} from '@oclif/core/interfaces' +import cleanStack from 'clean-stack' + +/** + * A formatted CLI error that pretty-prints to stderr. + * + * This is a lightweight reimplementation of `@oclif/core`'s `CLIError`. + * We can't import the original because `@oclif/core` is a CJS barrel that + * pulls in the entire oclif runtime (~10MB) when bundled - defeating + * tree-shaking in the standalone `create-sanity` bundle. By owning the + * error class here, code in `@sanity/cli-core` and the init action tree + * can throw formatted errors without depending on oclif at all. + * + * The `oclif` property is shaped so oclif's error handler still recognises + * these errors when thrown inside an oclif command, preserving the correct + * exit code and suppressing redundant stack traces. + */ +export class CLIError extends Error { + code?: string + oclif: {exit?: number} = {exit: 2} + ref?: string + skipOclifErrorHandling?: boolean + suggestions?: string[] + + constructor(error: Error | string, options: PrettyPrintableError & {exit?: false | number} = {}) { + super(error instanceof Error ? error.message : error) + if (error instanceof Error && error.stack) { + this.stack = error.stack + } + if (options.exit !== undefined) + this.oclif.exit = options.exit === false ? undefined : options.exit + this.code = options.code + this.suggestions = options.suggestions + this.ref = options.ref + } + + get bang(): string | undefined { + return styleText('red', process.platform === 'win32' ? '»' : '›') + } + + get prettyStack(): string { + return cleanStack(super.stack!, {pretty: true}) + } +} diff --git a/packages/@sanity/cli-core/src/errors/CLIWarning.ts b/packages/@sanity/cli-core/src/errors/CLIWarning.ts new file mode 100644 index 000000000..388a9398d --- /dev/null +++ b/packages/@sanity/cli-core/src/errors/CLIWarning.ts @@ -0,0 +1,18 @@ +import {styleText} from 'node:util' + +import {CLIError} from './CLIError.js' + +/** + * A warning-level CLI error. Identical to {@link CLIError} except the + * bang prefix is yellow instead of red. + */ +export class CLIWarning extends CLIError { + constructor(input: Error | string) { + super(input instanceof Error ? input.message : input) + this.name = 'Warning' + } + + override get bang(): string | undefined { + return styleText('yellow', process.platform === 'win32' ? '»' : '›') + } +} diff --git a/packages/@sanity/cli-core/src/errors/NonInteractiveError.ts b/packages/@sanity/cli-core/src/errors/NonInteractiveError.ts index cf2e50851..d05e45444 100644 --- a/packages/@sanity/cli-core/src/errors/NonInteractiveError.ts +++ b/packages/@sanity/cli-core/src/errors/NonInteractiveError.ts @@ -1,4 +1,4 @@ -import {CLIError} from '@oclif/core/errors' +import {CLIError} from './CLIError.js' /** * Error thrown when a prompt is attempted in a non-interactive environment diff --git a/packages/@sanity/cli-core/src/errors/ProjectRootNotFoundError.ts b/packages/@sanity/cli-core/src/errors/ProjectRootNotFoundError.ts index 3548e1b0e..7b623783a 100644 --- a/packages/@sanity/cli-core/src/errors/ProjectRootNotFoundError.ts +++ b/packages/@sanity/cli-core/src/errors/ProjectRootNotFoundError.ts @@ -1,6 +1,5 @@ -import {CLIError} from '@oclif/core/errors' - import {isRecord} from '../util/isRecord.js' +import {CLIError} from './CLIError.js' /** * Error thrown when a project root directory cannot be found. diff --git a/packages/@sanity/cli-core/src/index.ts b/packages/@sanity/cli-core/src/index.ts deleted file mode 100644 index 4e2b03da8..000000000 --- a/packages/@sanity/cli-core/src/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -export * from './config/cli/getCliConfig.js' -export * from './config/cli/getCliConfigSync.js' -export {type CliConfig} from './config/cli/types/cliConfig.js' -export {type UserViteConfig} from './config/cli/types/userViteConfig.js' -export * from './config/findProjectRoot.js' -export * from './config/findProjectRootSync.js' -export * from './config/studio/getStudioConfig.js' -export * from './config/studio/getStudioWorkspaces.js' -export * from './config/studio/isStudioConfig.js' -export * from './config/util/findConfigsPaths.js' -export * from './config/util/findStudioConfigPath.js' -export {type ProjectRootResult} from './config/util/recursivelyResolveProjectRoot.js' -export * from './debug.js' -export * from './errors/NonInteractiveError.js' -export * from './errors/NotFoundError.js' -export * from './errors/ProjectRootNotFoundError.js' -export * from './loaders/studio/studioWorkerTask.js' -export * from './loaders/tsx/tsxWorkerTask.js' -export * from './SanityCommand.js' -export * from './services/apiClient.js' -export * from './services/cliUserConfig.js' -export * from './services/getCliToken.js' -export * from './telemetry/getTelemetryBaseInfo.js' -export { - type CLITelemetryStore, - type ConsentInformation, - type TelemetryUserProperties, -} from './telemetry/types.js' -export {type Output, type SanityOrgUser} from './types.js' -export {doImport} from './util/doImport.js' -export * from './util/environment/mockBrowserEnvironment.js' -export { - clearCliTelemetry, - CLI_TELEMETRY_SYMBOL, - getCliTelemetry, - setCliTelemetry, -} from './util/getCliTelemetry.js' -export * from './util/getSanityEnvVar.js' -export * from './util/getSanityUrl.js' -export * from './util/getUserConfig.js' -export * from './util/importModule.js' -export * from './util/isCi.js' -export * from './util/isInteractive.js' -export * from './util/isStaging.js' -export * from './util/normalizePath.js' -export * from './util/promisifyWorker.js' -export * from './util/readPackageJson.js' -export * from './util/resolveLocalPackage.js' -export * from './util/safeStructuredClone.js' -export * from './ux/colorizeJson.js' -export * from './ux/timer.js' diff --git a/packages/@sanity/cli-core/src/services/apiClient.ts b/packages/@sanity/cli-core/src/services/apiClient.ts index a8e631207..e2e2953bd 100644 --- a/packages/@sanity/cli-core/src/services/apiClient.ts +++ b/packages/@sanity/cli-core/src/services/apiClient.ts @@ -1,4 +1,5 @@ -import {ux} from '@oclif/core' +import {styleText} from 'node:util' + import { type ClientConfig, type ClientError, @@ -168,7 +169,7 @@ function authErrors() { const statusCode = isHttpError(err) && err.response.body.statusCode if (statusCode === 401) { - err.message = `${err.message}. You may need to login again with ${ux.colorize('cyan', 'sanity login')}.\nFor more information, see ${generateHelpUrl('cli-errors')}.` + err.message = `${err.message}. You may need to login again with ${styleText('cyan', 'sanity login')}.\nFor more information, see ${generateHelpUrl('cli-errors')}.` } return err diff --git a/packages/@sanity/cli-core/src/util/getCliTelemetry.ts b/packages/@sanity/cli-core/src/util/getCliTelemetry.ts index 587302192..b6552c424 100644 --- a/packages/@sanity/cli-core/src/util/getCliTelemetry.ts +++ b/packages/@sanity/cli-core/src/util/getCliTelemetry.ts @@ -1,7 +1,7 @@ -import {ux} from '@oclif/core' import {noopLogger} from '@sanity/telemetry' import {type CLITelemetryStore} from '../telemetry/types.js' +import {warn} from '../ux/errors.js' /** * @public @@ -33,7 +33,7 @@ export function getCliTelemetry(): CLITelemetryStore { const state = getState() // This should never happen, but if it does, we return a noop logger to avoid errors. if (!state) { - ux.warn('CLI telemetry not initialized, returning noop logger') + warn('CLI telemetry not initialized, returning noop logger') return noopLogger } diff --git a/packages/@sanity/cli-core/src/ux/errors.ts b/packages/@sanity/cli-core/src/ux/errors.ts new file mode 100644 index 000000000..9e7cfb99b --- /dev/null +++ b/packages/@sanity/cli-core/src/ux/errors.ts @@ -0,0 +1,84 @@ +/* eslint-disable no-console -- this is the error output layer */ +import {type PrettyPrintableError} from '@oclif/core/interfaces' +import wrapAnsi from 'wrap-ansi' + +import {CLIError} from '../errors/CLIError.js' +import {CLIWarning} from '../errors/CLIWarning.js' + +const settings: {debug?: boolean} = (globalThis as Record).oclif ?? {} + +/** + * Print a formatted error to stderr without throwing, when `exit: false`. + */ +export function error(input: Error | string, options: PrettyPrintableError & {exit: false}): void +/** + * Throw a formatted {@link CLIError}. + */ +export function error( + input: Error | string, + options?: PrettyPrintableError & {exit?: number}, +): never +export function error( + input: Error | string, + options: PrettyPrintableError & {exit?: false | number} = {}, +): void { + const err = new CLIError(input, options) + + if (options.exit === false) { + const message = prettyPrint(err) + if (message) console.error(message) + } else { + throw err + } +} + +/** + * Print a formatted warning to stderr. + */ +export function warn(input: Error | string): void { + const err = new CLIWarning(input) + const message = prettyPrint(err) + if (message) console.error(message) +} + +function indentString(str: string, count: number, options?: {indent?: string}): string { + const indent = options?.indent ?? ' ' + if (count === 0) return str + return str.replaceAll(/^(?!\s*$)/gm, indent.repeat(count)) +} + +function stderrWidth(): number { + const env = Number.parseInt(process.env.OCLIF_COLUMNS!, 10) + if (env) return env + if (!process.stderr.isTTY) return 80 + const w = (process.stderr as {getWindowSize?: () => number[]}).getWindowSize?.()[0] ?? 80 + return Math.max(w < 1 ? 80 : w, 40) +} + +function formatSuggestions(suggestions?: string[]): string | undefined { + const label = 'Try this:' + if (!suggestions || suggestions.length === 0) return undefined + if (suggestions.length === 1) return `${label} ${suggestions[0]}` + return `${label}\n${indentString(suggestions.map((s) => `* ${s}`).join('\n'), 2)}` +} + +function prettyPrint(error: CLIError): string | undefined { + if (settings.debug) return error.prettyStack + + const {bang: prefix, code, message, name: errorSuffix, ref, suggestions} = error + const formattedHeader = message ? `${errorSuffix || 'Error'}: ${message}` : undefined + const formattedCode = code ? `Code: ${code}` : undefined + const formattedSuggestions = formatSuggestions(suggestions) + const formattedReference = ref ? `Reference: ${ref}` : undefined + + const formatted = [formattedHeader, formattedCode, formattedSuggestions, formattedReference] + .filter(Boolean) + .join('\n') + + const width = stderrWidth() + let output = wrapAnsi(formatted, width - 6, {hard: true, trim: false}) + output = indentString(output, 3) + output = indentString(output, 1, {indent: prefix || ''}) + output = indentString(output, 1) + return output +} diff --git a/packages/@sanity/cli-core/src/ux/output.ts b/packages/@sanity/cli-core/src/ux/output.ts new file mode 100644 index 000000000..26498fbad --- /dev/null +++ b/packages/@sanity/cli-core/src/ux/output.ts @@ -0,0 +1,19 @@ +/* eslint-disable no-console -- these are intentional output helpers */ + +/** + * Write a message to stdout. Equivalent to `console.log`. + * + * Prefer this over `process.stdout.write` since console.log + * handles buffering correctly and won't silently drop output + * if the process exits before the write buffer flushes. + */ +export function stdout(...args: Parameters): void { + console.log(...args) +} + +/** + * Write a message to stderr. Equivalent to `console.error`. + */ +export function stderr(...args: Parameters): void { + console.error(...args) +} diff --git a/packages/@sanity/cli-test/tsconfig.json b/packages/@sanity/cli-test/tsconfig.json index 67a08e75a..bc55ca14e 100644 --- a/packages/@sanity/cli-test/tsconfig.json +++ b/packages/@sanity/cli-test/tsconfig.json @@ -4,7 +4,7 @@ "compilerOptions": { "rootDir": ".", "paths": { - "@sanity/cli-core": ["./node_modules/@sanity/cli-core/src/index.ts"] + "@sanity/cli-core": ["./node_modules/@sanity/cli-core/src/_exports/index.ts"] } } } diff --git a/packages/@sanity/cli/src/SanityHelp.ts b/packages/@sanity/cli/src/SanityHelp.ts index 23c09591d..728c75fd7 100644 --- a/packages/@sanity/cli/src/SanityHelp.ts +++ b/packages/@sanity/cli/src/SanityHelp.ts @@ -1,5 +1,5 @@ import {Command, Help, Interfaces} from '@oclif/core' -import {getBinCommand, getRunningPackageManager} from '@sanity/cli-core/package-manager' +import {getBinCommand} from '@sanity/cli-core/package-manager' // Running `oclif readme`, we don't want to apply the `prefixBinName` transformation, // as it will include whatever pm was used to spawn the script in the generated readme. @@ -19,17 +19,7 @@ const IS_README_GENERATION = (process.argv[process.argv.indexOf('readme') - 1] ? */ export default class SanityHelp extends Help { protected formatCommand(command: Command.Loadable): string { - let help = super.formatCommand(command) - - // When `sanity init` is called, but originates from the `create-sanity` - // package/binary (eg the one used by `npm create sanity@latest` etc), we want to - // customize the help text to show that command instead of `sanity init`. - const isFromCreate = process.argv.includes('--from-create') && command.id === 'init' - if (isFromCreate) { - help = replaceInitWithCreateCommand(help) - } - - return prefixBinName(help) + return prefixBinName(super.formatCommand(command)) } protected formatRoot(): string { @@ -50,38 +40,3 @@ export function prefixBinName(help: string): string { if (binCommand === 'sanity') return help return help.replaceAll('$ sanity', `$ ${binCommand}`) } - -/** - * Replace `sanity init` references in help text with the equivalent `create` command - * for the detected package manager. Lines ending in just `sanity init\n` (no flags) - * are replaced without a flag separator, while lines with flags get the separator - * (eg `--` for npm) so the flags are forwarded correctly. - * - * @internal - */ -export function replaceInitWithCreateCommand(help: string): string { - const createCmd = guessCreateCommand() - const flagSeparator = needsFlagSeparator() ? ' --' : '' - - // First replace all `sanity init` references that ends with a newline with the - // create variant that does not include any flag separator (eg `--`). Then replace - // the other references that do. Most package managers do not require the `--` - // separator, but npm does. Only include it if we need to, as the commands look - // cleaner without it. - return help - .replaceAll(/(\s+)sanity\s+init\s*\n/g, `$1${createCmd}\n`) - .replaceAll(/(\s+)sanity(\s+)init/g, `$1${createCmd}${flagSeparator}`) -} - -function guessCreateCommand() { - const pm = getRunningPackageManager() - if (pm === 'yarn') return `yarn create sanity` - if (pm === 'bun') return `bun create sanity@latest` - if (pm === 'pnpm') return `pnpm create sanity@latest` - return `npm create sanity@latest` -} - -function needsFlagSeparator() { - const pm = getRunningPackageManager() - return pm === 'npm' || !pm -} diff --git a/packages/@sanity/cli/src/__tests__/SanityHelp.test.ts b/packages/@sanity/cli/src/__tests__/SanityHelp.test.ts index 873f73935..f24aabe0b 100644 --- a/packages/@sanity/cli/src/__tests__/SanityHelp.test.ts +++ b/packages/@sanity/cli/src/__tests__/SanityHelp.test.ts @@ -1,6 +1,6 @@ import {afterEach, describe, expect, test, vi} from 'vitest' -import {prefixBinName, replaceInitWithCreateCommand} from '../SanityHelp.js' +import {prefixBinName} from '../SanityHelp.js' describe('prefixBinName', () => { afterEach(() => { @@ -58,231 +58,3 @@ describe('prefixBinName', () => { expect(prefixBinName(input)).toBe(input) }) }) - -// Simulates a realistic oclif help output for the `init` command -function makeInitHelp() { - return [ - 'Initialize a new Sanity project', - '', - 'USAGE', - ' $ sanity init [--bare] [--env ] [--project ] [--dataset ]', - '', - 'FLAGS', - ' --bare Minimal Sanity starter', - ' --dataset= Dataset name', - ' --env= Environment variable file', - ' --project= Project ID', - '', - 'DESCRIPTION', - ' Initialize a new Sanity project', - '', - 'EXAMPLES', - ' $ sanity init', - '', - ' $ sanity init --bare', - '', - ' $ sanity init --project my-project --dataset production', - '', - ].join('\n') -} - -describe('replaceInitWithCreateCommand', () => { - afterEach(() => { - vi.unstubAllEnvs() - }) - - describe('pnpm', () => { - const UA = 'pnpm/8.15.1 npm/? node/v20.10.0 darwin arm64' - - test('replaces USAGE line without flag separator', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).toContain('$ pnpm create sanity@latest [--bare]') - }) - - test('replaces example with flags', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).toContain('$ pnpm create sanity@latest --bare') - expect(result).toContain( - '$ pnpm create sanity@latest --project my-project --dataset production', - ) - }) - - test('replaces bare example (no flags)', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - // The bare "$ sanity init\n" line gets the first regex (no flag separator) - expect(result).toContain('$ pnpm create sanity@latest\n') - }) - - test('removes all "sanity init" references', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).not.toMatch(/sanity\s+init/) - }) - }) - - describe('npm', () => { - const UA = 'npm/10.2.0 node/v20.10.0 darwin arm64' - - test('replaces USAGE line with -- flag separator', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).toContain('$ npm create sanity@latest -- [--bare]') - }) - - test('replaces examples with flags using -- separator', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).toContain('$ npm create sanity@latest -- --bare') - expect(result).toContain( - '$ npm create sanity@latest -- --project my-project --dataset production', - ) - }) - - test('bare example (no flags) does NOT include -- separator', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - // The bare line "$ sanity init\n" is replaced by the first regex which - // doesn't add --. Verify there's no "-- " or "--\n" after the create command - // on this specific line. - const lines = result.split('\n') - const bareLine = lines.find( - (l) => - l.includes('npm create sanity@latest') && - !l.includes('--bare') && - !l.includes('--project'), - ) - expect(bareLine).toBeDefined() - expect(bareLine).not.toContain(' -- ') - expect(bareLine).not.toMatch(/--\s*$/) - }) - - test('removes all "sanity init" references', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).not.toMatch(/sanity\s+init/) - }) - }) - - describe('yarn', () => { - const UA = 'yarn/1.22.19 npm/? node/v20.10.0 darwin arm64' - - test('replaces with yarn create sanity (no @latest, no flag separator)', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).toContain('$ yarn create sanity [--bare]') - expect(result).toContain('$ yarn create sanity --bare') - expect(result).not.toContain('@latest') - }) - - test('removes all "sanity init" references', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).not.toMatch(/sanity\s+init/) - }) - }) - - describe('bun', () => { - const UA = 'bun/1.0.25 npm/? node/v20.10.0 darwin arm64' - - test('replaces with bun create sanity@latest (no flag separator)', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).toContain('$ bun create sanity@latest [--bare]') - expect(result).toContain('$ bun create sanity@latest --bare') - }) - - test('removes all "sanity init" references', () => { - vi.stubEnv('npm_config_user_agent', UA) - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).not.toMatch(/sanity\s+init/) - }) - }) - - describe('unknown package manager', () => { - test('falls back to npm create with -- flag separator', () => { - vi.stubEnv('npm_config_user_agent', '') - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).toContain('$ npm create sanity@latest -- [--bare]') - expect(result).toContain('$ npm create sanity@latest -- --bare') - }) - }) - - describe('does not replace non-init references', () => { - test('preserves description text containing "sanity"', () => { - vi.stubEnv('npm_config_user_agent', 'pnpm/8.15.1 npm/? node/v20.10.0 darwin arm64') - const result = replaceInitWithCreateCommand(makeInitHelp()) - expect(result).toContain('Initialize a new Sanity project') - }) - - test('does not touch "sanity" without "init"', () => { - vi.stubEnv('npm_config_user_agent', 'pnpm/8.15.1 npm/? node/v20.10.0 darwin arm64') - const input = ' $ sanity deploy\n' - const result = replaceInitWithCreateCommand(input) - expect(result).toBe(input) - }) - }) -}) - -describe('replaceInitWithCreateCommand + prefixBinName interaction', () => { - afterEach(() => { - vi.unstubAllEnvs() - }) - - test('pnpm: prefixBinName does not double-replace already-substituted create commands', () => { - vi.stubEnv('npm_config_user_agent', 'pnpm/8.15.1 npm/? node/v20.10.0 darwin arm64') - const afterCreate = replaceInitWithCreateCommand(makeInitHelp()) - const result = prefixBinName(afterCreate) - - // prefixBinName replaces "$ sanity" → "$ pnpm exec sanity", but after - // replaceInitWithCreateCommand there should be no "$ sanity" left - expect(result).not.toContain('$ pnpm exec sanity') - expect(result).toContain('$ pnpm create sanity@latest') - // No mangled combo like "pnpm exec sanity create" or "pnpm exec pnpm create" - expect(result).not.toContain('exec sanity create') - expect(result).not.toContain('exec pnpm') - }) - - test('npm: prefixBinName does not double-replace already-substituted create commands', () => { - vi.stubEnv('npm_config_user_agent', 'npm/10.2.0 node/v20.10.0 darwin arm64') - const afterCreate = replaceInitWithCreateCommand(makeInitHelp()) - const result = prefixBinName(afterCreate) - - expect(result).not.toContain('$ npx sanity') - expect(result).toContain('$ npm create sanity@latest') - expect(result).not.toContain('npx sanity create') - expect(result).not.toContain('npx npm') - }) - - test('bun: prefixBinName does not double-replace already-substituted create commands', () => { - vi.stubEnv('npm_config_user_agent', 'bun/1.0.25 npm/? node/v20.10.0 darwin arm64') - const afterCreate = replaceInitWithCreateCommand(makeInitHelp()) - const result = prefixBinName(afterCreate) - - expect(result).not.toContain('$ bunx sanity') - expect(result).toContain('$ bun create sanity@latest') - expect(result).not.toContain('bunx sanity create') - expect(result).not.toContain('bunx bun') - }) - - test('yarn: prefixBinName does not double-replace already-substituted create commands', () => { - vi.stubEnv('npm_config_user_agent', 'yarn/1.22.19 npm/? node/v20.10.0 darwin arm64') - const afterCreate = replaceInitWithCreateCommand(makeInitHelp()) - const result = prefixBinName(afterCreate) - - expect(result).not.toContain('$ yarn sanity') - expect(result).toContain('$ yarn create sanity') - expect(result).not.toContain('yarn sanity create') - }) - - test('unknown PM: prefixBinName is no-op (binCommand is "sanity")', () => { - vi.stubEnv('npm_config_user_agent', '') - const afterCreate = replaceInitWithCreateCommand(makeInitHelp()) - const result = prefixBinName(afterCreate) - - // With unknown PM, getBinCommand returns "sanity" so prefixBinName is a no-op - expect(result).toBe(afterCreate) - }) -}) diff --git a/packages/@sanity/cli/src/actions/init/__tests__/flagsToInitOptions.test.ts b/packages/@sanity/cli/src/actions/init/__tests__/flagsToInitOptions.test.ts new file mode 100644 index 000000000..61e395b00 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/__tests__/flagsToInitOptions.test.ts @@ -0,0 +1,147 @@ +import {describe, expect, test} from 'vitest' + +import {flagsToInitOptions} from '../flagsToInitOptions.js' + +/** Returns a minimal set of flags with required boolean fields set to defaults. */ +function defaultFlags( + overrides: Record = {}, +): Parameters[0] { + return { + 'auto-updates': true, + bare: false, + 'dataset-default': false, + mcp: true, + 'no-git': false, + ...overrides, + } as Parameters[0] +} + +/** Shorthand that fills in the trailing `args` and `mcpMode` parameters. */ +function toOptions( + flags: Parameters[0], + isUnattended: boolean, +): ReturnType { + return flagsToInitOptions(flags, isUnattended, undefined, 'prompt') +} + +describe('flagsToInitOptions', () => { + test('maps kebab-case flags to camelCase options', () => { + const result = toOptions( + defaultFlags({ + 'auto-updates': false, + dataset: 'staging', + 'dataset-default': true, + 'output-path': '/tmp/myproject', + 'package-manager': 'pnpm', + project: 'proj-123', + 'project-plan': 'enterprise', + template: 'blog', + 'template-token': 'ghp_abc', + visibility: 'private', + }), + false, + ) + + expect(result.autoUpdates).toBe(false) + expect(result.dataset).toBe('staging') + expect(result.datasetDefault).toBe(true) + expect(result.outputPath).toBe('/tmp/myproject') + expect(result.packageManager).toBe('pnpm') + expect(result.project).toBe('proj-123') + expect(result.projectPlan).toBe('enterprise') + expect(result.template).toBe('blog') + expect(result.templateToken).toBe('ghp_abc') + expect(result.visibility).toBe('private') + }) + + test('maps Next.js specific flags', () => { + const result = toOptions( + defaultFlags({ + 'nextjs-add-config-files': true, + 'nextjs-append-env': false, + 'nextjs-embed-studio': true, + }), + false, + ) + + expect(result.nextjsAddConfigFiles).toBe(true) + expect(result.nextjsAppendEnv).toBe(false) + expect(result.nextjsEmbedStudio).toBe(true) + }) + + test('resolves --no-git to git: false', () => { + const result = toOptions(defaultFlags({'no-git': true}), false) + + expect(result.git).toBe(false) + }) + + test('passes through git commit message when --no-git is not set', () => { + const result = toOptions(defaultFlags({git: 'Initial commit from Sanity'}), false) + + expect(result.git).toBe('Initial commit from Sanity') + }) + + test('leaves git as undefined when neither --git nor --no-git is provided', () => { + const result = toOptions(defaultFlags(), false) + + expect(result.git).toBeUndefined() + }) + + test('sets unattended from the isUnattended parameter', () => { + const attended = toOptions(defaultFlags(), false) + expect(attended.unattended).toBe(false) + + const unattended = toOptions(defaultFlags(), true) + expect(unattended.unattended).toBe(true) + }) + + test('aliases --create-project to projectName', () => { + const result = toOptions(defaultFlags({'create-project': 'My Legacy Project'}), false) + + expect(result.projectName).toBe('My Legacy Project') + }) + + test('prefers --project-name over --create-project', () => { + const result = toOptions( + defaultFlags({ + 'create-project': 'Legacy Name', + 'project-name': 'Preferred Name', + }), + false, + ) + + expect(result.projectName).toBe('Preferred Name') + }) + + test('returns undefined for optional fields when not provided', () => { + const result = toOptions(defaultFlags(), false) + + expect(result.coupon).toBeUndefined() + expect(result.dataset).toBeUndefined() + expect(result.env).toBeUndefined() + expect(result.importDataset).toBeUndefined() + expect(result.organization).toBeUndefined() + expect(result.outputPath).toBeUndefined() + expect(result.overwriteFiles).toBeUndefined() + expect(result.packageManager).toBeUndefined() + expect(result.project).toBeUndefined() + expect(result.projectName).toBeUndefined() + expect(result.projectPlan).toBeUndefined() + expect(result.provider).toBeUndefined() + expect(result.template).toBeUndefined() + expect(result.templateToken).toBeUndefined() + expect(result.typescript).toBeUndefined() + expect(result.visibility).toBeUndefined() + }) + + test('passes mcpMode through to options', () => { + const prompt = flagsToInitOptions(defaultFlags(), false, undefined, 'prompt') + expect(prompt.mcpMode).toBe('prompt') + + const auto = flagsToInitOptions(defaultFlags(), false, undefined, 'auto') + expect(auto.mcpMode).toBe('auto') + + const skip = flagsToInitOptions(defaultFlags(), false, undefined, 'skip') + expect(skip.mcpMode).toBe('skip') + }) +}) diff --git a/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts b/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts new file mode 100644 index 000000000..5c1a75569 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts @@ -0,0 +1,213 @@ +import {createTestClient, mockApi} from '@sanity/cli-test' +import nock from 'nock' +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {PROJECT_FEATURES_API_VERSION} from '../../../services/getProjectFeatures.js' +import {ORGANIZATIONS_API_VERSION} from '../../../services/organizations.js' +import {initAction} from '../initAction.js' +import {InitError} from '../initError.js' +import {type InitContext, type InitOptions} from '../types.js' + +// --------------------------------------------------------------------------- +// Hoisted mocks +// --------------------------------------------------------------------------- + +const mockGetById = vi.hoisted(() => vi.fn()) +const mockValidateSession = vi.hoisted(() => vi.fn()) +const mockLogin = vi.hoisted(() => vi.fn()) + +// --------------------------------------------------------------------------- +// Module-level mocks +// --------------------------------------------------------------------------- + +vi.mock('@sanity/cli-core', async (importOriginal) => { + const actual = await importOriginal() + const globalTestClient = createTestClient({ + apiVersion: 'v2025-05-14', + token: 'test-token', + }) + + return { + ...actual, + getGlobalCliClient: vi.fn().mockResolvedValue({ + projects: { + list: vi + .fn() + .mockResolvedValue([ + {createdAt: '2024-01-01T00:00:00Z', displayName: 'Test Project', id: 'test-project'}, + ]), + }, + request: globalTestClient.request, + users: { + getById: mockGetById, + } as never, + }), + getProjectCliClient: vi.fn().mockImplementation(async (options) => { + const client = createTestClient({ + apiVersion: options.apiVersion, + token: 'test-token', + }) + + return { + datasets: { + list: vi.fn().mockResolvedValue([{aclMode: 'public', name: 'production'}]), + }, + request: client.request, + } + }), + } +}) + +vi.mock('../../../util/detectFramework.js', () => ({ + detectFrameworkRecord: vi.fn().mockResolvedValue(null), +})) + +vi.mock('../../auth/ensureAuthenticated.js', () => ({ + validateSession: mockValidateSession, +})) + +vi.mock('../../auth/login/login.js', () => ({ + login: mockLogin, +})) + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +const defaultOptions: InitOptions = { + autoUpdates: true, + bare: false, + datasetDefault: false, + mcpMode: 'skip', + unattended: false, +} + +function createTestContext(): InitContext { + return { + output: { + // output.error has a `never` return type in the Output interface, but + // initAction throws InitError instead of calling it directly. A plain + // vi.fn() satisfies the mock here. + error: vi.fn() as unknown as InitContext['output']['error'], + log: vi.fn(), + warn: vi.fn(), + }, + telemetry: { + trace: vi.fn().mockReturnValue({ + complete: vi.fn(), + error: vi.fn(), + log: vi.fn(), + newContext: vi.fn().mockReturnValue(vi.fn()), + start: vi.fn(), + }), + } as unknown as InitContext['telemetry'], + workDir: '/tmp/test-work-dir', + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('initAction (direct)', () => { + afterEach(() => { + vi.clearAllMocks() + const pending = nock.pendingMocks() + nock.cleanAll() + expect(pending, 'pending mocks').toEqual([]) + }) + + test('throws InitError for deprecated reconfigure flag', async () => { + mockValidateSession.mockResolvedValue({ + email: 'test@example.com', + id: 'user-123', + name: 'Test User', + provider: 'google', + }) + + const context = createTestContext() + const options: InitOptions = { + ...defaultOptions, + reconfigure: true, + } + + let caughtError: unknown + try { + await initAction(options, context) + } catch (error) { + caughtError = error + } + + expect(caughtError).toBeInstanceOf(InitError) + const initError = caughtError as InitError + expect(initError.message).toBe( + '--reconfigure is deprecated - manual configuration is now required', + ) + expect(initError.exitCode).toBe(1) + }) + + test('bare mode outputs project details and returns', async () => { + mockValidateSession.mockResolvedValue({ + email: 'test@example.com', + id: 'user-123', + name: 'Test User', + provider: 'google', + }) + + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + method: 'get', + uri: '/organizations', + }).reply(200, [{id: 'org-1', name: 'Org 1', slug: 'org-1'}]) + + mockApi({ + apiVersion: PROJECT_FEATURES_API_VERSION, + method: 'get', + uri: '/features', + }).reply(200, ['privateDataset']) + + const context = createTestContext() + const options: InitOptions = { + ...defaultOptions, + bare: true, + dataset: 'production', + project: 'test-project', + } + + await initAction(options, context) + + const logCalls = vi.mocked(context.output.log).mock.calls.map((call) => call[0]) + const combined = logCalls.join('\n') + + expect(combined).toContain('Below are your project details') + expect(combined).toContain('test-project') + expect(combined).toContain('production') + }) + + test('throws InitError when not authenticated in unattended mode', async () => { + mockValidateSession.mockResolvedValue(null) + + const context = createTestContext() + const options: InitOptions = { + ...defaultOptions, + dataset: 'production', + outputPath: '/tmp/test-output', + project: 'test-project', + unattended: true, + } + + let caughtError: unknown + try { + await initAction(options, context) + } catch (error) { + caughtError = error + } + + expect(caughtError).toBeInstanceOf(InitError) + const initError = caughtError as InitError + expect(initError.message).toBe( + 'Must be logged in to run this command in unattended mode, run `sanity login`', + ) + expect(initError.exitCode).toBe(1) + }) +}) diff --git a/packages/@sanity/cli/src/actions/init/__tests__/initError.test.ts b/packages/@sanity/cli/src/actions/init/__tests__/initError.test.ts new file mode 100644 index 000000000..04e2ad5b3 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/__tests__/initError.test.ts @@ -0,0 +1,19 @@ +import {describe, expect, test} from 'vitest' + +import {InitError} from '../initError.js' + +describe('InitError', () => { + test('creates error with message and exit code', () => { + const error = new InitError('Something went wrong', 1) + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(InitError) + expect(error.message).toBe('Something went wrong') + expect(error.exitCode).toBe(1) + expect(error.name).toBe('InitError') + }) + + test('defaults exit code to 1', () => { + const error = new InitError('fail') + expect(error.exitCode).toBe(1) + }) +}) diff --git a/packages/@sanity/cli/src/actions/init/bootstrapTemplate.ts b/packages/@sanity/cli/src/actions/init/bootstrapTemplate.ts index 3102bd854..99117f952 100644 --- a/packages/@sanity/cli/src/actions/init/bootstrapTemplate.ts +++ b/packages/@sanity/cli/src/actions/init/bootstrapTemplate.ts @@ -12,13 +12,14 @@ interface BootstrapTemplateOptions { organizationId: string | undefined output: Output outputPath: string - overwriteFiles: boolean packageName: string projectId: string projectName: string remoteTemplateInfo: RepoInfo | undefined templateName: string - useTypeScript: boolean + + overwriteFiles?: boolean + useTypeScript?: boolean } export async function bootstrapTemplate({ @@ -61,7 +62,7 @@ export async function bootstrapTemplate({ overwriteFiles, packageName, templateName, - useTypeScript, + useTypeScript: useTypeScript ?? false, variables: bootstrapVariables, }) } diff --git a/packages/@sanity/cli/src/actions/init/env/createOrAppendEnvVars.ts b/packages/@sanity/cli/src/actions/init/env/createOrAppendEnvVars.ts index ee0f09ed4..db6cc483d 100644 --- a/packages/@sanity/cli/src/actions/init/env/createOrAppendEnvVars.ts +++ b/packages/@sanity/cli/src/actions/init/env/createOrAppendEnvVars.ts @@ -1,8 +1,8 @@ import {styleText} from 'node:util' -import {CLIError} from '@oclif/core/errors' import {Output} from '@sanity/cli-core' +import {InitError} from '../initError.js' import {VersionedFramework} from '../types.js' import {writeEnvVarsToFile} from './writeEnvVarsToFile.js' @@ -42,6 +42,6 @@ export async function createOrAppendEnvVars({ }) } catch (err) { output.error(err) - throw new CLIError('An error occurred while creating .env', {exit: 1}) + throw new InitError('An error occurred while creating .env') } } diff --git a/packages/@sanity/cli/src/actions/init/flags.ts b/packages/@sanity/cli/src/actions/init/flags.ts new file mode 100644 index 000000000..eddca0cb0 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/flags.ts @@ -0,0 +1,239 @@ +/** + * POJO flag and arg definitions for the `sanity init` command. + * + * These are plain objects with ZERO imports from `@oclif/core` so they can be + * used by `create-sanity`'s standalone entry point without pulling in the + * entire oclif dependency tree. + * + * The InitCommand converts them to oclif format via `toOclifFlags`/`toOclifArgs`. + */ + +export interface FlagDef { + // Only 'boolean' and 'string' are supported because these definitions must + // work with both oclif (via toOclifFlags) and node:util parseArgs (in + // create-sanity's standalone entry point). parseArgs only supports these two + // types. If a new type is ever needed, both consumers must be updated. + type: 'boolean' | 'string' + + aliases?: string[] + allowNo?: boolean + default?: boolean | string + deprecated?: boolean | {message?: string; version?: string} + description?: string + exclusive?: string[] + helpGroup?: string + helpLabel?: string + helpValue?: string + hidden?: boolean + options?: string[] + short?: string +} + +export interface ArgDef { + type: 'string' + + description?: string + hidden?: boolean +} + +export const initFlagDefs = { + 'auto-updates': { + allowNo: true, + default: true, + description: 'Enable auto updates of studio versions', + exclusive: ['bare'], + type: 'boolean', + }, + bare: { + default: false, + description: + 'Skip the Studio initialization and only print the selected project ID and dataset name to stdout', + type: 'boolean', + }, + coupon: { + description: + 'Optionally select a coupon for a new project (cannot be used with --project-plan)', + exclusive: ['project-plan'], + helpValue: '', + type: 'string', + }, + 'create-project': { + deprecated: {message: 'Use --project-name instead'}, + description: 'Create a new project with the given name', + helpValue: '', + hidden: true, + type: 'string', + }, + dataset: { + description: 'Dataset name for the studio', + exclusive: ['dataset-default'], + helpValue: '', + type: 'string', + }, + 'dataset-default': { + default: false, + description: 'Set up a project with a public dataset named "production"', + type: 'boolean', + }, + env: { + description: 'Write environment variables to file', + exclusive: ['bare'], + helpValue: '', + type: 'string', + }, + git: { + default: undefined, + description: 'Specify a commit message for initial commit, or disable git init', + exclusive: ['bare'], + // oclif doesn't indent correctly with custom help labels, thus leading space :/ + helpLabel: ' --[no-]git', + helpValue: '', + type: 'string', + }, + 'import-dataset': { + allowNo: true, + default: undefined, + description: 'Import template sample dataset', + type: 'boolean', + }, + mcp: { + allowNo: true, + default: true, + description: 'Enable AI editor integration (MCP) setup', + type: 'boolean', + }, + 'nextjs-add-config-files': { + allowNo: true, + default: undefined, + description: 'Add config files to Next.js project', + helpGroup: 'Next.js', + type: 'boolean', + }, + 'nextjs-append-env': { + allowNo: true, + default: undefined, + description: 'Append project ID and dataset to .env file', + helpGroup: 'Next.js', + type: 'boolean', + }, + 'nextjs-embed-studio': { + allowNo: true, + default: undefined, + description: 'Embed the Studio in Next.js application', + helpGroup: 'Next.js', + type: 'boolean', + }, + // oclif doesn't support a boolean/string flag combination, but listing both a + // `--git` and a `--no-git` flag in help breaks conventions, so we hide this one, + // but use it to "combine" the two in the actual logic. + 'no-git': { + default: false, + description: 'Disable git initialization', + exclusive: ['git'], + hidden: true, + type: 'boolean', + }, + organization: { + description: 'Organization ID to use for the project', + helpValue: '', + type: 'string', + }, + 'output-path': { + description: 'Path to write studio project to', + exclusive: ['bare'], + helpValue: '', + type: 'string', + }, + 'overwrite-files': { + allowNo: true, + default: undefined, + description: 'Overwrite existing files', + type: 'boolean', + }, + 'package-manager': { + description: 'Specify which package manager to use [allowed: npm, yarn, pnpm]', + exclusive: ['bare'], + helpValue: '', + options: ['npm', 'yarn', 'pnpm'], + type: 'string', + }, + project: { + aliases: ['project-id'], + description: 'Project ID to use for the studio', + exclusive: ['create-project', 'project-name'], + helpValue: '', + type: 'string', + }, + 'project-name': { + description: 'Create a new project with the given name', + exclusive: ['project', 'create-project'], + helpValue: '', + type: 'string', + }, + 'project-plan': { + description: 'Optionally select a plan for a new project', + helpValue: '', + type: 'string', + }, + provider: { + description: 'Login provider to use', + helpValue: '', + type: 'string', + }, + quickstart: { + deprecated: true, + description: + 'Used for initializing a project from a server schema that is saved in the Journey API', + hidden: true, + type: 'boolean', + }, + reconfigure: { + deprecated: { + message: 'This flag is no longer supported', + version: '3.0.0', + }, + description: 'Reconfigure an existing project', + hidden: true, + type: 'boolean', + }, + template: { + description: 'Project template to use [default: "clean"]', + exclusive: ['bare'], + helpValue: '