From 3b3a947f218e231ce9364810576b3445a26e456c Mon Sep 17 00:00:00 2001 From: Espen Hovlandsdal Date: Mon, 23 Mar 2026 11:18:22 -0700 Subject: [PATCH 01/27] refactor(init): move init logic to action pattern (#748) --- .../init/__tests__/flagsToInitOptions.test.ts | 148 ++ .../actions/init/__tests__/initAction.test.ts | 214 +++ .../actions/init/__tests__/initError.test.ts | 19 + .../cli/src/actions/init/bootstrapTemplate.ts | 7 +- .../cli/src/actions/init/initAction.ts | 1654 +++++++++++++++++ .../@sanity/cli/src/actions/init/initError.ts | 14 + .../@sanity/cli/src/actions/init/types.ts | 159 +- .../__tests__/init/init.bootstrap-app.test.ts | 2 +- .../__tests__/init/init.command.test.ts | 114 ++ .../__tests__/init/init.nextjs.test.ts | 6 +- .../__tests__/init/init.staging-env.test.ts | 2 +- packages/@sanity/cli/src/commands/init.ts | 1552 +--------------- .../cli/src/telemetry/init.telemetry.ts | 2 +- .../test/__fixtures__/exec-get-user-token.ts | 2 +- 14 files changed, 2349 insertions(+), 1546 deletions(-) create mode 100644 packages/@sanity/cli/src/actions/init/__tests__/flagsToInitOptions.test.ts create mode 100644 packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts create mode 100644 packages/@sanity/cli/src/actions/init/__tests__/initError.test.ts create mode 100644 packages/@sanity/cli/src/actions/init/initAction.ts create mode 100644 packages/@sanity/cli/src/actions/init/initError.ts create mode 100644 packages/@sanity/cli/src/commands/__tests__/init/init.command.test.ts 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..4c9783bf0 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/__tests__/flagsToInitOptions.test.ts @@ -0,0 +1,148 @@ +import {describe, expect, test} from 'vitest' + +import {flagsToInitOptions} from '../types.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, + 'from-create': 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..6aba91ac5 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts @@ -0,0 +1,214 @@ +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, + fromCreate: 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/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts new file mode 100644 index 000000000..189b9342d --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -0,0 +1,1654 @@ +import {existsSync} from 'node:fs' +import {mkdir, writeFile} from 'node:fs/promises' +import path from 'node:path' +import {styleText} from 'node:util' + +import { + getCliToken, + type SanityOrgUser, + subdebug, + type TelemetryUserProperties, +} from '@sanity/cli-core' +import {confirm, input, logSymbols, select, Separator, spinner} from '@sanity/cli-core/ux' +import {type DatasetAclMode, isHttpError} from '@sanity/client' +import {type TelemetryTrace} from '@sanity/telemetry' +import {type Framework, frameworks} from '@vercel/frameworks' +import {execa, type Options} from 'execa' +import deburr from 'lodash-es/deburr.js' + +import { + promptForAppendEnv, + promptForConfigFiles, + promptForEmbeddedStudio, + promptForNextTemplate, + promptForStudioPath, +} from '../../prompts/init/nextjs.js' +import {promptForTypeScript} from '../../prompts/init/promptForTypescript.js' +import {promptForDatasetName} from '../../prompts/promptForDatasetName.js' +import {promptForDefaultConfig} from '../../prompts/promptForDefaultConfig.js' +import {promptForOrganizationName} from '../../prompts/promptForOrganizationName.js' +import {createCorsOrigin, listCorsOrigins} from '../../services/cors.js' +import {createDataset as createDatasetService, listDatasets} from '../../services/datasets.js' +import {getProjectFeatures} from '../../services/getProjectFeatures.js' +import { + createOrganization, + listOrganizations, + type OrganizationCreateResponse, + type ProjectOrganization, +} from '../../services/organizations.js' +import {getPlanId, getPlanIdFromCoupon} from '../../services/plans.js' +import {createProject, listProjects, updateProjectInitializedAt} from '../../services/projects.js' +import {getCliUser} from '../../services/user.js' +import {CLIInitStepCompleted, type InitStepResult} from '../../telemetry/init.telemetry.js' +import {detectFrameworkRecord} from '../../util/detectFramework.js' +import {absolutify, validateEmptyPath} from '../../util/fsUtils.js' +import {getProjectDefaults} from '../../util/getProjectDefaults.js' +import {getSanityEnv} from '../../util/getSanityEnv.js' +import {getPeerDependencies} from '../../util/packageManager/getPeerDependencies.js' +import { + installDeclaredPackages, + installNewPackages, +} from '../../util/packageManager/installPackages.js' +import { + getPartialEnvWithNpmPath, + type PackageManager, +} from '../../util/packageManager/packageManagerChoice.js' +import {validateSession} from '../auth/ensureAuthenticated.js' +import {getProviderName} from '../auth/getProviderName.js' +import {login} from '../auth/login/login.js' +import {createDataset} from '../dataset/create.js' +import {type EditorName} from '../mcp/editorConfigs.js' +import {setupMCP} from '../mcp/setupMCP.js' +import {findOrganizationByUserName} from '../organizations/findOrganizationByUserName.js' +import {getOrganizationChoices} from '../organizations/getOrganizationChoices.js' +import {getOrganizationsWithAttachGrantInfo} from '../organizations/getOrganizationsWithAttachGrantInfo.js' +import {hasProjectAttachGrant} from '../organizations/hasProjectAttachGrant.js' +import {type OrganizationChoices} from '../organizations/types.js' +import {bootstrapTemplate} from './bootstrapTemplate.js' +import {checkNextJsReactCompatibility} from './checkNextJsReactCompatibility.js' +import {countNestedFolders} from './countNestedFolders.js' +import {determineAppTemplate} from './determineAppTemplate.js' +import {createOrAppendEnvVars} from './env/createOrAppendEnvVars.js' +import {fetchPostInitPrompt} from './fetchPostInitPrompt.js' +import {tryGitInit} from './git.js' +import {InitError} from './initError.js' +import {checkIsRemoteTemplate, getGitHubRepoInfo, type RepoInfo} from './remoteTemplate.js' +import {resolvePackageManager} from './resolvePackageManager.js' +import templates from './templates/index.js' +import { + sanityCliTemplate, + sanityConfigTemplate, + sanityFolder, + sanityStudioTemplate, +} from './templates/nextjs/index.js' +import {type InitContext, type InitOptions, type VersionedFramework} from './types.js' + +const debug = subdebug('init') + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function shouldPrompt(unattended: boolean, flagValue: unknown): boolean { + return !unattended && flagValue === undefined +} + +function flagOrDefault(flagValue: boolean | undefined, defaultValue: boolean): boolean { + return typeof flagValue === 'boolean' ? flagValue : defaultValue +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +/** + * Core init logic, free of oclif and `\@sanity/cli-core` command abstractions. + * Takes plain options and a minimal context object so the logic can be reused + * outside of oclif and is easier to test. + */ +export async function initAction(options: InitOptions, context: InitContext): Promise { + const {output, workDir} = context + + // For backwards "compatibility" - we used to allow `sanity init plugin`, + // and no longer do - but instead of printing an error about an unknown + // _command_, we want to acknowledge that the user is trying to do something + // that no longer exists but might have at some point in the past. + if (options.argType) { + throw new InitError( + options.argType === 'plugin' + ? 'Initializing plugins through the CLI is no longer supported' + : `Unknown init type "${options.argType}"`, + 1, + ) + } + + const trace = context.telemetry.trace(CLIInitStepCompleted) + + // Slightly more helpful message for removed flags rather than just saying the flag + // does not exist. + if (options.reconfigure) { + throw new InitError('--reconfigure is deprecated - manual configuration is now required', 1) + } + + // Oclif doesn't support custom exclusive error messaging + if (options.project && options.organization) { + throw new InitError( + 'You have specified both a project and an organization. To move a project to an organization please visit https://www.sanity.io/manage', + 1, + ) + } + + const defaultConfig = options.datasetDefault + let showDefaultConfigPrompt = !defaultConfig + if (options.dataset || options.visibility || options.datasetDefault || options.unattended) { + showDefaultConfigPrompt = false + } + + const detectedFramework = await detectFrameworkRecord({ + frameworkList: frameworks as readonly Framework[], + rootPath: workDir, + }) + const isNextJs = detectedFramework?.slug === 'nextjs' + + let remoteTemplateInfo: RepoInfo | undefined + if (options.template && checkIsRemoteTemplate(options.template)) { + remoteTemplateInfo = await getGitHubRepoInfo(options.template, options.templateToken) + } + + if (detectedFramework && detectedFramework.slug !== 'sanity' && remoteTemplateInfo) { + throw new InitError( + `A remote template cannot be used with a detected framework. Detected: ${detectedFramework.name}`, + 1, + ) + } + + const isAppTemplate = options.template ? determineAppTemplate(options.template) : false + + // Checks flags are present when in unattended mode + if (options.unattended) { + checkFlagsInUnattendedMode(options, {isAppTemplate, isNextJs}) + } + + trace.start() + trace.log({ + flags: { + bare: options.bare, + coupon: options.coupon, + defaultConfig, + env: options.env, + git: typeof options.git === 'string' ? options.git : undefined, + plan: options.projectPlan, + reconfigure: options.reconfigure, + unattended: options.unattended, + }, + step: 'start', + }) + + // Plan can be set through `--project-plan`, or implied through `--coupon`. + // As coupons can expire and project plans might change/be removed, we need to + // verify that the passed flags are valid. The complexity of this is hidden in the + // below plan methods, eventually returning a plan ID or undefined if we are told to + // use the default plan. + const planId = await getPlan(options, output, trace) + + let envFilenameDefault = '.env' + if (isNextJs) { + envFilenameDefault = '.env.local' + } + const envFilename = typeof options.env === 'string' ? options.env : envFilenameDefault + + // If the user isn't already authenticated, make it so + const {user} = await ensureAuthenticated(options, output, trace) + if (!isAppTemplate) { + output.log(`${logSymbols.success} Fetching existing projects`) + output.log('') + } + + let newProject: string | undefined + if (options.projectName) { + newProject = await createProjectFromName({ + coupon: options.coupon, + createProjectName: options.projectName, + dataset: options.dataset, + organization: options.organization, + planId, + user, + visibility: options.visibility, + }) + } + + const {datasetName, displayName, isFirstProject, organizationId, projectId} = + await getProjectDetails({ + coupon: options.coupon, + dataset: options.dataset, + datasetDefault: options.datasetDefault, + isAppTemplate, + newProject, + organization: options.organization, + output, + planId, + project: options.project, + showDefaultConfigPrompt, + trace, + unattended: options.unattended, + user, + visibility: options.visibility, + }) + + // If user doesn't want to output any template code + if (options.bare) { + output.log(`${logSymbols.success} Below are your project details`) + output.log('') + output.log(`Project ID: ${styleText('cyan', projectId)}`) + output.log(`Dataset: ${styleText('cyan', datasetName)}`) + output.log( + `\nYou can find your project on Sanity Manage — https://www.sanity.io/manage/project/${projectId}\n`, + ) + trace.complete() + return + } + + let initNext = flagOrDefault(options.nextjsAddConfigFiles, false) + if (isNextJs && shouldPrompt(options.unattended, options.nextjsAddConfigFiles)) { + initNext = await promptForConfigFiles() + } + + trace.log({ + detectedFramework: detectedFramework?.name, + selectedOption: initNext ? 'yes' : 'no', + step: 'useDetectedFramework', + }) + + const sluggedName = deburr(displayName.toLowerCase()) + .replaceAll(/\s+/g, '-') + .replaceAll(/[^a-z0-9-]/g, '') + + // add more frameworks to this as we add support for them + // this is used to skip the getProjectInfo prompt + const initFramework = initNext + + // Gather project defaults based on environment + const defaults = await getProjectDefaults({isPlugin: false, workDir}) + + // Prompt the user for required information + const outputPath = await getProjectOutputPath({ + initFramework, + outputPath: options.outputPath, + sluggedName, + unattended: options.unattended, + useEnv: Boolean(options.env), + workDir, + }) + + // Set up MCP integration + const mcpResult = await setupMCP({mode: options.mcpMode}) + + trace.log({ + configuredEditors: mcpResult.configuredEditors, + detectedEditors: mcpResult.detectedEditors, + skipped: mcpResult.skipped, + step: 'mcpSetup', + }) + if (mcpResult.error) { + trace.error(mcpResult.error) + } + const mcpConfigured = mcpResult.configuredEditors + + // Show checkmark for editors that were already configured + const {alreadyConfiguredEditors} = mcpResult + if (alreadyConfiguredEditors.length > 0) { + const label = + alreadyConfiguredEditors.length === 1 + ? `${alreadyConfiguredEditors[0]} already configured for Sanity MCP` + : `${alreadyConfiguredEditors.length} editors already configured for Sanity MCP` + spinner(label).start().succeed() + } + + if (isNextJs) { + await checkNextJsReactCompatibility({ + detectedFramework, + output, + outputPath, + }) + } + + if (initNext) { + await doInitNextJs({ + datasetName, + detectedFramework, + envFilename, + mcpConfigured, + options, + output, + projectId, + trace, + workDir, + }) + trace.complete() + return + } + + // user wants to write environment variables to file + if (options.env) { + await createOrAppendEnvVars({ + envVars: { + DATASET: datasetName, + PROJECT_ID: projectId, + }, + filename: envFilename, + framework: detectedFramework, + log: false, + output, + outputPath, + }) + await writeStagingEnvIfNeeded(output, outputPath) + trace.complete() + return + } + + // Prompt for template to use + const templateName = await promptForTemplate(options) + trace.log({ + selectedOption: templateName, + step: 'selectProjectTemplate', + }) + const template = templates[templateName] + if (!remoteTemplateInfo && !template) { + throw new InitError(`Template "${templateName}" not found`, 1) + } + + let useTypeScript = options.typescript + if (!remoteTemplateInfo && template && template.typescriptOnly === true) { + useTypeScript = true + } else if (shouldPrompt(options.unattended, options.typescript)) { + useTypeScript = await promptForTypeScript() + trace.log({ + selectedOption: useTypeScript ? 'yes' : 'no', + step: 'useTypeScript', + }) + } + + // If the template has a sample dataset, prompt the user whether or not we should import it + const importDatasetFlag = options.importDataset + const shouldImport = + template?.datasetUrl && + (importDatasetFlag ?? + (!options.unattended && (await promptForDatasetImport(template.importPrompt)))) + + trace.log({ + selectedOption: shouldImport ? 'yes' : 'no', + step: 'importTemplateDataset', + }) + + try { + await updateProjectInitializedAt(projectId) + } catch (err) { + // Non-critical update + debug('Failed to update cliInitializedAt metadata', err) + } + + await bootstrapTemplate({ + autoUpdates: options.autoUpdates, + bearerToken: options.templateToken, + dataset: datasetName, + organizationId, + output, + outputPath, + overwriteFiles: options.overwriteFiles, + packageName: sluggedName, + projectId, + projectName: displayName || defaults.projectName, + remoteTemplateInfo, + templateName, + useTypeScript, + }) + + const pkgManager = await resolvePackageManager({ + interactive: !options.unattended, + output, + packageManager: options.packageManager as PackageManager, + targetDir: outputPath, + }) + + trace.log({ + selectedOption: pkgManager, + step: 'selectPackageManager', + }) + + // Now for the slow part... installing dependencies + await installDeclaredPackages(outputPath, pkgManager, { + output, + workDir, + }) + + const useGit = options.git === undefined || Boolean(options.git) + const commitMessage = options.git + await writeStagingEnvIfNeeded(output, outputPath) + + // Try initializing a git repository + if (useGit) { + tryGitInit(outputPath, typeof commitMessage === 'string' ? commitMessage : undefined) + } + + // Prompt for dataset import (if a dataset is defined) + if (shouldImport && template?.datasetUrl) { + const token = await getCliToken() + if (!token) { + throw new InitError('Authentication required to import dataset', 1) + } + // Dynamic import to keep initAction decoupled from oclif commands. + // TODO: consider replacing with `npx sanity dataset import` to fully decouple. + // eslint-disable-next-line no-restricted-syntax + const {ImportDatasetCommand} = await import('../../commands/datasets/import.js') + await ImportDatasetCommand.run( + [template.datasetUrl, '--project-id', projectId, '--dataset', datasetName, '--token', token], + { + root: outputPath, + }, + ) + + output.log('') + output.log('If you want to delete the imported data, use') + output.log(` ${styleText('cyan', `npx sanity dataset delete ${datasetName}`)}`) + output.log('and create a new clean dataset with') + output.log(` ${styleText('cyan', `npx sanity dataset create `)}\n`) + } + + const devCommandMap: Record = { + bun: 'bun dev', + manual: 'npm run dev', + npm: 'npm run dev', + pnpm: 'pnpm dev', + yarn: 'yarn dev', + } + const devCommand = devCommandMap[pkgManager] + + const isCurrentDir = outputPath === workDir + const goToProjectDir = `\n(${styleText('cyan', `cd ${outputPath}`)} to navigate to your new project directory)` + + if (isAppTemplate) { + //output for custom apps here + output.log( + `${logSymbols.success} ${styleText(['green', 'bold'], 'Success!')} Your custom app has been scaffolded.`, + ) + if (!isCurrentDir) output.log(goToProjectDir) + output.log( + `\n${styleText('bold', 'Next')}, configure the project(s) and dataset(s) your app should work with.`, + ) + output.log('\nGet started in `src/App.tsx`, or refer to our documentation for a walkthrough:') + output.log( + styleText(['blue', 'underline'], 'https://www.sanity.io/docs/app-sdk/sdk-configuration'), + ) + if (mcpConfigured && mcpConfigured.length > 0) { + const message = await getPostInitMCPPrompt(mcpConfigured) + output.log(`\n${message}`) + output.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) + output.log( + `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, + ) + } + output.log('\n') + output.log(`Other helpful commands:`) + output.log(`npx sanity docs browse to open the documentation in a browser`) + output.log(`npx sanity dev to start the development server for your app`) + output.log(`npx sanity deploy to deploy your app`) + } else { + //output for Studios here + output.log(`\u2705 ${styleText(['green', 'bold'], 'Success!')} Your Studio has been created.`) + if (!isCurrentDir) output.log(goToProjectDir) + output.log( + `\nGet started by running ${styleText('cyan', devCommand)} to launch your Studio's development server`, + ) + if (mcpConfigured && mcpConfigured.length > 0) { + const message = await getPostInitMCPPrompt(mcpConfigured) + output.log(`\n${message}`) + output.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) + output.log( + `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, + ) + } + output.log('\n') + output.log(`Other helpful commands:`) + output.log(`npx sanity docs browse to open the documentation in a browser`) + output.log(`npx sanity manage to open the project settings in a browser`) + output.log(`npx sanity help to explore the CLI manual`) + } + + if (isFirstProject) { + trace.log({selectedOption: 'yes', step: 'sendCommunityInvite'}) + + const DISCORD_INVITE_LINK = 'https://www.sanity.io/community/join' + + output.log(`\nJoin the Sanity community: ${styleText('cyan', DISCORD_INVITE_LINK)}`) + output.log('We look forward to seeing you there!\n') + } + + trace.complete() +} + +// --------------------------------------------------------------------------- +// Extracted private methods (now module-level functions) +// --------------------------------------------------------------------------- + +function checkFlagsInUnattendedMode( + options: InitOptions, + {isAppTemplate, isNextJs}: {isAppTemplate: boolean; isNextJs: boolean}, +): void { + debug('Unattended mode, validating required options') + + // App templates only require --organization and --output-path + if (isAppTemplate) { + if (!options.outputPath) { + throw new InitError('`--output-path` must be specified in unattended mode', 1) + } + + if (!options.organization) { + throw new InitError( + 'The --organization flag is required for app templates in unattended mode. ' + + 'Use --organization to specify which organization to use.', + 1, + ) + } + + return + } + + if (!options.dataset) { + throw new InitError('`--dataset` must be specified in unattended mode', 1) + } + + // output-path is required in unattended mode when not using nextjs + if (!isNextJs && !options.outputPath) { + throw new InitError('`--output-path` must be specified in unattended mode', 1) + } + + if (!options.project && !options.projectName) { + throw new InitError( + '`--project ` or `--project-name ` must be specified in unattended mode', + 1, + ) + } + + if (options.projectName && !options.organization) { + throw new InitError('`--project-name` requires `--organization ` in unattended mode', 1) + } +} + +async function createProjectFromName({ + coupon, + createProjectName, + dataset, + organization, + planId, + user, + visibility, +}: { + coupon: string | undefined + createProjectName: string + dataset: string | undefined + organization: string | undefined + planId: string | undefined + user: SanityOrgUser + visibility: 'private' | 'public' | undefined +}): Promise { + debug('--project-name specified, creating a new project') + + let orgForCreateProjectFlag = organization + + if (!orgForCreateProjectFlag) { + debug('no organization specified, selecting one') + const organizations = await listOrganizations() + orgForCreateProjectFlag = await promptUserForOrganization({ + organizations, + user, + }) + } + + debug('creating a new project') + const createdProject = await createProject({ + displayName: createProjectName.trim(), + metadata: {coupon}, + organizationId: orgForCreateProjectFlag, + subscription: planId ? {planId} : undefined, + }) + + debug('Project with ID %s created', createdProject.projectId) + if (dataset) { + debug('--dataset specified, creating dataset (%s)', dataset) + const spin = spinner('Creating dataset').start() + await createDatasetService({ + aclMode: visibility as DatasetAclMode | undefined, + datasetName: dataset, + projectId: createdProject.projectId, + }) + spin.succeed() + } + + return createdProject.projectId +} + +async function ensureAuthenticated( + options: InitOptions, + output: InitContext['output'], + trace: TelemetryTrace, +): Promise<{user: SanityOrgUser}> { + const user = await validateSession() + + if (user) { + trace.log({alreadyLoggedIn: true, step: 'login'}) + output.log( + `${logSymbols.success} You are logged in as ${user.email} using ${getProviderName(user.provider)}`, + ) + return {user} + } + + if (options.unattended) { + throw new InitError( + 'Must be logged in to run this command in unattended mode, run `sanity login`', + 1, + ) + } + + trace.log({step: 'login'}) + + try { + await login({ + output, + telemetry: trace.newContext('login'), + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new InitError(`Login failed: ${message}`, 1) + } + + let loggedInUser + try { + loggedInUser = await getCliUser() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new InitError(`Failed to retrieve user after login: ${message}`, 1) + } + + output.log( + `${logSymbols.success} You are logged in as ${loggedInUser.email} using ${getProviderName(loggedInUser.provider)}`, + ) + return {user: loggedInUser} +} + +async function getOrCreateDataset(opts: { + dataset?: string + defaultConfig: boolean | undefined + displayName: string + output: InitContext['output'] + projectId: string + showDefaultConfigPrompt: boolean + unattended: boolean + visibility?: string +}): Promise<{ + datasetName: string + userAction: 'create' | 'none' | 'select' +}> { + const {dataset, visibility} = opts + let {defaultConfig} = opts + + if (dataset && opts.unattended) { + return {datasetName: dataset, userAction: 'none'} + } + + const [datasets, projectFeatures] = await Promise.all([ + listDatasets(opts.projectId), + getProjectFeatures(opts.projectId), + ]) + + if (dataset) { + debug('User has specified dataset through a flag (%s)', dataset) + const existing = datasets.find((ds) => ds.name === dataset) + if (!existing) { + debug('Specified dataset not found, creating it') + await createDataset({ + datasetName: dataset, + forcePublic: defaultConfig, + output: opts.output, + projectFeatures, + projectId: opts.projectId, + visibility, + }) + } + + return {datasetName: dataset, userAction: 'none'} + } + + if (datasets.length === 0) { + debug('No datasets found for project, prompting for name') + if (opts.showDefaultConfigPrompt) { + defaultConfig = await promptForDefaultConfig() + } + const name = defaultConfig + ? 'production' + : await promptForDatasetName({ + message: 'Name of your first dataset:', + }) + await createDataset({ + datasetName: name, + forcePublic: defaultConfig, + output: opts.output, + projectFeatures, + projectId: opts.projectId, + visibility, + }) + return {datasetName: name, userAction: 'create'} + } + + debug(`User has ${datasets.length} dataset(s) already, showing list of choices`) + const datasetChoices = datasets.map((ds) => ({value: ds.name})) + + const selected = await select({ + choices: [{name: 'Create new dataset', value: 'new'}, new Separator(), ...datasetChoices], + message: 'Select dataset to use', + }) + + if (selected === 'new') { + const existingDatasetNames = datasets.map((ds) => ds.name) + debug('User wants to create a new dataset, prompting for name') + if (opts.showDefaultConfigPrompt && !existingDatasetNames.includes('production')) { + defaultConfig = await promptForDefaultConfig() + } + + const newDatasetName = defaultConfig + ? 'production' + : await promptForDatasetName( + { + message: 'Dataset name:', + }, + existingDatasetNames, + ) + await createDataset({ + datasetName: newDatasetName, + forcePublic: defaultConfig, + output: opts.output, + projectFeatures, + projectId: opts.projectId, + visibility, + }) + return {datasetName: newDatasetName, userAction: 'create'} + } + + debug(`Returning selected dataset (${selected})`) + return {datasetName: selected, userAction: 'select'} +} + +async function getOrCreateProject({ + coupon, + newProject, + organization, + planId, + project, + unattended, + user, +}: { + coupon?: string + newProject: string | undefined + organization: string | undefined + planId: string | undefined + project: string | undefined + unattended: boolean + user: SanityOrgUser +}): Promise<{ + displayName: string + isFirstProject: boolean + projectId: string + userAction: 'create' | 'select' +}> { + const projectId = project || newProject + let projects + let organizations: ProjectOrganization[] + + try { + const [allProjects, allOrgs] = await Promise.all([listProjects(), listOrganizations()]) + projects = allProjects.toSorted((a, b) => b.createdAt.localeCompare(a.createdAt)) + organizations = allOrgs + } catch (err: unknown) { + if (unattended && projectId) { + return { + displayName: 'Unknown project', + isFirstProject: false, + projectId, + userAction: 'select', + } + } + const message = err instanceof Error ? err.message : String(err) + throw new InitError(`Failed to communicate with the Sanity API:\n${message}`, 1) + } + + if (projects.length === 0 && unattended) { + throw new InitError('No projects found for current user', 1) + } + + if (projectId) { + const proj = projects.find((p) => p.id === projectId) + if (!proj && !unattended) { + throw new InitError( + `Given project ID (${projectId}) not found, or you do not have access to it`, + 1, + ) + } + + return { + displayName: proj ? proj.displayName : 'Unknown project', + isFirstProject: false, + projectId, + userAction: 'select', + } + } + + if (organization) { + const org = + organizations.find((o) => o.id === organization) || + organizations.find((o) => o.slug === organization) + + if (!org) { + throw new InitError( + `Given organization ID (${organization}) not found, or you do not have access to it`, + 1, + ) + } + + if (!(await hasProjectAttachGrant(organization))) { + throw new InitError( + 'You lack the necessary permissions to attach a project to this organization', + 1, + ) + } + } + + // If the user has no projects or is using a coupon (which can only be applied to new projects) + // just ask for project details instead of showing a list of projects + const isUsersFirstProject = projects.length === 0 + if (isUsersFirstProject || coupon) { + debug( + isUsersFirstProject + ? 'No projects found for user, prompting for name' + : 'Using a coupon - skipping project selection', + ) + + const created = await promptForProjectCreation({ + coupon, + isUsersFirstProject, + organizationId: organization, + organizations, + planId, + user, + }) + + return { + ...created, + isFirstProject: isUsersFirstProject, + userAction: 'create', + } + } + + debug(`User has ${projects.length} project(s) already, showing list of choices`) + + const projectChoices = projects.map((p) => ({ + name: `${p.displayName} (${p.id})`, + value: p.id, + })) + + const selected = await select({ + choices: [{name: 'Create new project', value: 'new'}, new Separator(), ...projectChoices], + message: 'Create a new project or select an existing one', + }) + + if (selected === 'new') { + debug('User wants to create a new project, prompting for name') + + const created = await promptForProjectCreation({ + coupon, + isUsersFirstProject, + organizationId: organization, + organizations, + planId, + user, + }) + + return { + ...created, + isFirstProject: isUsersFirstProject, + userAction: 'create', + } + } + + debug(`Returning selected project (${selected})`) + return { + displayName: projects.find((p) => p.id === selected)?.displayName || '', + isFirstProject: isUsersFirstProject, + projectId: selected, + userAction: 'select', + } +} + +async function getPlan( + options: InitOptions, + output: InitContext['output'], + trace: TelemetryTrace, +): Promise { + const intendedPlan = options.projectPlan + const intendedCoupon = options.coupon + + if (intendedCoupon) { + return verifyCoupon(intendedCoupon, options.unattended, output, trace) + } else if (intendedPlan) { + return verifyPlan(intendedPlan, options.unattended, output, trace) + } else { + return undefined + } +} + +async function getPostInitMCPPrompt(editorsNames: EditorName[]): Promise { + return fetchPostInitPrompt(new Intl.ListFormat('en').format(editorsNames)) +} + +async function getProjectDetails({ + coupon, + dataset, + datasetDefault, + isAppTemplate, + newProject, + organization, + output, + planId, + project, + showDefaultConfigPrompt, + trace, + unattended, + user, + visibility, +}: { + coupon: string | undefined + dataset: string | undefined + datasetDefault: boolean + isAppTemplate: boolean + newProject: string | undefined + organization: string | undefined + output: InitContext['output'] + planId: string | undefined + project: string | undefined + showDefaultConfigPrompt: boolean + trace: TelemetryTrace + unattended: boolean + user: SanityOrgUser + visibility: 'private' | 'public' | undefined +}): Promise<{ + datasetName: string + displayName: string + isFirstProject: boolean + organizationId?: string + projectId: string + schemaUrl?: string +}> { + if (isAppTemplate) { + // If organization flag is provided, use it directly (skip prompt and API call) + if (organization) { + return { + datasetName: '', + displayName: '', + isFirstProject: false, + organizationId: organization, + projectId: '', + } + } + + // Interactive mode: fetch orgs and prompt + // Note: unattended mode without --organization is rejected by checkFlagsInUnattendedMode + const organizations = await listOrganizations({ + includeImplicitMemberships: 'true', + includeMembers: 'true', + }) + + const appOrganizationId = await promptUserForOrganization({ + isAppTemplate: true, + organizations, + user, + }) + + return { + datasetName: '', + displayName: '', + isFirstProject: false, + organizationId: appOrganizationId, + projectId: '', + } + } + + debug('Prompting user to select or create a project') + const projectResult = await getOrCreateProject({ + coupon, + newProject, + organization, + planId, + project, + unattended, + user, + }) + debug(`Project with name ${projectResult.displayName} selected`) + + // Now let's pick or create a dataset + debug('Prompting user to select or create a dataset') + const datasetResult = await getOrCreateDataset({ + dataset, + defaultConfig: datasetDefault || undefined, + displayName: projectResult.displayName, + output, + projectId: projectResult.projectId, + showDefaultConfigPrompt, + unattended, + visibility, + }) + debug(`Dataset with name ${datasetResult.datasetName} selected`) + + trace.log({ + datasetName: datasetResult.datasetName, + selectedOption: datasetResult.userAction, + step: 'createOrSelectDataset', + visibility, + }) + + return { + datasetName: datasetResult.datasetName, + displayName: projectResult.displayName, + isFirstProject: projectResult.isFirstProject, + projectId: projectResult.projectId, + } +} + +async function getProjectOutputPath({ + initFramework, + outputPath, + sluggedName, + unattended, + useEnv, + workDir, +}: { + initFramework: boolean + outputPath: string | undefined + sluggedName: string + unattended: boolean + useEnv: boolean + workDir: string +}): Promise { + const specifiedPath = outputPath && path.resolve(outputPath) + if (unattended || specifiedPath || useEnv || initFramework) { + return specifiedPath || workDir + } + + const inputPath = await input({ + default: path.join(workDir, sluggedName), + message: 'Project output path:', + validate: validateEmptyPath, + }) + + return absolutify(inputPath) +} + +async function doInitNextJs({ + datasetName, + detectedFramework, + envFilename, + mcpConfigured, + options, + output, + projectId, + trace, + workDir, +}: { + datasetName: string + detectedFramework: VersionedFramework | null + envFilename: string + mcpConfigured: EditorName[] + options: InitOptions + output: InitContext['output'] + projectId: string + trace: TelemetryTrace + workDir: string +}): Promise { + let useTypeScript = flagOrDefault(options.typescript, true) + if (shouldPrompt(options.unattended, options.typescript)) { + useTypeScript = await promptForTypeScript() + } + trace.log({ + selectedOption: useTypeScript ? 'yes' : 'no', + step: 'useTypeScript', + }) + + const fileExtension = useTypeScript ? 'ts' : 'js' + let embeddedStudio = flagOrDefault(options.nextjsEmbedStudio, true) + if (shouldPrompt(options.unattended, options.nextjsEmbedStudio)) { + embeddedStudio = await promptForEmbeddedStudio() + } + let hasSrcFolder = false + + if (embeddedStudio) { + // find source path (app or src/app) + const appDir = 'app' + let srcPath = path.join(workDir, appDir) + + if (!existsSync(srcPath)) { + srcPath = path.join(workDir, 'src', appDir) + hasSrcFolder = true + if (!existsSync(srcPath)) { + try { + await mkdir(srcPath, {recursive: true}) + } catch { + debug('Error creating folder %s', srcPath) + } + } + } + + const studioPath = options.unattended ? '/studio' : await promptForStudioPath() + + const embeddedStudioRouteFilePath = path.join( + srcPath, + `${studioPath}/`, + `[[...tool]]/page.${fileExtension}x`, + ) + + // this selects the correct template string based on whether the user is using the app or pages directory and + // replaces the ":configPath:" placeholder in the template with the correct path to the sanity.config.ts file. + // we account for the user-defined embeddedStudioPath (default /studio) is accounted for by creating enough "../" + // relative paths to reach the root level of the project + await writeOrOverwrite( + embeddedStudioRouteFilePath, + sanityStudioTemplate.replace( + ':configPath:', + `${'../'.repeat(countNestedFolders(embeddedStudioRouteFilePath.slice(workDir.length)))}sanity.config`, + ), + workDir, + options, + ) + + const sanityConfigPath = path.join(workDir, `sanity.config.${fileExtension}`) + await writeOrOverwrite( + sanityConfigPath, + sanityConfigTemplate(hasSrcFolder) + .replace(':route:', embeddedStudioRouteFilePath.slice(workDir.length).replace('src/', '')) + .replace(':basePath:', studioPath), + workDir, + options, + ) + } + + const sanityCliPath = path.join(workDir, `sanity.cli.${fileExtension}`) + await writeOrOverwrite(sanityCliPath, sanityCliTemplate, workDir, options) + + let templateToUse = options.template ?? 'clean' + if (shouldPrompt(options.unattended, options.template)) { + templateToUse = await promptForNextTemplate() + } + + await writeSourceFiles({ + fileExtension, + files: sanityFolder(useTypeScript, templateToUse as 'blog' | 'clean'), + folderPath: undefined, + options, + srcFolderPrefix: hasSrcFolder, + workDir, + }) + + let appendEnv = flagOrDefault(options.nextjsAppendEnv, true) + if (shouldPrompt(options.unattended, options.nextjsAppendEnv)) { + appendEnv = await promptForAppendEnv(envFilename) + } + + if (appendEnv) { + await createOrAppendEnvVars({ + envVars: { + DATASET: datasetName, + PROJECT_ID: projectId, + }, + filename: envFilename, + framework: detectedFramework, + log: true, + output, + outputPath: workDir, + }) + } + + if (embeddedStudio) { + const nextjsLocalDevOrigin = 'http://localhost:3000' + const existingCorsOrigins = await listCorsOrigins(projectId) + const hasExistingCorsOrigin = existingCorsOrigins.some( + (item: {origin: string}) => item.origin === nextjsLocalDevOrigin, + ) + if (!hasExistingCorsOrigin) { + try { + const createCorsRes = await createCorsOrigin({ + allowCredentials: true, + origin: nextjsLocalDevOrigin, + projectId, + }) + + output.log( + createCorsRes.id + ? `Added ${nextjsLocalDevOrigin} to CORS origins` + : `Failed to add ${nextjsLocalDevOrigin} to CORS origins`, + ) + } catch (error) { + debug(`Error creating new CORS Origin ${nextjsLocalDevOrigin}: ${error}`) + const message = error instanceof Error ? error.message : String(error) + throw new InitError(`Failed to add ${nextjsLocalDevOrigin} to CORS origins: ${message}`, 1) + } + } + } + + const chosen = await resolvePackageManager({ + interactive: !options.unattended, + output, + packageManager: options.packageManager as PackageManager, + targetDir: workDir, + }) + trace.log({selectedOption: chosen, step: 'selectPackageManager'}) + const packages = ['@sanity/vision@4', 'sanity@4', '@sanity/image-url@1', 'styled-components@6'] + if (templateToUse === 'blog') { + packages.push('@sanity/icons') + } + await installNewPackages( + { + packageManager: chosen, + packages, + }, + { + output, + workDir, + }, + ) + + // will refactor this later + const execOptions: Options = { + cwd: workDir, + encoding: 'utf8', + env: getPartialEnvWithNpmPath(workDir), + stdio: 'inherit', + } + + switch (chosen) { + case 'npm': { + await execa('npm', ['install', '--legacy-peer-deps', 'next-sanity@11'], execOptions) + break + } + case 'pnpm': { + await execa('pnpm', ['install', 'next-sanity@11'], execOptions) + break + } + case 'yarn': { + const peerDeps = await getPeerDependencies('next-sanity@11', workDir) + await installNewPackages( + {packageManager: 'yarn', packages: ['next-sanity@11', ...peerDeps]}, + {output, workDir}, + ) + break + } + case 'bun': { + await execa('bun', ['add', 'next-sanity@11'], execOptions) + break + } + default: { + // manual - do nothing + break + } + } + + output.log( + `\n${styleText('green', 'Success!')} Your Sanity configuration files has been added to this project`, + ) + if (mcpConfigured && mcpConfigured.length > 0) { + const message = await getPostInitMCPPrompt(mcpConfigured) + output.log(`\n${message}`) + output.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) + output.log( + `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, + ) + } + + await writeStagingEnvIfNeeded(output, workDir) +} + +async function promptForDatasetImport(message?: string): Promise { + return confirm({ + default: true, + message: message || 'This template includes a sample dataset, would you like to use it?', + }) +} + +async function promptForProjectCreation({ + coupon, + isUsersFirstProject, + organizationId, + organizations, + planId, + user, +}: { + coupon: string | undefined + isUsersFirstProject: boolean + organizationId: string | undefined + organizations: ProjectOrganization[] + planId: string | undefined + user: SanityOrgUser +}): Promise<{displayName: string; isFirstProject: boolean; projectId: string}> { + const projectName = await input({ + default: 'My Sanity Project', + message: 'Project name:', + validate(val) { + if (!val || val.trim() === '') { + return 'Project name cannot be empty' + } + + if (val.length > 80) { + return 'Project name cannot be longer than 80 characters' + } + + return true + }, + }) + + const org = organizationId || (await promptUserForOrganization({organizations, user})) + + const newProjectResult = await createProject({ + displayName: projectName, + metadata: {coupon}, + organizationId: org, + subscription: planId ? {planId} : undefined, + }) + + return { + ...newProjectResult, + isFirstProject: isUsersFirstProject, + } +} + +async function promptForTemplate(options: InitOptions): Promise { + const template = options.template + + const defaultTemplate = options.unattended || template ? template || 'clean' : null + if (defaultTemplate) { + return defaultTemplate + } + + return select({ + choices: [ + { + name: 'Clean project with no predefined schema types', + value: 'clean', + }, + { + name: 'Blog (schema)', + value: 'blog', + }, + { + name: 'E-commerce (Shopify)', + value: 'shopify', + }, + { + name: 'Movie project (schema + sample data)', + value: 'moviedb', + }, + ], + message: 'Select project template', + }) +} + +async function promptUserForNewOrganization( + user: SanityOrgUser, +): Promise { + const name = await promptForOrganizationName(user) + + const spin = spinner('Creating organization').start() + const org = await createOrganization(name) + spin.succeed() + + return org +} + +async function promptUserForOrganization({ + isAppTemplate = false, + organizations, + user, +}: { + isAppTemplate?: boolean + organizations: ProjectOrganization[] + user: SanityOrgUser +}): Promise { + // If the user has no organizations, prompt them to create one with the same name as + // their user, but allow them to customize it if they want + if (organizations.length === 0) { + const newOrganization = await promptUserForNewOrganization(user) + return newOrganization.id + } + + let organizationChoices: OrganizationChoices + let defaultOrganizationId: string | undefined + + if (isAppTemplate) { + // For app templates, all organizations are valid - no attach grant check needed + organizationChoices = getOrganizationChoices(organizations) + defaultOrganizationId = + organizations.length === 1 + ? organizations[0].id + : findOrganizationByUserName(organizations, user) + } else { + // For studio projects, check which organizations the user can attach projects to + debug(`User has ${organizations.length} organization(s), checking attach access`) + const withGrantInfo = await getOrganizationsWithAttachGrantInfo(organizations) + const withAttach = withGrantInfo.filter(({hasAttachGrant}) => hasAttachGrant) + + debug('User has attach access to %d organizations.', withAttach.length) + organizationChoices = getOrganizationChoices(withGrantInfo) + defaultOrganizationId = + withAttach.length === 1 + ? withAttach[0].organization.id + : findOrganizationByUserName(organizations, user) + } + + const chosenOrg = await select({ + choices: organizationChoices, + default: defaultOrganizationId || undefined, + message: 'Select organization:', + }) + + if (chosenOrg === '-new-') { + const newOrganization = await promptUserForNewOrganization(user) + return newOrganization.id + } + + return chosenOrg || undefined +} + +async function verifyCoupon( + intendedCoupon: string, + unattended: boolean, + output: InitContext['output'], + trace: TelemetryTrace, +): Promise { + try { + const planId = await getPlanIdFromCoupon(intendedCoupon) + output.log(`Coupon "${intendedCoupon}" validated!\n`) + return planId + } catch (err: unknown) { + if (!isHttpError(err) || err.statusCode !== 404) { + const message = err instanceof Error ? err.message : `${err}` + throw new InitError(`Unable to validate coupon, please try again later:\n\n${message}`, 1) + } + + const useDefaultPlan = + unattended || + (await confirm({ + default: true, + message: `Coupon "${intendedCoupon}" is not available, use default plan instead?`, + })) + + if (unattended) { + output.warn(`Coupon "${intendedCoupon}" is not available - using default plan`) + } + + trace.log({ + coupon: intendedCoupon, + selectedOption: useDefaultPlan ? 'yes' : 'no', + step: 'useDefaultPlanCoupon', + }) + + if (useDefaultPlan) { + output.log('Using default plan.') + return undefined + } + + throw new InitError(`Coupon "${intendedCoupon}" does not exist`, 1) + } +} + +async function verifyPlan( + intendedPlan: string, + unattended: boolean, + output: InitContext['output'], + trace: TelemetryTrace, +): Promise { + try { + const planId = await getPlanId(intendedPlan) + return planId + } catch (err: unknown) { + if (!isHttpError(err) || err.statusCode !== 404) { + const message = err instanceof Error ? err.message : `${err}` + throw new InitError(`Unable to validate plan, please try again later:\n\n${message}`, 1) + } + + const useDefaultPlan = + unattended || + (await confirm({ + default: true, + message: `Project plan "${intendedPlan}" does not exist, use default plan instead?`, + })) + + if (unattended) { + output.warn(`Project plan "${intendedPlan}" does not exist - using default plan`) + } + + trace.log({ + planId: intendedPlan, + selectedOption: useDefaultPlan ? 'yes' : 'no', + step: 'useDefaultPlanId', + }) + + if (useDefaultPlan) { + output.log('Using default plan.') + return undefined + } + + throw new InitError(`Plan id "${intendedPlan}" does not exist`, 1) + } +} + +async function writeOrOverwrite( + filePath: string, + content: string, + workDir: string, + options: InitOptions, +): Promise { + if (existsSync(filePath)) { + let overwrite = flagOrDefault(options.overwriteFiles, false) + if (shouldPrompt(options.unattended, options.overwriteFiles)) { + overwrite = await confirm({ + default: false, + message: `File ${styleText( + 'yellow', + filePath.replace(workDir, ''), + )} already exists. Do you want to overwrite it?`, + }) + } + + if (!overwrite) { + return + } + } + + // make folder if not exists + const folderPath = path.dirname(filePath) + + try { + await mkdir(folderPath, {recursive: true}) + } catch { + debug('Error creating folder %s', folderPath) + } + + await writeFile(filePath, content, { + encoding: 'utf8', + }) +} + +// write sanity folder files +async function writeSourceFiles({ + fileExtension, + files, + folderPath, + options, + srcFolderPrefix, + workDir, +}: { + fileExtension: string + files: Record | string> + folderPath?: string + options: InitOptions + srcFolderPrefix?: boolean + workDir: string +}): Promise { + for (const [filePath, content] of Object.entries(files)) { + // check if file ends with full stop to indicate it's file and not directory (this only works with our template tree structure) + if (filePath.includes('.') && typeof content === 'string') { + await writeOrOverwrite( + path.join( + workDir, + srcFolderPrefix ? 'src' : '', + 'sanity', + folderPath || '', + `${filePath}${fileExtension}`, + ), + content, + workDir, + options, + ) + } else { + await mkdir(path.join(workDir, srcFolderPrefix ? 'src' : '', 'sanity', filePath), { + recursive: true, + }) + if (typeof content === 'object') { + await writeSourceFiles({ + fileExtension, + files: content, + folderPath: filePath, + options, + srcFolderPrefix, + workDir, + }) + } + } + } +} + +/** + * When running in a non-production Sanity environment (e.g. staging), write the + * `SANITY_INTERNAL_ENV` variable to a `.env` file in the output directory so that + * the bootstrapped project continues to target the same environment. + */ +async function writeStagingEnvIfNeeded( + output: InitContext['output'], + outputPath: string, +): Promise { + const sanityEnv = getSanityEnv() + if (sanityEnv === 'production') return + + await createOrAppendEnvVars({ + envVars: {INTERNAL_ENV: sanityEnv}, + filename: '.env', + framework: null, + log: false, + output, + outputPath, + }) +} diff --git a/packages/@sanity/cli/src/actions/init/initError.ts b/packages/@sanity/cli/src/actions/init/initError.ts new file mode 100644 index 000000000..e3a9b2d02 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/initError.ts @@ -0,0 +1,14 @@ +/** + * Error thrown by initAction when the init flow should terminate with an error. + * The caller decides how to handle it - eg InitCommand (oclif) catches + * and translates to oclif's error/exit semantics. + */ +export class InitError extends Error { + exitCode: number + override name = 'InitError' + + constructor(message: string, exitCode = 1) { + super(message) + this.exitCode = exitCode + } +} diff --git a/packages/@sanity/cli/src/actions/init/types.ts b/packages/@sanity/cli/src/actions/init/types.ts index b9cbfafcd..5ed78c98a 100644 --- a/packages/@sanity/cli/src/actions/init/types.ts +++ b/packages/@sanity/cli/src/actions/init/types.ts @@ -1,6 +1,7 @@ +import {type CLITelemetryStore, type Output} from '@sanity/cli-core' import {Framework} from '@vercel/frameworks' -import {GenerateConfigOptions} from './createStudioConfig' +import {type GenerateConfigOptions} from './createStudioConfig.js' export type VersionedFramework = Framework & { detectedVersion?: string @@ -17,3 +18,159 @@ export interface ProjectTemplate { type?: 'commonjs' | 'module' typescriptOnly?: boolean } + +export interface InitOptions { + autoUpdates: boolean + bare: boolean + datasetDefault: boolean + fromCreate: boolean + /** + * Controls how MCP setup behaves during init: + * - 'prompt': Ask the user which editors to configure (default, interactive) + * - 'auto': Auto-configure all detected editors without prompting (--yes in TTY) + * - 'skip': Skip MCP configuration entirely (CI, --no-mcp) + */ + mcpMode: 'auto' | 'prompt' | 'skip' + /** Resolved from `--yes` + TTY check before calling `initAction` */ + unattended: boolean + + /** Positional argument, e.g. `sanity init plugin` */ + argType?: string + coupon?: string + dataset?: string + env?: string + /** `string` = commit message, `true`/`undefined` = default, `false` = no git */ + git?: boolean | string + importDataset?: boolean + // Next.js specific + nextjsAddConfigFiles?: boolean + nextjsAppendEnv?: boolean + nextjsEmbedStudio?: boolean + organization?: string + outputPath?: string + overwriteFiles?: boolean + packageManager?: 'npm' | 'pnpm' | 'yarn' + project?: string + projectName?: string + projectPlan?: string + provider?: string + /** Deprecated flag - kept for backwards compat error messaging */ + reconfigure?: boolean + template?: string + templateToken?: string + typescript?: boolean + visibility?: 'private' | 'public' +} + +export interface InitContext { + /** + * Logging methods passed to sub-actions. + * `initAction` itself throws `InitError` instead of calling `output.error`. + */ + output: Output + telemetry: CLITelemetryStore + workDir: string +} + +/** + * Shape of the parsed oclif flags from `InitCommand`. + * Kept loose so we don't need to import oclif types at runtime. + */ +interface InitCommandFlags { + 'auto-updates': boolean + bare: boolean + 'dataset-default': boolean + 'from-create': boolean + mcp: boolean + 'no-git': boolean + + coupon?: string + 'create-project'?: string + dataset?: string + env?: string + git?: string + 'import-dataset'?: boolean + 'nextjs-add-config-files'?: boolean + 'nextjs-append-env'?: boolean + 'nextjs-embed-studio'?: boolean + organization?: string + 'output-path'?: string + 'overwrite-files'?: boolean + 'package-manager'?: string + project?: string + 'project-name'?: string + 'project-plan'?: string + provider?: string + reconfigure?: boolean + template?: string + 'template-token'?: string + typescript?: boolean + visibility?: string +} + +/** + * Shape of the parsed oclif args from `InitCommand`. + */ +interface InitCommandArgs { + type?: string +} + +const VALID_PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn']) +function narrowPackageManager(value: string | undefined): InitOptions['packageManager'] { + return value !== undefined && VALID_PACKAGE_MANAGERS.has(value) + ? (value as InitOptions['packageManager']) + : undefined +} + +const VALID_VISIBILITIES = new Set(['private', 'public']) +function narrowVisibility(value: string | undefined): InitOptions['visibility'] { + return value !== undefined && VALID_VISIBILITIES.has(value) + ? (value as InitOptions['visibility']) + : undefined +} + +/** + * Converts oclif's kebab-case parsed flags into a framework-agnostic `InitOptions` object. + * + * @param flags - Parsed oclif flags from `InitCommand` + * @param isUnattended - Whether the session is unattended (resolved from `--yes` + TTY check by the caller) + * @param args - Parsed oclif positional arguments from `InitCommand` + * @param mcpMode - MCP setup mode, computed by the command from flags and environment + */ +export function flagsToInitOptions( + flags: InitCommandFlags, + isUnattended: boolean, + args: InitCommandArgs | undefined, + mcpMode: InitOptions['mcpMode'], +): InitOptions { + return { + argType: args?.type, + autoUpdates: flags['auto-updates'], + bare: flags.bare, + coupon: flags.coupon, + dataset: flags.dataset, + datasetDefault: flags['dataset-default'], + env: flags.env, + fromCreate: flags['from-create'], + git: flags['no-git'] ? false : flags.git, + importDataset: flags['import-dataset'], + mcpMode, + nextjsAddConfigFiles: flags['nextjs-add-config-files'], + nextjsAppendEnv: flags['nextjs-append-env'], + nextjsEmbedStudio: flags['nextjs-embed-studio'], + organization: flags.organization, + outputPath: flags['output-path'], + overwriteFiles: flags['overwrite-files'], + packageManager: narrowPackageManager(flags['package-manager']), + project: flags.project, + projectName: flags['project-name'] ?? flags['create-project'], + projectPlan: flags['project-plan'], + provider: flags.provider, + reconfigure: flags.reconfigure, + template: flags.template, + templateToken: flags['template-token'], + typescript: flags.typescript, + unattended: isUnattended, + visibility: narrowVisibility(flags.visibility), + } +} diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts index 89a32275a..2d6082e9c 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.bootstrap-app.test.ts @@ -232,7 +232,7 @@ describe('#init: bootstrap-app-initialization', () => { ) // Exits early without calling rest of templating code - expect(error?.oclif?.exit).toBe(0) + if (error) throw error }) test('initializes app-quickstart template with app-specific output', async () => { diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.command.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.command.test.ts new file mode 100644 index 000000000..3208bb426 --- /dev/null +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.command.test.ts @@ -0,0 +1,114 @@ +import {testCommand} from '@sanity/cli-test' +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {InitCommand} from '../../init.js' + +const mockInitAction = vi.hoisted(() => vi.fn()) +const mockIsInteractive = vi.hoisted(() => vi.fn().mockReturnValue(true)) + +vi.mock('../../../actions/init/initAction.js', () => ({ + initAction: mockInitAction, +})) + +vi.mock('@sanity/cli-core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isInteractive: mockIsInteractive, + } +}) + +describe('InitCommand.run() mcpMode computation', () => { + afterEach(() => { + vi.clearAllMocks() + mockIsInteractive.mockReturnValue(true) + }) + + test('sets mcpMode to "prompt" by default (interactive, no --yes)', async () => { + mockInitAction.mockResolvedValue(undefined) + + const {error} = await testCommand(InitCommand, [], { + mocks: {isInteractive: true, token: 'test-token'}, + }) + + if (error) throw error + expect(mockInitAction).toHaveBeenCalledWith( + expect.objectContaining({mcpMode: 'prompt'}), + expect.any(Object), + ) + }) + + test('sets mcpMode to "auto" when --yes is passed in interactive env', async () => { + mockInitAction.mockResolvedValue(undefined) + + const {error} = await testCommand(InitCommand, ['--yes'], { + mocks: {isInteractive: true, token: 'test-token'}, + }) + + if (error) throw error + expect(mockInitAction).toHaveBeenCalledWith( + expect.objectContaining({mcpMode: 'auto'}), + expect.any(Object), + ) + }) + + test('sets mcpMode to "skip" when --no-mcp is passed', async () => { + mockInitAction.mockResolvedValue(undefined) + + const {error} = await testCommand(InitCommand, ['--no-mcp'], { + mocks: {isInteractive: true, token: 'test-token'}, + }) + + if (error) throw error + expect(mockInitAction).toHaveBeenCalledWith( + expect.objectContaining({mcpMode: 'skip'}), + expect.any(Object), + ) + }) + + test('sets mcpMode to "skip" when not interactive (CI)', async () => { + mockIsInteractive.mockReturnValue(false) + mockInitAction.mockResolvedValue(undefined) + + const {error} = await testCommand(InitCommand, [], { + mocks: {isInteractive: false, token: 'test-token'}, + }) + + if (error) throw error + expect(mockInitAction).toHaveBeenCalledWith( + expect.objectContaining({mcpMode: 'skip'}), + expect.any(Object), + ) + }) +}) + +describe('InitCommand.run() error handling', () => { + afterEach(() => { + vi.clearAllMocks() + mockIsInteractive.mockReturnValue(true) + }) + + test('translates InitError to oclif error', async () => { + const {InitError} = await import('../../../actions/init/initError.js') + mockInitAction.mockRejectedValue(new InitError('something broke', 1)) + + const {error} = await testCommand(InitCommand, [], { + mocks: {isInteractive: true, token: 'test-token'}, + }) + + expect(error).toBeInstanceOf(Error) + expect(error?.oclif?.exit).toBe(1) + expect(error?.message).toContain('something broke') + }) + + test('re-throws non-InitError errors', async () => { + mockInitAction.mockRejectedValue(new TypeError('unexpected')) + + const {error} = await testCommand(InitCommand, [], { + mocks: {isInteractive: true, token: 'test-token'}, + }) + + expect(error).toBeInstanceOf(TypeError) + expect(error?.message).toBe('unexpected') + }) +}) diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.nextjs.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.nextjs.test.ts index 5081dda9a..715a78990 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.nextjs.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.nextjs.test.ts @@ -253,7 +253,7 @@ describe('#init:nextjs-app-initialization', () => { 'Have feedback? Tell us in the community: https://www.sanity.io/community/join', ) - expect(error?.oclif?.exit).toBe(0) + if (error) throw error }) test('initializes nextjs app in unattended mode', async () => { @@ -334,7 +334,7 @@ describe('#init:nextjs-app-initialization', () => { 'Have feedback? Tell us in the community: https://www.sanity.io/community/join', ) - expect(error?.oclif?.exit).toBe(0) + if (error) throw error }) test('writes SANITY_INTERNAL_ENV to .env when in staging', async () => { @@ -381,7 +381,7 @@ describe('#init:nextjs-app-initialization', () => { }, ) - expect(error?.oclif?.exit).toBe(0) + if (error) throw error // Called twice: once for project env vars (.env.local), once for staging env (.env) expect(mocks.createOrAppendEnvVars).toHaveBeenCalledTimes(2) diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts index 71380d9c3..38992ce1c 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.staging-env.test.ts @@ -192,7 +192,7 @@ describe('#init: staging env propagation', () => { }, ) - expect(error?.oclif?.exit).toBe(0) + if (error) throw error // Should be called twice: once for project env vars, once for staging env expect(mocks.createOrAppendEnvVars).toHaveBeenCalledTimes(2) diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index 544aea81f..2c48b75f3 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -1,96 +1,11 @@ -import {existsSync} from 'node:fs' -import {mkdir, writeFile} from 'node:fs/promises' -import path from 'node:path' -import {styleText} from 'node:util' - import {Args, Command, Flags} from '@oclif/core' import {CLIError} from '@oclif/core/errors' -import { - getCliToken, - SanityCommand, - type SanityOrgUser, - subdebug, - type TelemetryUserProperties, -} from '@sanity/cli-core' -import {confirm, input, logSymbols, select, Separator, spinner} from '@sanity/cli-core/ux' -import {type DatasetAclMode, isHttpError} from '@sanity/client' -import {type TelemetryTrace} from '@sanity/telemetry' -import {type Framework, frameworks} from '@vercel/frameworks' -import {execa, type Options} from 'execa' -import deburr from 'lodash-es/deburr.js' - -import {validateSession} from '../actions/auth/ensureAuthenticated.js' -import {getProviderName} from '../actions/auth/getProviderName.js' -import {login} from '../actions/auth/login/login.js' -import {createDataset} from '../actions/dataset/create.js' -import {bootstrapTemplate} from '../actions/init/bootstrapTemplate.js' -import {checkNextJsReactCompatibility} from '../actions/init/checkNextJsReactCompatibility.js' -import {countNestedFolders} from '../actions/init/countNestedFolders.js' -import {determineAppTemplate} from '../actions/init/determineAppTemplate.js' -import {createOrAppendEnvVars} from '../actions/init/env/createOrAppendEnvVars.js' -import {fetchPostInitPrompt} from '../actions/init/fetchPostInitPrompt.js' -import {tryGitInit} from '../actions/init/git.js' -import { - checkIsRemoteTemplate, - getGitHubRepoInfo, - type RepoInfo, -} from '../actions/init/remoteTemplate.js' -import {resolvePackageManager} from '../actions/init/resolvePackageManager.js' -import templates from '../actions/init/templates/index.js' -import { - sanityCliTemplate, - sanityConfigTemplate, - sanityFolder, - sanityStudioTemplate, -} from '../actions/init/templates/nextjs/index.js' -import {type VersionedFramework} from '../actions/init/types.js' -import {type EditorName} from '../actions/mcp/editorConfigs.js' -import {setupMCP} from '../actions/mcp/setupMCP.js' -import {findOrganizationByUserName} from '../actions/organizations/findOrganizationByUserName.js' -import {getOrganizationChoices} from '../actions/organizations/getOrganizationChoices.js' -import {getOrganizationsWithAttachGrantInfo} from '../actions/organizations/getOrganizationsWithAttachGrantInfo.js' -import {hasProjectAttachGrant} from '../actions/organizations/hasProjectAttachGrant.js' -import {type OrganizationChoices} from '../actions/organizations/types.js' -import { - promptForAppendEnv, - promptForConfigFiles, - promptForEmbeddedStudio, - promptForNextTemplate, - promptForStudioPath, -} from '../prompts/init/nextjs.js' -import {promptForTypeScript} from '../prompts/init/promptForTypescript.js' -import {promptForDatasetName} from '../prompts/promptForDatasetName.js' -import {promptForDefaultConfig} from '../prompts/promptForDefaultConfig.js' -import {promptForOrganizationName} from '../prompts/promptForOrganizationName.js' -import {createCorsOrigin, listCorsOrigins} from '../services/cors.js' -import {createDataset as createDatasetService, listDatasets} from '../services/datasets.js' -import {getProjectFeatures} from '../services/getProjectFeatures.js' -import { - createOrganization, - listOrganizations, - type OrganizationCreateResponse, - type ProjectOrganization, -} from '../services/organizations.js' -import {getPlanId, getPlanIdFromCoupon} from '../services/plans.js' -import {createProject, listProjects, updateProjectInitializedAt} from '../services/projects.js' -import {getCliUser} from '../services/user.js' -import {CLIInitStepCompleted, type InitStepResult} from '../telemetry/init.telemetry.js' -import {detectFrameworkRecord} from '../util/detectFramework.js' -import {absolutify, validateEmptyPath} from '../util/fsUtils.js' -import {getProjectDefaults} from '../util/getProjectDefaults.js' -import {getSanityEnv} from '../util/getSanityEnv.js' -import {getPeerDependencies} from '../util/packageManager/getPeerDependencies.js' -import { - installDeclaredPackages, - installNewPackages, -} from '../util/packageManager/installPackages.js' -import { - getPartialEnvWithNpmPath, - type PackageManager, -} from '../util/packageManager/packageManagerChoice.js' -import {ImportDatasetCommand} from './datasets/import.js' +import {type FlagInput} from '@oclif/core/interfaces' +import {isInteractive, SanityCommand} from '@sanity/cli-core' -const debug = subdebug('init') +import {initAction} from '../actions/init/initAction.js' +import {InitError} from '../actions/init/initError.js' +import {flagsToInitOptions} from '../actions/init/types.js' export class InitCommand extends SanityCommand { static override args = {type: Args.string({hidden: true})} @@ -292,1464 +207,31 @@ export class InitCommand extends SanityCommand { description: 'Unattended mode, answers "yes" to any "yes/no" prompt and otherwise uses defaults', }), - } - - _trace!: TelemetryTrace + } satisfies FlagInput public async run(): Promise { - const workDir = process.cwd() - - const createProjectName = this.flags['project-name'] ?? this.flags['create-project'] - // For backwards "compatibility" - we used to allow `sanity init plugin`, - // and no longer do - but instead of printing an error about an unknown - // _command_, we want to acknowledge that the user is trying to do something - // that no longer exists but might have at some point in the past. - if (this.args.type) { - this.error( - this.args.type === 'plugin' - ? 'Initializing plugins through the CLI is no longer supported' - : `Unknown init type "${this.args.type}"`, - {exit: 1}, - ) - } - - this._trace = this.telemetry.trace(CLIInitStepCompleted) - - // Slightly more helpful message for removed flags rather than just saying the flag - // does not exist. - if (this.flags.reconfigure) { - this.error('--reconfigure is deprecated - manual configuration is now required', {exit: 1}) - } - - // Oclif doesn't support custom exclusive error messaging - if (this.flags.project && this.flags.organization) { - this.error( - 'You have specified both a project and an organization. To move a project to an organization please visit https://www.sanity.io/manage', - {exit: 1}, - ) - } - - const defaultConfig = this.flags['dataset-default'] - let showDefaultConfigPrompt = !defaultConfig - if ( - this.flags.dataset || - this.flags.visibility || - this.flags['dataset-default'] || - this.isUnattended() - ) { - showDefaultConfigPrompt = false - } - - const detectedFramework = await detectFrameworkRecord({ - frameworkList: frameworks as readonly Framework[], - rootPath: process.cwd(), - }) - const isNextJs = detectedFramework?.slug === 'nextjs' - - let remoteTemplateInfo: RepoInfo | undefined - if (this.flags.template && checkIsRemoteTemplate(this.flags.template)) { - remoteTemplateInfo = await getGitHubRepoInfo( - this.flags.template, - this.flags['template-token'], - ) - } - - if (detectedFramework && detectedFramework.slug !== 'sanity' && remoteTemplateInfo) { - this.error( - `A remote template cannot be used with a detected framework. Detected: ${detectedFramework.name}`, - {exit: 1}, - ) - } - - const isAppTemplate = this.flags.template ? determineAppTemplate(this.flags.template) : false // Default to false - - // Checks flags are present when in unattended mode - if (this.isUnattended()) { - this.checkFlagsInUnattendedMode({createProjectName, isAppTemplate, isNextJs}) - } - - this._trace.start() - this._trace.log({ - flags: { - bare: this.flags.bare, - coupon: this.flags.coupon, - defaultConfig, - env: this.flags.env, - git: this.flags.git, - plan: this.flags['project-plan'], - reconfigure: this.flags.reconfigure, - unattended: this.isUnattended(), - }, - step: 'start', - }) - - // Plan can be set through `--project-plan`, or implied through `--coupon`. - // As coupons can expire and project plans might change/be removed, we need to - // verify that the passed flags are valid. The complexity of this is hidden in the - // below plan methods, eventually returning a plan ID or undefined if we are told to - // use the default plan. - - const planId = await this.getPlan() - - let envFilenameDefault = '.env' - if (detectedFramework && detectedFramework.slug === 'nextjs') { - envFilenameDefault = '.env.local' - } - const envFilename = typeof this.flags.env === 'string' ? this.flags.env : envFilenameDefault - - // If the user isn't already autenticated, make it so - const {user} = await this.ensureAuthenticated() - if (!isAppTemplate) { - this.log(`${logSymbols.success} Fetching existing projects`) - this.log('') - } - - let newProject: string | undefined - if (createProjectName) { - newProject = await this.createProjectFromName({ - createProjectName, - planId, - user, - }) - } - - const {datasetName, displayName, isFirstProject, organizationId, projectId} = - await this.getProjectDetails({ - isAppTemplate, - newProject, - planId, - showDefaultConfigPrompt, - user, - }) - - // If user doesn't want to output any template code - if (this.flags.bare) { - this.log(`${logSymbols.success} Below are your project details`) - this.log('') - this.log(`Project ID: ${styleText('cyan', projectId)}`) - this.log(`Dataset: ${styleText('cyan', datasetName)}`) - this.log( - `\nYou can find your project on Sanity Manage — https://www.sanity.io/manage/project/${projectId}\n`, - ) - return - } - - let initNext = this.flagOrDefault('nextjs-add-config-files', false) - if (isNextJs && this.promptForUndefinedFlag(this.flags['nextjs-add-config-files'])) { - initNext = await promptForConfigFiles() - } - - this._trace.log({ - detectedFramework: detectedFramework?.name, - selectedOption: initNext ? 'yes' : 'no', - step: 'useDetectedFramework', - }) - - const sluggedName = deburr(displayName.toLowerCase()) - .replaceAll(/\s+/g, '-') - .replaceAll(/[^a-z0-9-]/g, '') - - // add more frameworks to this as we add support for them - // this is used to skip the getProjectInfo prompt - const initFramework = initNext - - // Gather project defaults based on environment - const defaults = await getProjectDefaults({isPlugin: false, workDir}) - - // Prompt the user for required information - const outputPath = await this.getProjectOutputPath({ - initFramework, - sluggedName, - workDir, - }) - - // Set up MCP integration + // Compute MCP mode from flags and environment: + // - CI (no TTY) or --no-mcp: skip MCP entirely + // - --yes (user terminal): auto-configure all detected editors + // - Interactive: prompt user let mcpMode: 'auto' | 'prompt' | 'skip' = 'prompt' - if (!this.flags.mcp || !this.resolveIsInteractive()) { + if (!this.flags.mcp || !isInteractive()) { mcpMode = 'skip' } else if (this.flags.yes) { mcpMode = 'auto' } - const mcpResult = await setupMCP({mode: mcpMode}) - - this._trace.log({ - configuredEditors: mcpResult.configuredEditors, - detectedEditors: mcpResult.detectedEditors, - skipped: mcpResult.skipped, - step: 'mcpSetup', - }) - if (mcpResult.error) { - this._trace.error(mcpResult.error) - } - const mcpConfigured = mcpResult.configuredEditors - - // Show checkmark for editors that were already configured - const {alreadyConfiguredEditors} = mcpResult - if (alreadyConfiguredEditors.length > 0) { - const label = - alreadyConfiguredEditors.length === 1 - ? `${alreadyConfiguredEditors[0]} already configured for Sanity MCP` - : `${alreadyConfiguredEditors.length} editors already configured for Sanity MCP` - spinner(label).start().succeed() - } - - if (isNextJs) { - await checkNextJsReactCompatibility({ - detectedFramework, - output: this.output, - outputPath, - }) - } - - if (initNext) { - await this.initNextJs({ - datasetName, - detectedFramework, - envFilename, - mcpConfigured, - projectId, - workDir, - }) - } - - // user wants to write environment variables to file - if (this.flags.env) { - await createOrAppendEnvVars({ - envVars: { - DATASET: datasetName, - PROJECT_ID: projectId, - }, - filename: envFilename, - framework: detectedFramework, - log: false, - output: this.output, - outputPath, - }) - await this.writeStagingEnvIfNeeded(outputPath) - this.exit(0) - } - - // Prompt for template to use - const templateName = await this.promptForTemplate() - this._trace.log({ - selectedOption: templateName, - step: 'selectProjectTemplate', - }) - const template = templates[templateName] - if (!remoteTemplateInfo && !template) { - this.error(`Template "${templateName}" not found`, {exit: 1}) - } - - let useTypeScript = this.flags.typescript - if (!remoteTemplateInfo && template && template.typescriptOnly === true) { - useTypeScript = true - } else if (this.promptForUndefinedFlag(this.flags.typescript)) { - useTypeScript = await promptForTypeScript() - this._trace.log({ - selectedOption: useTypeScript ? 'yes' : 'no', - step: 'useTypeScript', - }) - } - - // If the template has a sample dataset, prompt the user whether or not we should import it - const importDatasetFlag = this.flags['import-dataset'] - const shouldImport = - template?.datasetUrl && - (importDatasetFlag ?? - (!this.isUnattended() && (await this.promptForDatasetImport(template.importPrompt)))) - - this._trace.log({ - selectedOption: shouldImport ? 'yes' : 'no', - step: 'importTemplateDataset', - }) try { - await updateProjectInitializedAt(projectId) - } catch (err) { - // Non-critical update - debug('Failed to update cliInitializedAt metadata', err) - } - - try { - await bootstrapTemplate({ - autoUpdates: this.flags['auto-updates'], - bearerToken: this.flags['template-token'], - dataset: datasetName, - organizationId, + await initAction(flagsToInitOptions(this.flags, this.isUnattended(), this.args, mcpMode), { output: this.output, - outputPath, - overwriteFiles: this.flags['overwrite-files'], - packageName: sluggedName, - projectId, - projectName: displayName || defaults.projectName, - remoteTemplateInfo, - templateName, - useTypeScript, + telemetry: this.telemetry, + workDir: process.cwd(), }) } catch (error) { - if (error instanceof Error) { - throw error - } - throw new Error(String(error)) - } - - const pkgManager = await resolvePackageManager({ - interactive: !this.isUnattended(), - output: this.output, - packageManager: this.flags['package-manager'] as PackageManager, - targetDir: outputPath, - }) - - this._trace.log({ - selectedOption: pkgManager, - step: 'selectPackageManager', - }) - - // Now for the slow part... installing dependencies - await installDeclaredPackages(outputPath, pkgManager, { - output: this.output, - workDir, - }) - - const useGit = this.flags.git === undefined || Boolean(this.flags.git) - const commitMessage = this.flags.git - await this.writeStagingEnvIfNeeded(outputPath) - - // Try initializing a git repository - if (useGit) { - tryGitInit(outputPath, typeof commitMessage === 'string' ? commitMessage : undefined) - } - - // Prompt for dataset import (if a dataset is defined) - if (shouldImport && template?.datasetUrl) { - const token = await getCliToken() - if (!token) { - this.error('Authentication required to import dataset', {exit: 1}) - } - await ImportDatasetCommand.run( - [ - template.datasetUrl, - '--project-id', - projectId, - '--dataset', - datasetName, - '--token', - token, - ], - { - root: outputPath, - }, - ) - - this.log('') - this.log('If you want to delete the imported data, use') - this.log(` ${styleText('cyan', `npx sanity dataset delete ${datasetName}`)}`) - this.log('and create a new clean dataset with') - this.log(` ${styleText('cyan', `npx sanity dataset create `)}\n`) - } - - const devCommandMap: Record = { - bun: 'bun dev', - manual: 'npm run dev', - npm: 'npm run dev', - pnpm: 'pnpm dev', - yarn: 'yarn dev', - } - const devCommand = devCommandMap[pkgManager] - - const isCurrentDir = outputPath === process.cwd() - const goToProjectDir = `\n(${styleText('cyan', `cd ${outputPath}`)} to navigate to your new project directory)` - - if (isAppTemplate) { - //output for custom apps here - this.log( - `${logSymbols.success} ${styleText(['green', 'bold'], 'Success!')} Your custom app has been scaffolded.`, - ) - if (!isCurrentDir) this.log(goToProjectDir) - this.log( - `\n${styleText('bold', 'Next')}, configure the project(s) and dataset(s) your app should work with.`, - ) - this.log('\nGet started in `src/App.tsx`, or refer to our documentation for a walkthrough:') - this.log( - styleText(['blue', 'underline'], 'https://www.sanity.io/docs/app-sdk/sdk-configuration'), - ) - if (mcpConfigured && mcpConfigured.length > 0) { - const message = await this.getPostInitMCPPrompt(mcpConfigured) - this.log(`\n${message}`) - this.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) - this.log( - `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, - ) - } - this.log('\n') - this.log(`Other helpful commands:`) - this.log(`npx sanity docs browse to open the documentation in a browser`) - this.log(`npx sanity dev to start the development server for your app`) - this.log(`npx sanity deploy to deploy your app`) - } else { - //output for Studios here - this.log(`✅ ${styleText(['green', 'bold'], 'Success!')} Your Studio has been created.`) - if (!isCurrentDir) this.log(goToProjectDir) - this.log( - `\nGet started by running ${styleText('cyan', devCommand)} to launch your Studio's development server`, - ) - if (mcpConfigured && mcpConfigured.length > 0) { - const message = await this.getPostInitMCPPrompt(mcpConfigured) - this.log(`\n${message}`) - this.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) - this.log( - `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, - ) - } - this.log('\n') - this.log(`Other helpful commands:`) - this.log(`npx sanity docs browse to open the documentation in a browser`) - this.log(`npx sanity manage to open the project settings in a browser`) - this.log(`npx sanity help to explore the CLI manual`) - } - - if (isFirstProject) { - this._trace.log({selectedOption: 'yes', step: 'sendCommunityInvite'}) - - const DISCORD_INVITE_LINK = 'https://www.sanity.io/community/join' - - this.log(`\nJoin the Sanity community: ${styleText('cyan', DISCORD_INVITE_LINK)}`) - this.log('We look forward to seeing you there!\n') - } - - this._trace.complete() - } - - private checkFlagsInUnattendedMode({ - createProjectName, - isAppTemplate, - isNextJs, - }: { - createProjectName: string | undefined - isAppTemplate: boolean - isNextJs: boolean - }) { - debug('Unattended mode, validating required options') - - // App templates only require --organization and --output-path - if (isAppTemplate) { - if (!this.flags['output-path']) { - this.error('`--output-path` must be specified in unattended mode', { - exit: 1, - }) - } - - if (!this.flags.organization) { - this.error( - 'The --organization flag is required for app templates in unattended mode. ' + - 'Use --organization to specify which organization to use.', - {exit: 1}, - ) - } - - return - } - - if (!this.flags['dataset']) { - this.error(`\`--dataset\` must be specified in unattended mode`, { - exit: 1, - }) - } - - // output-path is required in unattended mode when not using nextjs - if (!isNextJs && !this.flags['output-path']) { - this.error(`\`--output-path\` must be specified in unattended mode`, { - exit: 1, - }) - } - - if (!this.flags.project && !createProjectName) { - this.error( - '`--project ` or `--project-name ` must be specified in unattended mode', - {exit: 1}, - ) - } - - if (createProjectName && !this.flags.organization) { - this.error('`--project-name` requires `--organization ` in unattended mode', {exit: 1}) - } - } - - private async createProjectFromName({ - createProjectName, - planId, - user, - }: { - createProjectName: string - planId: string | undefined - user: SanityOrgUser - }) { - debug('--project-name specified, creating a new project') - - let orgForCreateProjectFlag = this.flags.organization - - if (!orgForCreateProjectFlag) { - debug('no organization specified, selecting one') - const organizations = await listOrganizations() - orgForCreateProjectFlag = await this.promptUserForOrganization({ - organizations, - user, - }) - } - - debug('creating a new project') - const createdProject = await createProject({ - displayName: createProjectName.trim(), - metadata: {coupon: this.flags.coupon}, - organizationId: orgForCreateProjectFlag, - subscription: planId ? {planId} : undefined, - }) - - debug('Project with ID %s created', createdProject.projectId) - if (this.flags.dataset) { - debug('--dataset specified, creating dataset (%s)', this.flags.dataset) - const spin = spinner('Creating dataset').start() - await createDatasetService({ - aclMode: this.flags.visibility as DatasetAclMode, - datasetName: this.flags.dataset, - projectId: createdProject.projectId, - }) - spin.succeed() - } - - return createdProject.projectId - } - - // @todo do we actually need to be authenticated for init? check flags and determine. - private async ensureAuthenticated(): Promise<{user: SanityOrgUser}> { - const user = await validateSession() - - if (user) { - this._trace.log({alreadyLoggedIn: true, step: 'login'}) - this.log( - `${logSymbols.success} You are logged in as ${user.email} using ${getProviderName(user.provider)}`, - ) - return {user} - } - - if (this.isUnattended()) { - this.error('Must be logged in to run this command in unattended mode, run `sanity login`', { - exit: 1, - }) - } - - this._trace.log({step: 'login'}) - - try { - await login({ - output: this.output, - telemetry: this._trace.newContext('login'), - }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - this.error(`Login failed: ${message}`, {exit: 1}) - } - - const loggedInUser = await getCliUser() - - this.log( - `${logSymbols.success} You are logged in as ${loggedInUser.email} using ${getProviderName(loggedInUser.provider)}`, - ) - return {user: loggedInUser} - } - - private flagOrDefault(flag: keyof typeof this.flags, defaultValue: boolean): boolean { - return typeof this.flags[flag] === 'boolean' ? this.flags[flag] : defaultValue - } - - private async getOrCreateDataset(opts: { - displayName: string - projectId: string - showDefaultConfigPrompt: boolean - }): Promise<{ - datasetName: string - userAction: 'create' | 'none' | 'select' - }> { - const visibility = this.flags.visibility - const dataset = this.flags.dataset - let defaultConfig = this.flags['dataset-default'] - - if (dataset && this.isUnattended()) { - return {datasetName: dataset, userAction: 'none'} - } - - const [datasets, projectFeatures] = await Promise.all([ - listDatasets(opts.projectId), - getProjectFeatures(opts.projectId), - ]) - - if (dataset) { - debug('User has specified dataset through a flag (%s)', dataset) - const existing = datasets.find((ds) => ds.name === dataset) - if (!existing) { - debug('Specified dataset not found, creating it') - await createDataset({ - datasetName: dataset, - forcePublic: defaultConfig, - output: this.output, - projectFeatures, - projectId: opts.projectId, - visibility, - }) - } - - return {datasetName: dataset, userAction: 'none'} - } - - if (datasets.length === 0) { - debug('No datasets found for project, prompting for name') - if (opts.showDefaultConfigPrompt) { - defaultConfig = await promptForDefaultConfig() - } - const name = defaultConfig - ? 'production' - : await promptForDatasetName({ - message: 'Name of your first dataset:', - }) - await createDataset({ - datasetName: name, - forcePublic: defaultConfig, - output: this.output, - projectFeatures, - projectId: opts.projectId, - visibility, - }) - return {datasetName: name, userAction: 'create'} - } - - debug(`User has ${datasets.length} dataset(s) already, showing list of choices`) - const datasetChoices = datasets.map((dataset) => ({value: dataset.name})) - - const selected = await select({ - choices: [{name: 'Create new dataset', value: 'new'}, new Separator(), ...datasetChoices], - message: 'Select dataset to use', - }) - - if (selected === 'new') { - const existingDatasetNames = datasets.map((ds) => ds.name) - debug('User wants to create a new dataset, prompting for name') - if (opts.showDefaultConfigPrompt && !existingDatasetNames.includes('production')) { - defaultConfig = await promptForDefaultConfig() - } - - const newDatasetName = defaultConfig - ? 'production' - : await promptForDatasetName( - { - message: 'Dataset name:', - }, - existingDatasetNames, - ) - await createDataset({ - datasetName: newDatasetName, - forcePublic: defaultConfig, - output: this.output, - projectFeatures, - projectId: opts.projectId, - visibility, - }) - return {datasetName: newDatasetName, userAction: 'create'} - } - - debug(`Returning selected dataset (${selected})`) - return {datasetName: selected, userAction: 'select'} - } - - private async getOrCreateProject({ - newProject, - planId, - user, - }: { - newProject: string | undefined - planId: string | undefined - user: SanityOrgUser - }): Promise<{ - displayName: string - isFirstProject: boolean - projectId: string - userAction: 'create' | 'select' - }> { - const projectId = this.flags.project || newProject - const organizationId = this.flags.organization - let projects - let organizations: ProjectOrganization[] - - try { - const [allProjects, allOrgs] = await Promise.all([listProjects(), listOrganizations()]) - projects = allProjects.toSorted((a, b) => b.createdAt.localeCompare(a.createdAt)) - organizations = allOrgs - } catch (err) { - if (this.isUnattended() && projectId) { - return { - displayName: 'Unknown project', - isFirstProject: false, - projectId, - userAction: 'select', - } - } - this.error(`Failed to communicate with the Sanity API:\n${err.message}`, { - exit: 1, - }) - } - - if (projects.length === 0 && this.isUnattended()) { - this.error('No projects found for current user', {exit: 1}) - } - - if (projectId) { - const project = projects.find((proj) => proj.id === projectId) - if (!project && !this.isUnattended()) { - this.error(`Given project ID (${projectId}) not found, or you do not have access to it`, { - exit: 1, - }) - } - - return { - displayName: project ? project.displayName : 'Unknown project', - isFirstProject: false, - projectId, - userAction: 'select', - } - } - - if (organizationId) { - const organization = - organizations.find((org) => org.id === organizationId) || - organizations.find((org) => org.slug === organizationId) - - if (!organization) { - this.error( - `Given organization ID (${organizationId}) not found, or you do not have access to it`, - {exit: 1}, - ) - } - - if (!(await hasProjectAttachGrant(organizationId))) { - this.error('You lack the necessary permissions to attach a project to this organization', { - exit: 1, - }) - } - } - - // If the user has no projects or is using a coupon (which can only be applied to new projects) - // just ask for project details instead of showing a list of projects - const isUsersFirstProject = projects.length === 0 - if (isUsersFirstProject || this.flags.coupon) { - debug( - isUsersFirstProject - ? 'No projects found for user, prompting for name' - : 'Using a coupon - skipping project selection', - ) - - const newProject = await this.promptForProjectCreation({ - isUsersFirstProject, - organizationId, - organizations, - planId, - user, - }) - - return { - ...newProject, - isFirstProject: isUsersFirstProject, - userAction: 'create', - } - } - - debug(`User has ${projects.length} project(s) already, showing list of choices`) - - const projectChoices = projects.map((project) => ({ - name: `${project.displayName} (${project.id})`, - value: project.id, - })) - - const selected = await select({ - choices: [{name: 'Create new project', value: 'new'}, new Separator(), ...projectChoices], - message: 'Create a new project or select an existing one', - }) - - if (selected === 'new') { - debug('User wants to create a new project, prompting for name') - - const newProject = await this.promptForProjectCreation({ - isUsersFirstProject, - organizationId, - organizations, - planId, - user, - }) - - return { - ...newProject, - isFirstProject: isUsersFirstProject, - userAction: 'create', - } - } - - debug(`Returning selected project (${selected})`) - return { - displayName: projects.find((proj) => proj.id === selected)?.displayName || '', - isFirstProject: isUsersFirstProject, - projectId: selected, - userAction: 'select', - } - } - - private async getPlan(): Promise { - const intendedPlan = this.flags['project-plan'] - const intendedCoupon = this.flags.coupon - - if (intendedCoupon) { - return this.verifyCoupon(intendedCoupon) - } else if (intendedPlan) { - return this.verifyPlan(intendedPlan) - } else { - return undefined - } - } - - private async getPostInitMCPPrompt(editorsNames: EditorName[]): Promise { - return fetchPostInitPrompt(new Intl.ListFormat('en').format(editorsNames)) - } - - private async getProjectDetails({ - isAppTemplate, - newProject, - planId, - showDefaultConfigPrompt, - user, - }: { - isAppTemplate: boolean - newProject: string | undefined - planId: string | undefined - showDefaultConfigPrompt: boolean - user: SanityOrgUser - }): Promise<{ - datasetName: string - displayName: string - isFirstProject: boolean - organizationId?: string - projectId: string - schemaUrl?: string - }> { - if (isAppTemplate) { - // If organization flag is provided, use it directly (skip prompt and API call) - if (this.flags.organization) { - return { - datasetName: '', - displayName: '', - isFirstProject: false, - organizationId: this.flags.organization, - projectId: '', - } - } - - // Interactive mode: fetch orgs and prompt - // Note: unattended mode without --organization is rejected by checkFlagsInUnattendedMode - const organizations = await listOrganizations({ - includeImplicitMemberships: 'true', - includeMembers: 'true', - }) - - const appOrganizationId = await this.promptUserForOrganization({ - isAppTemplate: true, - organizations, - user, - }) - - return { - datasetName: '', - displayName: '', - isFirstProject: false, - organizationId: appOrganizationId, - projectId: '', - } - } - - debug('Prompting user to select or create a project') - const project = await this.getOrCreateProject({newProject, planId, user}) - debug(`Project with name ${project.displayName} selected`) - - // Now let's pick or create a dataset - debug('Prompting user to select or create a dataset') - const dataset = await this.getOrCreateDataset({ - displayName: project.displayName, - projectId: project.projectId, - showDefaultConfigPrompt, - }) - debug(`Dataset with name ${dataset.datasetName} selected`) - - this._trace.log({ - datasetName: dataset.datasetName, - selectedOption: dataset.userAction, - step: 'createOrSelectDataset', - visibility: this.flags.visibility as 'private' | 'public', - }) - - return { - datasetName: dataset.datasetName, - displayName: project.displayName, - isFirstProject: project.isFirstProject, - projectId: project.projectId, - } - } - - private async getProjectOutputPath({ - initFramework, - sluggedName, - workDir, - }: { - initFramework: boolean - sluggedName: string - workDir: string - }): Promise { - const outputPath = this.flags['output-path'] - const specifiedPath = outputPath && path.resolve(outputPath) - if (this.isUnattended() || specifiedPath || this.flags.env || initFramework) { - return specifiedPath || workDir - } - - const inputPath = await input({ - default: path.join(workDir, sluggedName), - message: 'Project output path:', - validate: validateEmptyPath, - }) - - return absolutify(inputPath) - } - - private async initNextJs({ - datasetName, - detectedFramework, - envFilename, - mcpConfigured, - projectId, - workDir, - }: { - datasetName: string - detectedFramework: VersionedFramework | null - envFilename: string - mcpConfigured: EditorName[] - projectId: string - workDir: string - }) { - let useTypeScript = this.flagOrDefault('typescript', true) - if (this.promptForUndefinedFlag(this.flags.typescript)) { - useTypeScript = await promptForTypeScript() - } - this._trace.log({ - selectedOption: useTypeScript ? 'yes' : 'no', - step: 'useTypeScript', - }) - - const fileExtension = useTypeScript ? 'ts' : 'js' - let embeddedStudio = this.flagOrDefault('nextjs-embed-studio', true) - if (this.promptForUndefinedFlag(this.flags['nextjs-embed-studio'])) { - embeddedStudio = await promptForEmbeddedStudio() - } - let hasSrcFolder = false - - if (embeddedStudio) { - // find source path (app or src/app) - const appDir = 'app' - let srcPath = path.join(workDir, appDir) - - if (!existsSync(srcPath)) { - srcPath = path.join(workDir, 'src', appDir) - hasSrcFolder = true - if (!existsSync(srcPath)) { - try { - await mkdir(srcPath, {recursive: true}) - } catch { - debug('Error creating folder %s', srcPath) - } - } - } - - const studioPath = this.isUnattended() ? '/studio' : await promptForStudioPath() - - const embeddedStudioRouteFilePath = path.join( - srcPath, - `${studioPath}/`, - `[[...tool]]/page.${fileExtension}x`, - ) - - // this selects the correct template string based on whether the user is using the app or pages directory and - // replaces the ":configPath:" placeholder in the template with the correct path to the sanity.config.ts file. - // we account for the user-defined embeddedStudioPath (default /studio) is accounted for by creating enough "../" - // relative paths to reach the root level of the project - await this.writeOrOverwrite( - embeddedStudioRouteFilePath, - sanityStudioTemplate.replace( - ':configPath:', - `${'../'.repeat(countNestedFolders(embeddedStudioRouteFilePath.slice(workDir.length)))}sanity.config`, - ), - workDir, - ) - - const sanityConfigPath = path.join(workDir, `sanity.config.${fileExtension}`) - await this.writeOrOverwrite( - sanityConfigPath, - sanityConfigTemplate(hasSrcFolder) - .replace(':route:', embeddedStudioRouteFilePath.slice(workDir.length).replace('src/', '')) - .replace(':basePath:', studioPath), - workDir, - ) - } - - const sanityCliPath = path.join(workDir, `sanity.cli.${fileExtension}`) - await this.writeOrOverwrite(sanityCliPath, sanityCliTemplate, workDir) - - let templateToUse = this.flags.template ?? 'clean' - if (this.promptForUndefinedFlag(this.flags.template)) { - templateToUse = await promptForNextTemplate() - } - - await this.writeSourceFiles({ - fileExtension, - files: sanityFolder(useTypeScript, templateToUse as 'blog' | 'clean'), - folderPath: undefined, - srcFolderPrefix: hasSrcFolder, - workDir, - }) - - let appendEnv = this.flagOrDefault('nextjs-append-env', true) - if (this.promptForUndefinedFlag(this.flags['nextjs-append-env'])) { - appendEnv = await promptForAppendEnv(envFilename) - } - - if (appendEnv) { - await createOrAppendEnvVars({ - envVars: { - DATASET: datasetName, - PROJECT_ID: projectId, - }, - filename: envFilename, - framework: detectedFramework, - log: true, - output: this.output, - outputPath: workDir, - }) - } - - if (embeddedStudio) { - const nextjsLocalDevOrigin = 'http://localhost:3000' - const existingCorsOrigins = await listCorsOrigins(projectId) - const hasExistingCorsOrigin = existingCorsOrigins.some( - (item: {origin: string}) => item.origin === nextjsLocalDevOrigin, - ) - if (!hasExistingCorsOrigin) { - try { - const createCorsRes = await createCorsOrigin({ - allowCredentials: true, - origin: nextjsLocalDevOrigin, - projectId, - }) - - this.log( - createCorsRes.id - ? `Added ${nextjsLocalDevOrigin} to CORS origins` - : `Failed to add ${nextjsLocalDevOrigin} to CORS origins`, - ) - } catch (error) { - debug(`Error creating new CORS Origin ${nextjsLocalDevOrigin}: ${error}`) - this.error(`Failed to add ${nextjsLocalDevOrigin} to CORS origins: ${error}`, {exit: 1}) - } + if (error instanceof InitError) { + this.error(error.message, {exit: error.exitCode}) } + throw error } - - const chosen = await resolvePackageManager({ - interactive: !this.isUnattended(), - output: this.output, - packageManager: this.flags['package-manager'] as PackageManager, - targetDir: workDir, - }) - this._trace.log({selectedOption: chosen, step: 'selectPackageManager'}) - const packages = ['@sanity/vision@4', 'sanity@4', '@sanity/image-url@1', 'styled-components@6'] - if (templateToUse === 'blog') { - packages.push('@sanity/icons') - } - await installNewPackages( - { - packageManager: chosen, - packages, - }, - { - output: this.output, - workDir, - }, - ) - - // will refactor this later - const execOptions: Options = { - cwd: workDir, - encoding: 'utf8', - env: getPartialEnvWithNpmPath(workDir), - stdio: 'inherit', - } - - switch (chosen) { - case 'npm': { - await execa('npm', ['install', '--legacy-peer-deps', 'next-sanity@11'], execOptions) - break - } - case 'pnpm': { - await execa('pnpm', ['install', 'next-sanity@11'], execOptions) - break - } - case 'yarn': { - const peerDeps = await getPeerDependencies('next-sanity@11', workDir) - await installNewPackages( - {packageManager: 'yarn', packages: ['next-sanity@11', ...peerDeps]}, - {output: this.output, workDir}, - ) - break - } - default: { - // bun and manual - do nothing or handle differently - break - } - } - - this.log( - `\n${styleText('green', 'Success!')} Your Sanity configuration files has been added to this project`, - ) - if (mcpConfigured && mcpConfigured.length > 0) { - const message = await this.getPostInitMCPPrompt(mcpConfigured) - this.log(`\n${message}`) - this.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) - this.log( - `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, - ) - } - - await this.writeStagingEnvIfNeeded(workDir) - this.exit(0) - } - - private async promptForDatasetImport(message?: string) { - return confirm({ - default: true, - message: message || 'This template includes a sample dataset, would you like to use it?', - }) - } - - private async promptForProjectCreation({ - isUsersFirstProject, - organizationId, - organizations, - planId, - user, - }: { - isUsersFirstProject: boolean - organizationId: string | undefined - organizations: ProjectOrganization[] - planId: string | undefined - user: SanityOrgUser - }) { - const projectName = await input({ - default: 'My Sanity Project', - message: 'Project name:', - validate(input) { - if (!input || input.trim() === '') { - return 'Project name cannot be empty' - } - - if (input.length > 80) { - return 'Project name cannot be longer than 80 characters' - } - - return true - }, - }) - - const organization = - organizationId || (await this.promptUserForOrganization({organizations, user})) - - const newProject = await createProject({ - displayName: projectName, - metadata: {coupon: this.flags.coupon}, - organizationId: organization, - subscription: planId ? {planId} : undefined, - }) - - return { - ...newProject, - isFirstProject: isUsersFirstProject, - userAction: 'create', - } - } - - private async promptForTemplate() { - const template = this.flags.template - - const defaultTemplate = this.isUnattended() || template ? template || 'clean' : null - if (defaultTemplate) { - return defaultTemplate - } - - return select({ - choices: [ - { - name: 'Clean project with no predefined schema types', - value: 'clean', - }, - { - name: 'Blog (schema)', - value: 'blog', - }, - { - name: 'E-commerce (Shopify)', - value: 'shopify', - }, - { - name: 'Movie project (schema + sample data)', - value: 'moviedb', - }, - ], - message: 'Select project template', - }) - } - - private promptForUndefinedFlag(flag: unknown) { - return !this.isUnattended() && flag === undefined - } - - private async promptUserForNewOrganization( - user: SanityOrgUser, - ): Promise { - const name = await promptForOrganizationName(user) - - const spin = spinner('Creating organization').start() - const organization = await createOrganization(name) - spin.succeed() - - return organization - } - - private async promptUserForOrganization({ - isAppTemplate = false, - organizations, - user, - }: { - isAppTemplate?: boolean - organizations: ProjectOrganization[] - user: SanityOrgUser - }) { - // If the user has no organizations, prompt them to create one with the same name as - // their user, but allow them to customize it if they want - if (organizations.length === 0) { - const newOrganization = await this.promptUserForNewOrganization(user) - return newOrganization.id - } - - let organizationChoices: OrganizationChoices - let defaultOrganizationId: string | undefined - - if (isAppTemplate) { - // For app templates, all organizations are valid — no attach grant check needed - organizationChoices = getOrganizationChoices(organizations) - defaultOrganizationId = - organizations.length === 1 - ? organizations[0].id - : findOrganizationByUserName(organizations, user) - } else { - // For studio projects, check which organizations the user can attach projects to - debug(`User has ${organizations.length} organization(s), checking attach access`) - const withGrantInfo = await getOrganizationsWithAttachGrantInfo(organizations) - const withAttach = withGrantInfo.filter(({hasAttachGrant}) => hasAttachGrant) - - debug('User has attach access to %d organizations.', withAttach.length) - organizationChoices = getOrganizationChoices(withGrantInfo) - defaultOrganizationId = - withAttach.length === 1 - ? withAttach[0].organization.id - : findOrganizationByUserName(organizations, user) - } - - const chosenOrg = await select({ - choices: organizationChoices, - default: defaultOrganizationId || undefined, - message: 'Select organization:', - }) - - if (chosenOrg === '-new-') { - const newOrganization = await this.promptUserForNewOrganization(user) - return newOrganization.id - } - - return chosenOrg || undefined - } - - private async verifyCoupon(intendedCoupon: string): Promise { - try { - const planId = await getPlanIdFromCoupon(intendedCoupon) - this.log(`Coupon "${intendedCoupon}" validated!\n`) - return planId - } catch (err: unknown) { - if (!isHttpError(err) || err.statusCode !== 404) { - const message = err instanceof Error ? err.message : `${err}` - this.error(`Unable to validate coupon, please try again later:\n\n${message}`, {exit: 1}) - } - - const useDefaultPlan = - this.isUnattended() || - (await confirm({ - default: true, - message: `Coupon "${intendedCoupon}" is not available, use default plan instead?`, - })) - - if (this.isUnattended()) { - this.warn(`Coupon "${intendedCoupon}" is not available - using default plan`) - } - - this._trace.log({ - coupon: intendedCoupon, - selectedOption: useDefaultPlan ? 'yes' : 'no', - step: 'useDefaultPlanCoupon', - }) - - if (useDefaultPlan) { - this.log('Using default plan.') - } else { - this.error(`Coupon "${intendedCoupon}" does not exist`, {exit: 1}) - } - } - } - - private async verifyPlan(intendedPlan: string): Promise { - try { - const planId = await getPlanId(intendedPlan) - return planId - } catch (err: unknown) { - if (!isHttpError(err) || err.statusCode !== 404) { - const message = err instanceof Error ? err.message : `${err}` - this.error(`Unable to validate plan, please try again later:\n\n${message}`, {exit: 1}) - } - - const useDefaultPlan = - this.isUnattended() || - (await confirm({ - default: true, - message: `Project plan "${intendedPlan}" does not exist, use default plan instead?`, - })) - - if (this.isUnattended()) { - this.warn(`Project plan "${intendedPlan}" does not exist - using default plan`) - } - - this._trace.log({ - planId: intendedPlan, - selectedOption: useDefaultPlan ? 'yes' : 'no', - step: 'useDefaultPlanId', - }) - - if (useDefaultPlan) { - this.log('Using default plan.') - } else { - this.error(`Plan id "${intendedPlan}" does not exist`, {exit: 1}) - } - } - } - - private async writeOrOverwrite(filePath: string, content: string, workDir: string) { - if (existsSync(filePath)) { - let overwrite = this.flagOrDefault('overwrite-files', false) - if (this.promptForUndefinedFlag(this.flags['overwrite-files'])) { - overwrite = await confirm({ - default: false, - message: `File ${styleText( - 'yellow', - filePath.replace(workDir, ''), - )} already exists. Do you want to overwrite it?`, - }) - } - - if (!overwrite) { - return - } - } - - // make folder if not exists - const folderPath = path.dirname(filePath) - - try { - await mkdir(folderPath, {recursive: true}) - } catch { - debug('Error creating folder %s', folderPath) - } - - await writeFile(filePath, content, { - encoding: 'utf8', - }) - } - - // write sanity folder files - private async writeSourceFiles({ - fileExtension, - files, - folderPath, - srcFolderPrefix, - workDir, - }: { - fileExtension: string - files: Record | string> - folderPath?: string - srcFolderPrefix?: boolean - workDir: string - }) { - for (const [filePath, content] of Object.entries(files)) { - // check if file ends with full stop to indicate it's file and not directory (this only works with our template tree structure) - if (filePath.includes('.') && typeof content === 'string') { - await this.writeOrOverwrite( - path.join( - workDir, - srcFolderPrefix ? 'src' : '', - 'sanity', - folderPath || '', - `${filePath}${fileExtension}`, - ), - content, - workDir, - ) - } else { - await mkdir(path.join(workDir, srcFolderPrefix ? 'src' : '', 'sanity', filePath), { - recursive: true, - }) - if (typeof content === 'object') { - await this.writeSourceFiles({ - fileExtension, - files: content, - folderPath: filePath, - srcFolderPrefix, - workDir, - }) - } - } - } - } - - /** - * When running in a non-production Sanity environment (e.g. staging), write the - * `SANITY_INTERNAL_ENV` variable to a `.env` file in the output directory so that - * the bootstrapped project continues to target the same environment. - */ - private async writeStagingEnvIfNeeded(outputPath: string) { - const sanityEnv = getSanityEnv() - if (sanityEnv === 'production') return - - await createOrAppendEnvVars({ - envVars: {INTERNAL_ENV: sanityEnv}, - filename: '.env', - framework: null, - log: false, - output: this.output, - outputPath, - }) } } diff --git a/packages/@sanity/cli/src/telemetry/init.telemetry.ts b/packages/@sanity/cli/src/telemetry/init.telemetry.ts index e41a2bfe4..54488f36e 100644 --- a/packages/@sanity/cli/src/telemetry/init.telemetry.ts +++ b/packages/@sanity/cli/src/telemetry/init.telemetry.ts @@ -31,7 +31,7 @@ interface CreateOrSelectDatasetStep { datasetName: string selectedOption: 'create' | 'none' | 'select' step: 'createOrSelectDataset' - visibility: 'private' | 'public' + visibility?: 'private' | 'public' } interface UseDefaultPlanCoupon { diff --git a/packages/@sanity/cli/test/__fixtures__/exec-get-user-token.ts b/packages/@sanity/cli/test/__fixtures__/exec-get-user-token.ts index e8b40e56a..42070dcdd 100644 --- a/packages/@sanity/cli/test/__fixtures__/exec-get-user-token.ts +++ b/packages/@sanity/cli/test/__fixtures__/exec-get-user-token.ts @@ -12,8 +12,8 @@ try { console.log( JSON.stringify({ hasToken: typeof config.token === 'string' && config.token.length > 0, - token: config.token, success: true, + token: config.token, }), ) } catch (error) { From d7c72fb6bc7b4b89b6083dd855f2557aca89a8ee Mon Sep 17 00:00:00 2001 From: Espen Hovlandsdal Date: Mon, 23 Mar 2026 12:37:15 -0700 Subject: [PATCH 02/27] refactor(init): split init flows into separate functions (#753) --- .../cli/src/actions/init/initAction.ts | 643 ++---------------- .../@sanity/cli/src/actions/init/initApp.ts | 102 +++ .../cli/src/actions/init/initHelpers.ts | 43 ++ .../cli/src/actions/init/initNextJs.ts | 353 ++++++++++ .../cli/src/actions/init/initStudio.ts | 174 +++++ .../cli/src/actions/init/scaffoldTemplate.ts | 187 +++++ .../init/init.get-project-details.test.ts | 2 - .../cli/src/telemetry/init.telemetry.ts | 1 + 8 files changed, 899 insertions(+), 606 deletions(-) create mode 100644 packages/@sanity/cli/src/actions/init/initApp.ts create mode 100644 packages/@sanity/cli/src/actions/init/initHelpers.ts create mode 100644 packages/@sanity/cli/src/actions/init/initNextJs.ts create mode 100644 packages/@sanity/cli/src/actions/init/initStudio.ts create mode 100644 packages/@sanity/cli/src/actions/init/scaffoldTemplate.ts diff --git a/packages/@sanity/cli/src/actions/init/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts index 189b9342d..b22f3de37 100644 --- a/packages/@sanity/cli/src/actions/init/initAction.ts +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -1,33 +1,17 @@ -import {existsSync} from 'node:fs' -import {mkdir, writeFile} from 'node:fs/promises' import path from 'node:path' import {styleText} from 'node:util' -import { - getCliToken, - type SanityOrgUser, - subdebug, - type TelemetryUserProperties, -} from '@sanity/cli-core' +import {type SanityOrgUser, subdebug, type TelemetryUserProperties} from '@sanity/cli-core' import {confirm, input, logSymbols, select, Separator, spinner} from '@sanity/cli-core/ux' import {type DatasetAclMode, isHttpError} from '@sanity/client' import {type TelemetryTrace} from '@sanity/telemetry' import {type Framework, frameworks} from '@vercel/frameworks' -import {execa, type Options} from 'execa' import deburr from 'lodash-es/deburr.js' -import { - promptForAppendEnv, - promptForConfigFiles, - promptForEmbeddedStudio, - promptForNextTemplate, - promptForStudioPath, -} from '../../prompts/init/nextjs.js' -import {promptForTypeScript} from '../../prompts/init/promptForTypescript.js' +import {promptForConfigFiles} from '../../prompts/init/nextjs.js' import {promptForDatasetName} from '../../prompts/promptForDatasetName.js' import {promptForDefaultConfig} from '../../prompts/promptForDefaultConfig.js' import {promptForOrganizationName} from '../../prompts/promptForOrganizationName.js' -import {createCorsOrigin, listCorsOrigins} from '../../services/cors.js' import {createDataset as createDatasetService, listDatasets} from '../../services/datasets.js' import {getProjectFeatures} from '../../services/getProjectFeatures.js' import { @@ -37,66 +21,35 @@ import { type ProjectOrganization, } from '../../services/organizations.js' import {getPlanId, getPlanIdFromCoupon} from '../../services/plans.js' -import {createProject, listProjects, updateProjectInitializedAt} from '../../services/projects.js' +import {createProject, listProjects} from '../../services/projects.js' import {getCliUser} from '../../services/user.js' import {CLIInitStepCompleted, type InitStepResult} from '../../telemetry/init.telemetry.js' import {detectFrameworkRecord} from '../../util/detectFramework.js' import {absolutify, validateEmptyPath} from '../../util/fsUtils.js' import {getProjectDefaults} from '../../util/getProjectDefaults.js' -import {getSanityEnv} from '../../util/getSanityEnv.js' -import {getPeerDependencies} from '../../util/packageManager/getPeerDependencies.js' -import { - installDeclaredPackages, - installNewPackages, -} from '../../util/packageManager/installPackages.js' -import { - getPartialEnvWithNpmPath, - type PackageManager, -} from '../../util/packageManager/packageManagerChoice.js' import {validateSession} from '../auth/ensureAuthenticated.js' import {getProviderName} from '../auth/getProviderName.js' import {login} from '../auth/login/login.js' import {createDataset} from '../dataset/create.js' -import {type EditorName} from '../mcp/editorConfigs.js' import {setupMCP} from '../mcp/setupMCP.js' import {findOrganizationByUserName} from '../organizations/findOrganizationByUserName.js' import {getOrganizationChoices} from '../organizations/getOrganizationChoices.js' import {getOrganizationsWithAttachGrantInfo} from '../organizations/getOrganizationsWithAttachGrantInfo.js' import {hasProjectAttachGrant} from '../organizations/hasProjectAttachGrant.js' import {type OrganizationChoices} from '../organizations/types.js' -import {bootstrapTemplate} from './bootstrapTemplate.js' import {checkNextJsReactCompatibility} from './checkNextJsReactCompatibility.js' -import {countNestedFolders} from './countNestedFolders.js' import {determineAppTemplate} from './determineAppTemplate.js' import {createOrAppendEnvVars} from './env/createOrAppendEnvVars.js' -import {fetchPostInitPrompt} from './fetchPostInitPrompt.js' -import {tryGitInit} from './git.js' +import {initApp} from './initApp.js' import {InitError} from './initError.js' +import {flagOrDefault, shouldPrompt, writeStagingEnvIfNeeded} from './initHelpers.js' +import {initNextJs} from './initNextJs.js' +import {initStudio} from './initStudio.js' import {checkIsRemoteTemplate, getGitHubRepoInfo, type RepoInfo} from './remoteTemplate.js' -import {resolvePackageManager} from './resolvePackageManager.js' -import templates from './templates/index.js' -import { - sanityCliTemplate, - sanityConfigTemplate, - sanityFolder, - sanityStudioTemplate, -} from './templates/nextjs/index.js' -import {type InitContext, type InitOptions, type VersionedFramework} from './types.js' +import {type InitContext, type InitOptions} from './types.js' const debug = subdebug('init') -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function shouldPrompt(unattended: boolean, flagValue: unknown): boolean { - return !unattended && flagValue === undefined -} - -function flagOrDefault(flagValue: boolean | undefined, defaultValue: boolean): boolean { - return typeof flagValue === 'boolean' ? flagValue : defaultValue -} - // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- @@ -313,7 +266,7 @@ export async function initAction(options: InitOptions, context: InitContext): Pr } if (initNext) { - await doInitNextJs({ + await initNextJs({ datasetName, detectedFramework, envFilename, @@ -346,182 +299,36 @@ export async function initAction(options: InitOptions, context: InitContext): Pr return } - // Prompt for template to use - const templateName = await promptForTemplate(options) - trace.log({ - selectedOption: templateName, - step: 'selectProjectTemplate', - }) - const template = templates[templateName] - if (!remoteTemplateInfo && !template) { - throw new InitError(`Template "${templateName}" not found`, 1) - } - - let useTypeScript = options.typescript - if (!remoteTemplateInfo && template && template.typescriptOnly === true) { - useTypeScript = true - } else if (shouldPrompt(options.unattended, options.typescript)) { - useTypeScript = await promptForTypeScript() - trace.log({ - selectedOption: useTypeScript ? 'yes' : 'no', - step: 'useTypeScript', - }) - } - - // If the template has a sample dataset, prompt the user whether or not we should import it - const importDatasetFlag = options.importDataset - const shouldImport = - template?.datasetUrl && - (importDatasetFlag ?? - (!options.unattended && (await promptForDatasetImport(template.importPrompt)))) - - trace.log({ - selectedOption: shouldImport ? 'yes' : 'no', - step: 'importTemplateDataset', - }) - - try { - await updateProjectInitializedAt(projectId) - } catch (err) { - // Non-critical update - debug('Failed to update cliInitializedAt metadata', err) - } - - await bootstrapTemplate({ - autoUpdates: options.autoUpdates, - bearerToken: options.templateToken, - dataset: datasetName, - organizationId, - output, - outputPath, - overwriteFiles: options.overwriteFiles, - packageName: sluggedName, - projectId, - projectName: displayName || defaults.projectName, - remoteTemplateInfo, - templateName, - useTypeScript, - }) - - const pkgManager = await resolvePackageManager({ - interactive: !options.unattended, - output, - packageManager: options.packageManager as PackageManager, - targetDir: outputPath, - }) - - trace.log({ - selectedOption: pkgManager, - step: 'selectPackageManager', - }) - - // Now for the slow part... installing dependencies - await installDeclaredPackages(outputPath, pkgManager, { - output, - workDir, - }) - - const useGit = options.git === undefined || Boolean(options.git) - const commitMessage = options.git - await writeStagingEnvIfNeeded(output, outputPath) - - // Try initializing a git repository - if (useGit) { - tryGitInit(outputPath, typeof commitMessage === 'string' ? commitMessage : undefined) - } - - // Prompt for dataset import (if a dataset is defined) - if (shouldImport && template?.datasetUrl) { - const token = await getCliToken() - if (!token) { - throw new InitError('Authentication required to import dataset', 1) - } - // Dynamic import to keep initAction decoupled from oclif commands. - // TODO: consider replacing with `npx sanity dataset import` to fully decouple. - // eslint-disable-next-line no-restricted-syntax - const {ImportDatasetCommand} = await import('../../commands/datasets/import.js') - await ImportDatasetCommand.run( - [template.datasetUrl, '--project-id', projectId, '--dataset', datasetName, '--token', token], - { - root: outputPath, - }, - ) - - output.log('') - output.log('If you want to delete the imported data, use') - output.log(` ${styleText('cyan', `npx sanity dataset delete ${datasetName}`)}`) - output.log('and create a new clean dataset with') - output.log(` ${styleText('cyan', `npx sanity dataset create `)}\n`) - } - - const devCommandMap: Record = { - bun: 'bun dev', - manual: 'npm run dev', - npm: 'npm run dev', - pnpm: 'pnpm dev', - yarn: 'yarn dev', - } - const devCommand = devCommandMap[pkgManager] - - const isCurrentDir = outputPath === workDir - const goToProjectDir = `\n(${styleText('cyan', `cd ${outputPath}`)} to navigate to your new project directory)` - - if (isAppTemplate) { - //output for custom apps here - output.log( - `${logSymbols.success} ${styleText(['green', 'bold'], 'Success!')} Your custom app has been scaffolded.`, - ) - if (!isCurrentDir) output.log(goToProjectDir) - output.log( - `\n${styleText('bold', 'Next')}, configure the project(s) and dataset(s) your app should work with.`, - ) - output.log('\nGet started in `src/App.tsx`, or refer to our documentation for a walkthrough:') - output.log( - styleText(['blue', 'underline'], 'https://www.sanity.io/docs/app-sdk/sdk-configuration'), - ) - if (mcpConfigured && mcpConfigured.length > 0) { - const message = await getPostInitMCPPrompt(mcpConfigured) - output.log(`\n${message}`) - output.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) - output.log( - `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, - ) - } - output.log('\n') - output.log(`Other helpful commands:`) - output.log(`npx sanity docs browse to open the documentation in a browser`) - output.log(`npx sanity dev to start the development server for your app`) - output.log(`npx sanity deploy to deploy your app`) - } else { - //output for Studios here - output.log(`\u2705 ${styleText(['green', 'bold'], 'Success!')} Your Studio has been created.`) - if (!isCurrentDir) output.log(goToProjectDir) - output.log( - `\nGet started by running ${styleText('cyan', devCommand)} to launch your Studio's development server`, - ) - if (mcpConfigured && mcpConfigured.length > 0) { - const message = await getPostInitMCPPrompt(mcpConfigured) - output.log(`\n${message}`) - output.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) - output.log( - `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, - ) - } - output.log('\n') - output.log(`Other helpful commands:`) - output.log(`npx sanity docs browse to open the documentation in a browser`) - output.log(`npx sanity manage to open the project settings in a browser`) - output.log(`npx sanity help to explore the CLI manual`) - } - - if (isFirstProject) { - trace.log({selectedOption: 'yes', step: 'sendCommunityInvite'}) - - const DISCORD_INVITE_LINK = 'https://www.sanity.io/community/join' - - output.log(`\nJoin the Sanity community: ${styleText('cyan', DISCORD_INVITE_LINK)}`) - output.log('We look forward to seeing you there!\n') - } + // Studio/app template scaffolding + await (isAppTemplate + ? initApp({ + defaults, + mcpConfigured, + options, + organizationId, + output, + outputPath, + remoteTemplateInfo, + sluggedName, + trace, + workDir, + }) + : initStudio({ + datasetName, + defaults, + displayName, + isFirstProject, + mcpConfigured, + options, + organizationId, + output, + outputPath, + projectId, + remoteTemplateInfo, + sluggedName, + trace, + workDir, + })) trace.complete() } @@ -944,10 +751,6 @@ async function getPlan( } } -async function getPostInitMCPPrompt(editorsNames: EditorName[]): Promise { - return fetchPostInitPrompt(new Intl.ListFormat('en').format(editorsNames)) -} - async function getProjectDetails({ coupon, dataset, @@ -1090,235 +893,6 @@ async function getProjectOutputPath({ return absolutify(inputPath) } -async function doInitNextJs({ - datasetName, - detectedFramework, - envFilename, - mcpConfigured, - options, - output, - projectId, - trace, - workDir, -}: { - datasetName: string - detectedFramework: VersionedFramework | null - envFilename: string - mcpConfigured: EditorName[] - options: InitOptions - output: InitContext['output'] - projectId: string - trace: TelemetryTrace - workDir: string -}): Promise { - let useTypeScript = flagOrDefault(options.typescript, true) - if (shouldPrompt(options.unattended, options.typescript)) { - useTypeScript = await promptForTypeScript() - } - trace.log({ - selectedOption: useTypeScript ? 'yes' : 'no', - step: 'useTypeScript', - }) - - const fileExtension = useTypeScript ? 'ts' : 'js' - let embeddedStudio = flagOrDefault(options.nextjsEmbedStudio, true) - if (shouldPrompt(options.unattended, options.nextjsEmbedStudio)) { - embeddedStudio = await promptForEmbeddedStudio() - } - let hasSrcFolder = false - - if (embeddedStudio) { - // find source path (app or src/app) - const appDir = 'app' - let srcPath = path.join(workDir, appDir) - - if (!existsSync(srcPath)) { - srcPath = path.join(workDir, 'src', appDir) - hasSrcFolder = true - if (!existsSync(srcPath)) { - try { - await mkdir(srcPath, {recursive: true}) - } catch { - debug('Error creating folder %s', srcPath) - } - } - } - - const studioPath = options.unattended ? '/studio' : await promptForStudioPath() - - const embeddedStudioRouteFilePath = path.join( - srcPath, - `${studioPath}/`, - `[[...tool]]/page.${fileExtension}x`, - ) - - // this selects the correct template string based on whether the user is using the app or pages directory and - // replaces the ":configPath:" placeholder in the template with the correct path to the sanity.config.ts file. - // we account for the user-defined embeddedStudioPath (default /studio) is accounted for by creating enough "../" - // relative paths to reach the root level of the project - await writeOrOverwrite( - embeddedStudioRouteFilePath, - sanityStudioTemplate.replace( - ':configPath:', - `${'../'.repeat(countNestedFolders(embeddedStudioRouteFilePath.slice(workDir.length)))}sanity.config`, - ), - workDir, - options, - ) - - const sanityConfigPath = path.join(workDir, `sanity.config.${fileExtension}`) - await writeOrOverwrite( - sanityConfigPath, - sanityConfigTemplate(hasSrcFolder) - .replace(':route:', embeddedStudioRouteFilePath.slice(workDir.length).replace('src/', '')) - .replace(':basePath:', studioPath), - workDir, - options, - ) - } - - const sanityCliPath = path.join(workDir, `sanity.cli.${fileExtension}`) - await writeOrOverwrite(sanityCliPath, sanityCliTemplate, workDir, options) - - let templateToUse = options.template ?? 'clean' - if (shouldPrompt(options.unattended, options.template)) { - templateToUse = await promptForNextTemplate() - } - - await writeSourceFiles({ - fileExtension, - files: sanityFolder(useTypeScript, templateToUse as 'blog' | 'clean'), - folderPath: undefined, - options, - srcFolderPrefix: hasSrcFolder, - workDir, - }) - - let appendEnv = flagOrDefault(options.nextjsAppendEnv, true) - if (shouldPrompt(options.unattended, options.nextjsAppendEnv)) { - appendEnv = await promptForAppendEnv(envFilename) - } - - if (appendEnv) { - await createOrAppendEnvVars({ - envVars: { - DATASET: datasetName, - PROJECT_ID: projectId, - }, - filename: envFilename, - framework: detectedFramework, - log: true, - output, - outputPath: workDir, - }) - } - - if (embeddedStudio) { - const nextjsLocalDevOrigin = 'http://localhost:3000' - const existingCorsOrigins = await listCorsOrigins(projectId) - const hasExistingCorsOrigin = existingCorsOrigins.some( - (item: {origin: string}) => item.origin === nextjsLocalDevOrigin, - ) - if (!hasExistingCorsOrigin) { - try { - const createCorsRes = await createCorsOrigin({ - allowCredentials: true, - origin: nextjsLocalDevOrigin, - projectId, - }) - - output.log( - createCorsRes.id - ? `Added ${nextjsLocalDevOrigin} to CORS origins` - : `Failed to add ${nextjsLocalDevOrigin} to CORS origins`, - ) - } catch (error) { - debug(`Error creating new CORS Origin ${nextjsLocalDevOrigin}: ${error}`) - const message = error instanceof Error ? error.message : String(error) - throw new InitError(`Failed to add ${nextjsLocalDevOrigin} to CORS origins: ${message}`, 1) - } - } - } - - const chosen = await resolvePackageManager({ - interactive: !options.unattended, - output, - packageManager: options.packageManager as PackageManager, - targetDir: workDir, - }) - trace.log({selectedOption: chosen, step: 'selectPackageManager'}) - const packages = ['@sanity/vision@4', 'sanity@4', '@sanity/image-url@1', 'styled-components@6'] - if (templateToUse === 'blog') { - packages.push('@sanity/icons') - } - await installNewPackages( - { - packageManager: chosen, - packages, - }, - { - output, - workDir, - }, - ) - - // will refactor this later - const execOptions: Options = { - cwd: workDir, - encoding: 'utf8', - env: getPartialEnvWithNpmPath(workDir), - stdio: 'inherit', - } - - switch (chosen) { - case 'npm': { - await execa('npm', ['install', '--legacy-peer-deps', 'next-sanity@11'], execOptions) - break - } - case 'pnpm': { - await execa('pnpm', ['install', 'next-sanity@11'], execOptions) - break - } - case 'yarn': { - const peerDeps = await getPeerDependencies('next-sanity@11', workDir) - await installNewPackages( - {packageManager: 'yarn', packages: ['next-sanity@11', ...peerDeps]}, - {output, workDir}, - ) - break - } - case 'bun': { - await execa('bun', ['add', 'next-sanity@11'], execOptions) - break - } - default: { - // manual - do nothing - break - } - } - - output.log( - `\n${styleText('green', 'Success!')} Your Sanity configuration files has been added to this project`, - ) - if (mcpConfigured && mcpConfigured.length > 0) { - const message = await getPostInitMCPPrompt(mcpConfigured) - output.log(`\n${message}`) - output.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) - output.log( - `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, - ) - } - - await writeStagingEnvIfNeeded(output, workDir) -} - -async function promptForDatasetImport(message?: string): Promise { - return confirm({ - default: true, - message: message || 'This template includes a sample dataset, would you like to use it?', - }) -} - async function promptForProjectCreation({ coupon, isUsersFirstProject, @@ -1365,37 +939,6 @@ async function promptForProjectCreation({ } } -async function promptForTemplate(options: InitOptions): Promise { - const template = options.template - - const defaultTemplate = options.unattended || template ? template || 'clean' : null - if (defaultTemplate) { - return defaultTemplate - } - - return select({ - choices: [ - { - name: 'Clean project with no predefined schema types', - value: 'clean', - }, - { - name: 'Blog (schema)', - value: 'blog', - }, - { - name: 'E-commerce (Shopify)', - value: 'shopify', - }, - { - name: 'Movie project (schema + sample data)', - value: 'moviedb', - }, - ], - message: 'Select project template', - }) -} - async function promptUserForNewOrganization( user: SanityOrgUser, ): Promise { @@ -1544,111 +1087,3 @@ async function verifyPlan( throw new InitError(`Plan id "${intendedPlan}" does not exist`, 1) } } - -async function writeOrOverwrite( - filePath: string, - content: string, - workDir: string, - options: InitOptions, -): Promise { - if (existsSync(filePath)) { - let overwrite = flagOrDefault(options.overwriteFiles, false) - if (shouldPrompt(options.unattended, options.overwriteFiles)) { - overwrite = await confirm({ - default: false, - message: `File ${styleText( - 'yellow', - filePath.replace(workDir, ''), - )} already exists. Do you want to overwrite it?`, - }) - } - - if (!overwrite) { - return - } - } - - // make folder if not exists - const folderPath = path.dirname(filePath) - - try { - await mkdir(folderPath, {recursive: true}) - } catch { - debug('Error creating folder %s', folderPath) - } - - await writeFile(filePath, content, { - encoding: 'utf8', - }) -} - -// write sanity folder files -async function writeSourceFiles({ - fileExtension, - files, - folderPath, - options, - srcFolderPrefix, - workDir, -}: { - fileExtension: string - files: Record | string> - folderPath?: string - options: InitOptions - srcFolderPrefix?: boolean - workDir: string -}): Promise { - for (const [filePath, content] of Object.entries(files)) { - // check if file ends with full stop to indicate it's file and not directory (this only works with our template tree structure) - if (filePath.includes('.') && typeof content === 'string') { - await writeOrOverwrite( - path.join( - workDir, - srcFolderPrefix ? 'src' : '', - 'sanity', - folderPath || '', - `${filePath}${fileExtension}`, - ), - content, - workDir, - options, - ) - } else { - await mkdir(path.join(workDir, srcFolderPrefix ? 'src' : '', 'sanity', filePath), { - recursive: true, - }) - if (typeof content === 'object') { - await writeSourceFiles({ - fileExtension, - files: content, - folderPath: filePath, - options, - srcFolderPrefix, - workDir, - }) - } - } - } -} - -/** - * When running in a non-production Sanity environment (e.g. staging), write the - * `SANITY_INTERNAL_ENV` variable to a `.env` file in the output directory so that - * the bootstrapped project continues to target the same environment. - */ -async function writeStagingEnvIfNeeded( - output: InitContext['output'], - outputPath: string, -): Promise { - const sanityEnv = getSanityEnv() - if (sanityEnv === 'production') return - - await createOrAppendEnvVars({ - envVars: {INTERNAL_ENV: sanityEnv}, - filename: '.env', - framework: null, - log: false, - output, - outputPath, - }) -} diff --git a/packages/@sanity/cli/src/actions/init/initApp.ts b/packages/@sanity/cli/src/actions/init/initApp.ts new file mode 100644 index 000000000..ef75815bb --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/initApp.ts @@ -0,0 +1,102 @@ +import {styleText} from 'node:util' + +import {subdebug, type TelemetryUserProperties} from '@sanity/cli-core' +import {logSymbols} from '@sanity/cli-core/ux' +import {type TelemetryTrace} from '@sanity/telemetry' + +import {type InitStepResult} from '../../telemetry/init.telemetry.js' +import {type EditorName} from '../mcp/editorConfigs.js' +import {InitError} from './initError.js' +import {getPostInitMCPPrompt} from './initHelpers.js' +import {type RepoInfo} from './remoteTemplate.js' +import {scaffoldAndInstall, selectTemplate} from './scaffoldTemplate.js' +import {type InitContext, type InitOptions} from './types.js' + +const debug = subdebug('init:app') + +interface InitAppParams { + defaults: {projectName: string} + mcpConfigured: EditorName[] + options: InitOptions + organizationId: string | undefined + output: InitContext['output'] + outputPath: string + remoteTemplateInfo: RepoInfo | undefined + sluggedName: string + trace: TelemetryTrace + workDir: string +} + +export async function initApp({ + defaults, + mcpConfigured, + options, + organizationId, + output, + outputPath, + remoteTemplateInfo, + sluggedName, + trace, + workDir, +}: InitAppParams): Promise { + debug('Scaffolding app template') + + // Prompt for template and TypeScript + const {template, templateName, useTypeScript} = await selectTemplate( + options, + remoteTemplateInfo, + trace, + ) + if (!remoteTemplateInfo && !template) { + throw new InitError(`Template "${templateName}" not found`, 1) + } + + // Bootstrap, install deps, git init + const {pkgManager} = await scaffoldAndInstall({ + datasetName: '', + defaults, + displayName: '', + options, + organizationId, + output, + outputPath, + projectId: '', + remoteTemplateInfo, + sluggedName, + templateName, + trace, + useTypeScript, + workDir, + }) + + // App-specific success messages + const isCurrentDir = outputPath === workDir + const goToProjectDir = `\n(${styleText('cyan', `cd ${outputPath}`)} to navigate to your new project directory)` + + output.log( + `${logSymbols.success} ${styleText(['green', 'bold'], 'Success!')} Your custom app has been scaffolded.`, + ) + if (!isCurrentDir) output.log(goToProjectDir) + output.log( + `\n${styleText('bold', 'Next')}, configure the project(s) and dataset(s) your app should work with.`, + ) + output.log('\nGet started in `src/App.tsx`, or refer to our documentation for a walkthrough:') + output.log( + styleText(['blue', 'underline'], 'https://www.sanity.io/docs/app-sdk/sdk-configuration'), + ) + if (mcpConfigured && mcpConfigured.length > 0) { + const message = await getPostInitMCPPrompt(mcpConfigured) + output.log(`\n${message}`) + output.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) + output.log( + `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, + ) + } + output.log('\n') + output.log(`Other helpful commands:`) + output.log(`npx sanity docs browse to open the documentation in a browser`) + output.log(`npx sanity dev to start the development server for your app`) + output.log(`npx sanity deploy to deploy your app`) + + debug('App scaffolding complete (pkgManager=%s)', pkgManager) +} diff --git a/packages/@sanity/cli/src/actions/init/initHelpers.ts b/packages/@sanity/cli/src/actions/init/initHelpers.ts new file mode 100644 index 000000000..95bbee559 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/initHelpers.ts @@ -0,0 +1,43 @@ +import {getSanityEnv} from '../../util/getSanityEnv.js' +import {type EditorName} from '../mcp/editorConfigs.js' +import {createOrAppendEnvVars} from './env/createOrAppendEnvVars.js' +import {fetchPostInitPrompt} from './fetchPostInitPrompt.js' +import {type InitContext} from './types.js' + +// --------------------------------------------------------------------------- +// Helpers shared across init flows (orchestrator, Next.js, studio, etc.) +// --------------------------------------------------------------------------- + +export function shouldPrompt(unattended: boolean, flagValue: unknown): boolean { + return !unattended && flagValue === undefined +} + +export function flagOrDefault(flagValue: boolean | undefined, defaultValue: boolean): boolean { + return typeof flagValue === 'boolean' ? flagValue : defaultValue +} + +export async function getPostInitMCPPrompt(editorsNames: EditorName[]): Promise { + return fetchPostInitPrompt(new Intl.ListFormat('en').format(editorsNames)) +} + +/** + * When running in a non-production Sanity environment (e.g. staging), write the + * `SANITY_INTERNAL_ENV` variable to a `.env` file in the output directory so that + * the bootstrapped project continues to target the same environment. + */ +export async function writeStagingEnvIfNeeded( + output: InitContext['output'], + outputPath: string, +): Promise { + const sanityEnv = getSanityEnv() + if (sanityEnv === 'production') return + + await createOrAppendEnvVars({ + envVars: {INTERNAL_ENV: sanityEnv}, + filename: '.env', + framework: null, + log: false, + output, + outputPath, + }) +} diff --git a/packages/@sanity/cli/src/actions/init/initNextJs.ts b/packages/@sanity/cli/src/actions/init/initNextJs.ts new file mode 100644 index 000000000..2612d0ab7 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/initNextJs.ts @@ -0,0 +1,353 @@ +import {existsSync} from 'node:fs' +import {mkdir, writeFile} from 'node:fs/promises' +import path from 'node:path' +import {styleText} from 'node:util' + +import {subdebug, type TelemetryUserProperties} from '@sanity/cli-core' +import {confirm} from '@sanity/cli-core/ux' +import {type TelemetryTrace} from '@sanity/telemetry' +import {execa, type Options} from 'execa' + +import { + promptForAppendEnv, + promptForEmbeddedStudio, + promptForNextTemplate, + promptForStudioPath, +} from '../../prompts/init/nextjs.js' +import {promptForTypeScript} from '../../prompts/init/promptForTypescript.js' +import {createCorsOrigin, listCorsOrigins} from '../../services/cors.js' +import {type InitStepResult} from '../../telemetry/init.telemetry.js' +import {getPeerDependencies} from '../../util/packageManager/getPeerDependencies.js' +import {installNewPackages} from '../../util/packageManager/installPackages.js' +import { + getPartialEnvWithNpmPath, + type PackageManager, +} from '../../util/packageManager/packageManagerChoice.js' +import {type EditorName} from '../mcp/editorConfigs.js' +import {countNestedFolders} from './countNestedFolders.js' +import {createOrAppendEnvVars} from './env/createOrAppendEnvVars.js' +import {InitError} from './initError.js' +import { + flagOrDefault, + getPostInitMCPPrompt, + shouldPrompt, + writeStagingEnvIfNeeded, +} from './initHelpers.js' +import {resolvePackageManager} from './resolvePackageManager.js' +import { + sanityCliTemplate, + sanityConfigTemplate, + sanityFolder, + sanityStudioTemplate, +} from './templates/nextjs/index.js' +import {type InitContext, type InitOptions, type VersionedFramework} from './types.js' + +const debug = subdebug('init:nextjs') + +export async function initNextJs({ + datasetName, + detectedFramework, + envFilename, + mcpConfigured, + options, + output, + projectId, + trace, + workDir, +}: { + datasetName: string + detectedFramework: VersionedFramework | null + envFilename: string + mcpConfigured: EditorName[] + options: InitOptions + output: InitContext['output'] + projectId: string + trace: TelemetryTrace + workDir: string +}): Promise { + let useTypeScript = flagOrDefault(options.typescript, true) + if (shouldPrompt(options.unattended, options.typescript)) { + useTypeScript = await promptForTypeScript() + } + trace.log({ + selectedOption: useTypeScript ? 'yes' : 'no', + step: 'useTypeScript', + }) + + const fileExtension = useTypeScript ? 'ts' : 'js' + let embeddedStudio = flagOrDefault(options.nextjsEmbedStudio, true) + if (shouldPrompt(options.unattended, options.nextjsEmbedStudio)) { + embeddedStudio = await promptForEmbeddedStudio() + } + let hasSrcFolder = false + + if (embeddedStudio) { + // find source path (app or src/app) + const appDir = 'app' + let srcPath = path.join(workDir, appDir) + + if (!existsSync(srcPath)) { + srcPath = path.join(workDir, 'src', appDir) + hasSrcFolder = true + if (!existsSync(srcPath)) { + try { + await mkdir(srcPath, {recursive: true}) + } catch { + debug('Error creating folder %s', srcPath) + } + } + } + + const studioPath = options.unattended ? '/studio' : await promptForStudioPath() + + const embeddedStudioRouteFilePath = path.join( + srcPath, + `${studioPath}/`, + `[[...tool]]/page.${fileExtension}x`, + ) + + // this selects the correct template string based on whether the user is using the app or pages directory and + // replaces the ":configPath:" placeholder in the template with the correct path to the sanity.config.ts file. + // we account for the user-defined embeddedStudioPath (default /studio) is accounted for by creating enough "../" + // relative paths to reach the root level of the project + await writeOrOverwrite( + embeddedStudioRouteFilePath, + sanityStudioTemplate.replace( + ':configPath:', + `${'../'.repeat(countNestedFolders(embeddedStudioRouteFilePath.slice(workDir.length)))}sanity.config`, + ), + workDir, + options, + ) + + const sanityConfigPath = path.join(workDir, `sanity.config.${fileExtension}`) + await writeOrOverwrite( + sanityConfigPath, + sanityConfigTemplate(hasSrcFolder) + .replace(':route:', embeddedStudioRouteFilePath.slice(workDir.length).replace('src/', '')) + .replace(':basePath:', studioPath), + workDir, + options, + ) + } + + const sanityCliPath = path.join(workDir, `sanity.cli.${fileExtension}`) + await writeOrOverwrite(sanityCliPath, sanityCliTemplate, workDir, options) + + let templateToUse = options.template ?? 'clean' + if (shouldPrompt(options.unattended, options.template)) { + templateToUse = await promptForNextTemplate() + } + + await writeSourceFiles({ + fileExtension, + files: sanityFolder(useTypeScript, templateToUse as 'blog' | 'clean'), + folderPath: undefined, + options, + srcFolderPrefix: hasSrcFolder, + workDir, + }) + + let appendEnv = flagOrDefault(options.nextjsAppendEnv, true) + if (shouldPrompt(options.unattended, options.nextjsAppendEnv)) { + appendEnv = await promptForAppendEnv(envFilename) + } + + if (appendEnv) { + await createOrAppendEnvVars({ + envVars: { + DATASET: datasetName, + PROJECT_ID: projectId, + }, + filename: envFilename, + framework: detectedFramework, + log: true, + output, + outputPath: workDir, + }) + } + + if (embeddedStudio) { + const nextjsLocalDevOrigin = 'http://localhost:3000' + const existingCorsOrigins = await listCorsOrigins(projectId) + const hasExistingCorsOrigin = existingCorsOrigins.some( + (item: {origin: string}) => item.origin === nextjsLocalDevOrigin, + ) + if (!hasExistingCorsOrigin) { + try { + const createCorsRes = await createCorsOrigin({ + allowCredentials: true, + origin: nextjsLocalDevOrigin, + projectId, + }) + + output.log( + createCorsRes.id + ? `Added ${nextjsLocalDevOrigin} to CORS origins` + : `Failed to add ${nextjsLocalDevOrigin} to CORS origins`, + ) + } catch (error) { + debug(`Error creating new CORS Origin ${nextjsLocalDevOrigin}: ${error}`) + const message = error instanceof Error ? error.message : String(error) + throw new InitError(`Failed to add ${nextjsLocalDevOrigin} to CORS origins: ${message}`, 1) + } + } + } + + const chosen = await resolvePackageManager({ + interactive: !options.unattended, + output, + packageManager: options.packageManager as PackageManager, + targetDir: workDir, + }) + trace.log({selectedOption: chosen, step: 'selectPackageManager'}) + const packages = ['@sanity/vision@4', 'sanity@4', '@sanity/image-url@1', 'styled-components@6'] + if (templateToUse === 'blog') { + packages.push('@sanity/icons') + } + await installNewPackages( + { + packageManager: chosen, + packages, + }, + { + output, + workDir, + }, + ) + + // will refactor this later + const execOptions: Options = { + cwd: workDir, + encoding: 'utf8', + env: getPartialEnvWithNpmPath(workDir), + stdio: 'inherit', + } + + switch (chosen) { + case 'bun': { + await execa('bun', ['add', 'next-sanity@11'], execOptions) + break + } + case 'npm': { + await execa('npm', ['install', '--legacy-peer-deps', 'next-sanity@11'], execOptions) + break + } + case 'pnpm': { + await execa('pnpm', ['install', 'next-sanity@11'], execOptions) + break + } + case 'yarn': { + const peerDeps = await getPeerDependencies('next-sanity@11', workDir) + await installNewPackages( + {packageManager: 'yarn', packages: ['next-sanity@11', ...peerDeps]}, + {output, workDir}, + ) + break + } + default: { + // manual - do nothing + break + } + } + + output.log( + `\n${styleText('green', 'Success!')} Your Sanity configuration files has been added to this project`, + ) + if (mcpConfigured && mcpConfigured.length > 0) { + const message = await getPostInitMCPPrompt(mcpConfigured) + output.log(`\n${message}`) + output.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) + output.log( + `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, + ) + } + + await writeStagingEnvIfNeeded(output, workDir) +} + +async function writeOrOverwrite( + filePath: string, + content: string, + workDir: string, + options: InitOptions, +): Promise { + if (existsSync(filePath)) { + let overwrite = flagOrDefault(options.overwriteFiles, false) + if (shouldPrompt(options.unattended, options.overwriteFiles)) { + overwrite = await confirm({ + default: false, + message: `File ${styleText( + 'yellow', + filePath.replace(workDir, ''), + )} already exists. Do you want to overwrite it?`, + }) + } + + if (!overwrite) { + return + } + } + + // make folder if not exists + const folderPath = path.dirname(filePath) + + try { + await mkdir(folderPath, {recursive: true}) + } catch { + debug('Error creating folder %s', folderPath) + } + + await writeFile(filePath, content, { + encoding: 'utf8', + }) +} + +// write sanity folder files +async function writeSourceFiles({ + fileExtension, + files, + folderPath, + options, + srcFolderPrefix, + workDir, +}: { + fileExtension: string + files: Record | string> + folderPath?: string + options: InitOptions + srcFolderPrefix?: boolean + workDir: string +}): Promise { + for (const [filePath, content] of Object.entries(files)) { + // check if file ends with full stop to indicate it's file and not directory (this only works with our template tree structure) + if (filePath.includes('.') && typeof content === 'string') { + await writeOrOverwrite( + path.join( + workDir, + srcFolderPrefix ? 'src' : '', + 'sanity', + folderPath || '', + `${filePath}${fileExtension}`, + ), + content, + workDir, + options, + ) + } else { + await mkdir(path.join(workDir, srcFolderPrefix ? 'src' : '', 'sanity', filePath), { + recursive: true, + }) + if (typeof content === 'object') { + await writeSourceFiles({ + fileExtension, + files: content, + folderPath: filePath, + options, + srcFolderPrefix, + workDir, + }) + } + } + } +} diff --git a/packages/@sanity/cli/src/actions/init/initStudio.ts b/packages/@sanity/cli/src/actions/init/initStudio.ts new file mode 100644 index 000000000..9d3241824 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/initStudio.ts @@ -0,0 +1,174 @@ +import {styleText} from 'node:util' + +import {getCliToken, subdebug, type TelemetryUserProperties} from '@sanity/cli-core' +import {confirm} from '@sanity/cli-core/ux' +import {type TelemetryTrace} from '@sanity/telemetry' + +import {updateProjectInitializedAt} from '../../services/projects.js' +import {type InitStepResult} from '../../telemetry/init.telemetry.js' +import {type PackageManager} from '../../util/packageManager/packageManagerChoice.js' +import {type EditorName} from '../mcp/editorConfigs.js' +import {InitError} from './initError.js' +import {getPostInitMCPPrompt} from './initHelpers.js' +import {type RepoInfo} from './remoteTemplate.js' +import {scaffoldAndInstall, selectTemplate} from './scaffoldTemplate.js' +import {type InitContext, type InitOptions} from './types.js' + +const debug = subdebug('init:studio') + +interface InitStudioParams { + datasetName: string + defaults: {projectName: string} + displayName: string + isFirstProject: boolean + mcpConfigured: EditorName[] + options: InitOptions + organizationId: string | undefined + output: InitContext['output'] + outputPath: string + projectId: string + remoteTemplateInfo: RepoInfo | undefined + sluggedName: string + trace: TelemetryTrace + workDir: string +} + +export async function initStudio({ + datasetName, + defaults, + displayName, + isFirstProject, + mcpConfigured, + options, + organizationId, + output, + outputPath, + projectId, + remoteTemplateInfo, + sluggedName, + trace, + workDir, +}: InitStudioParams): Promise { + // Prompt for template and TypeScript + const {template, templateName, useTypeScript} = await selectTemplate( + options, + remoteTemplateInfo, + trace, + ) + if (!remoteTemplateInfo && !template) { + throw new InitError(`Template "${templateName}" not found`, 1) + } + + // If the template has a sample dataset, prompt the user whether or not we should import it + const importDatasetFlag = options.importDataset + const shouldImport = + template?.datasetUrl && + (importDatasetFlag ?? + (!options.unattended && (await promptForDatasetImport(template.importPrompt)))) + + trace.log({ + selectedOption: shouldImport ? 'yes' : 'no', + step: 'importTemplateDataset', + }) + + try { + await updateProjectInitializedAt(projectId) + } catch (err) { + // Non-critical update + debug('Failed to update cliInitializedAt metadata', err) + } + + // Bootstrap, install deps, git init + const {pkgManager} = await scaffoldAndInstall({ + datasetName, + defaults, + displayName, + options, + organizationId, + output, + outputPath, + projectId, + remoteTemplateInfo, + sluggedName, + templateName, + trace, + useTypeScript, + workDir, + }) + + // Prompt for dataset import (if a dataset is defined) + if (shouldImport && template?.datasetUrl) { + const token = await getCliToken() + if (!token) { + throw new InitError('Authentication required to import dataset', 1) + } + // Dynamic import to keep initAction decoupled from oclif commands. + // TODO: consider replacing with `npx sanity dataset import` to fully decouple. + // eslint-disable-next-line no-restricted-syntax + const {ImportDatasetCommand} = await import('../../commands/datasets/import.js') + await ImportDatasetCommand.run( + [template.datasetUrl, '--project-id', projectId, '--dataset', datasetName, '--token', token], + { + root: outputPath, + }, + ) + + output.log('') + output.log('If you want to delete the imported data, use') + output.log(` ${styleText('cyan', `npx sanity dataset delete ${datasetName}`)}`) + output.log('and create a new clean dataset with') + output.log(` ${styleText('cyan', `npx sanity dataset create `)}\n`) + } + + // Studio-specific success messages + const devCommandMap: Record = { + bun: 'bun dev', + manual: 'npm run dev', + npm: 'npm run dev', + pnpm: 'pnpm dev', + yarn: 'yarn dev', + } + const devCommand = devCommandMap[pkgManager] + + const isCurrentDir = outputPath === workDir + const goToProjectDir = `\n(${styleText('cyan', `cd ${outputPath}`)} to navigate to your new project directory)` + + output.log(`\u2705 ${styleText(['green', 'bold'], 'Success!')} Your Studio has been created.`) + if (!isCurrentDir) output.log(goToProjectDir) + output.log( + `\nGet started by running ${styleText('cyan', devCommand)} to launch your Studio's development server`, + ) + if (mcpConfigured && mcpConfigured.length > 0) { + const message = await getPostInitMCPPrompt(mcpConfigured) + output.log(`\n${message}`) + output.log(`\nLearn more: ${styleText('cyan', 'https://mcp.sanity.io')}`) + output.log( + `\nHave feedback? Tell us in the community: ${styleText('cyan', 'https://www.sanity.io/community/join')}`, + ) + } + output.log('\n') + output.log(`Other helpful commands:`) + output.log(`npx sanity docs browse to open the documentation in a browser`) + output.log(`npx sanity manage to open the project settings in a browser`) + output.log(`npx sanity help to explore the CLI manual`) + + if (isFirstProject) { + trace.log({selectedOption: 'yes', step: 'sendCommunityInvite'}) + + const DISCORD_INVITE_LINK = 'https://www.sanity.io/community/join' + + output.log(`\nJoin the Sanity community: ${styleText('cyan', DISCORD_INVITE_LINK)}`) + output.log('We look forward to seeing you there!\n') + } +} + +// --------------------------------------------------------------------------- +// Studio-specific prompt helpers +// --------------------------------------------------------------------------- + +async function promptForDatasetImport(message?: string): Promise { + return confirm({ + default: true, + message: message || 'This template includes a sample dataset, would you like to use it?', + }) +} diff --git a/packages/@sanity/cli/src/actions/init/scaffoldTemplate.ts b/packages/@sanity/cli/src/actions/init/scaffoldTemplate.ts new file mode 100644 index 000000000..e8e1831a1 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/scaffoldTemplate.ts @@ -0,0 +1,187 @@ +import {type Output, type TelemetryUserProperties} from '@sanity/cli-core' +import {select} from '@sanity/cli-core/ux' +import {type TelemetryTrace} from '@sanity/telemetry' + +import {promptForTypeScript} from '../../prompts/init/promptForTypescript.js' +import {type InitStepResult} from '../../telemetry/init.telemetry.js' +import {installDeclaredPackages} from '../../util/packageManager/installPackages.js' +import {type PackageManager} from '../../util/packageManager/packageManagerChoice.js' +import {bootstrapTemplate} from './bootstrapTemplate.js' +import {tryGitInit} from './git.js' +import {shouldPrompt, writeStagingEnvIfNeeded} from './initHelpers.js' +import {type RepoInfo} from './remoteTemplate.js' +import {resolvePackageManager} from './resolvePackageManager.js' +import templates from './templates/index.js' +import {type InitOptions, type ProjectTemplate} from './types.js' + +// --------------------------------------------------------------------------- +// Template selection +// --------------------------------------------------------------------------- + +interface SelectedTemplate { + template: ProjectTemplate | undefined + templateName: string + useTypeScript: boolean | undefined +} + +/** + * Prompts for (or resolves from flags) which template and TypeScript setting + * to use. Shared by both the app and studio init flows. + */ +export async function selectTemplate( + options: InitOptions, + remoteTemplateInfo: RepoInfo | undefined, + trace: TelemetryTrace, +): Promise { + const templateName = await promptForTemplate(options) + trace.log({ + selectedOption: templateName, + step: 'selectProjectTemplate', + }) + const template = templates[templateName] + + let useTypeScript = options.typescript + if (!remoteTemplateInfo && template && template.typescriptOnly === true) { + useTypeScript = true + } else if (shouldPrompt(options.unattended, options.typescript)) { + useTypeScript = await promptForTypeScript() + trace.log({ + selectedOption: useTypeScript ? 'yes' : 'no', + step: 'useTypeScript', + }) + } + + return {template, templateName, useTypeScript} +} + +// --------------------------------------------------------------------------- +// Scaffolding pipeline +// --------------------------------------------------------------------------- + +interface ScaffoldOptions { + // Studio-specific (empty string for apps) + datasetName: string + defaults: {projectName: string} + displayName: string + options: InitOptions + organizationId: string | undefined + output: Output + outputPath: string + projectId: string + remoteTemplateInfo: RepoInfo | undefined + sluggedName: string + + // From template selection + templateName: string + trace: TelemetryTrace + + useTypeScript: boolean | undefined + workDir: string +} + +interface ScaffoldResult { + pkgManager: PackageManager +} + +/** + * Runs the shared scaffolding pipeline: bootstrap the template, install + * dependencies, write staging env if needed, and optionally git-init. + * + * Used by both `initApp` and `initStudio`. + */ +export async function scaffoldAndInstall({ + datasetName, + defaults, + displayName, + options, + organizationId, + output, + outputPath, + projectId, + remoteTemplateInfo, + sluggedName, + templateName, + trace, + useTypeScript, + workDir, +}: ScaffoldOptions): Promise { + await bootstrapTemplate({ + autoUpdates: options.autoUpdates, + bearerToken: options.templateToken, + dataset: datasetName, + organizationId, + output, + outputPath, + overwriteFiles: options.overwriteFiles, + packageName: sluggedName, + projectId, + projectName: displayName || defaults.projectName, + remoteTemplateInfo, + templateName, + useTypeScript, + }) + + const pkgManager = await resolvePackageManager({ + interactive: !options.unattended, + output, + packageManager: options.packageManager as PackageManager, + targetDir: outputPath, + }) + + trace.log({ + selectedOption: pkgManager, + step: 'selectPackageManager', + }) + + // Now for the slow part... installing dependencies + await installDeclaredPackages(outputPath, pkgManager, { + output, + workDir, + }) + + const useGit = options.git === undefined || Boolean(options.git) + const commitMessage = options.git + await writeStagingEnvIfNeeded(output, outputPath) + + // Try initializing a git repository + if (useGit) { + tryGitInit(outputPath, typeof commitMessage === 'string' ? commitMessage : undefined) + } + + return {pkgManager} +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +async function promptForTemplate(options: InitOptions): Promise { + const template = options.template + + const defaultTemplate = options.unattended || template ? template || 'clean' : null + if (defaultTemplate) { + return defaultTemplate + } + + return select({ + choices: [ + { + name: 'Clean project with no predefined schema types', + value: 'clean', + }, + { + name: 'Blog (schema)', + value: 'blog', + }, + { + name: 'E-commerce (Shopify)', + value: 'shopify', + }, + { + name: 'Movie project (schema + sample data)', + value: 'moviedb', + }, + ], + message: 'Select project template', + }) +} diff --git a/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts index 8b3766799..71d650dde 100644 --- a/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/init/init.get-project-details.test.ts @@ -152,8 +152,6 @@ describe('#init: get project details', () => { mocks.select.mockResolvedValueOnce('org-123') - setupInitSuccessMocks('') - const {error} = await testCommand( InitCommand, [ diff --git a/packages/@sanity/cli/src/telemetry/init.telemetry.ts b/packages/@sanity/cli/src/telemetry/init.telemetry.ts index 54488f36e..5d1e81760 100644 --- a/packages/@sanity/cli/src/telemetry/init.telemetry.ts +++ b/packages/@sanity/cli/src/telemetry/init.telemetry.ts @@ -31,6 +31,7 @@ interface CreateOrSelectDatasetStep { datasetName: string selectedOption: 'create' | 'none' | 'select' step: 'createOrSelectDataset' + visibility?: 'private' | 'public' } From 1b1a2a14e6ebdf0be6a657e19c4ea25c2bd7ae12 Mon Sep 17 00:00:00 2001 From: Espen Hovlandsdal Date: Fri, 20 Mar 2026 14:08:26 -0700 Subject: [PATCH 03/27] feat(create-sanity): add standalone entry point calling initAction directly Replaces the spawn-wrapper approach with a TypeScript entry point that parses flags using @oclif/core/parser (reusing InitCommand.flags for exact parity) and calls initAction() directly. Uses no-op telemetry stub. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/create-sanity/src/index.ts | 54 +++++++++++++++++++++ packages/create-sanity/src/noopTelemetry.ts | 25 ++++++++++ 2 files changed, 79 insertions(+) create mode 100644 packages/create-sanity/src/index.ts create mode 100644 packages/create-sanity/src/noopTelemetry.ts diff --git a/packages/create-sanity/src/index.ts b/packages/create-sanity/src/index.ts new file mode 100644 index 000000000..34fb5a9f9 --- /dev/null +++ b/packages/create-sanity/src/index.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import {isInteractive} from '@sanity/cli-core' +import {parse} from '@oclif/core/parser' + +import {initAction} from '@sanity/cli/actions/init/initAction' +import {InitError} from '@sanity/cli/actions/init/initError' +import {flagsToInitOptions} from '@sanity/cli/actions/init/types' +import {InitCommand} from '@sanity/cli/commands/init' + +import {createNoopTelemetryStore} from './noopTelemetry.js' + +async function main(): Promise { + const {args, flags} = await parse(process.argv.slice(2), { + args: InitCommand.args, + flags: InitCommand.flags, + strict: true, + }) + + // Compute MCP mode (same logic as InitCommand.run) + let mcpMode: 'auto' | 'prompt' | 'skip' = 'prompt' + if (!flags.mcp || !isInteractive()) { + mcpMode = 'skip' + } else if (flags.yes) { + mcpMode = 'auto' + } + + const isUnattended = flags.yes || !isInteractive() + + const options = flagsToInitOptions({...flags, 'from-create': true}, isUnattended, args, mcpMode) + + await initAction(options, { + output: { + log: console.log, + warn: console.warn, + error: (msg: string) => { + console.error(msg) + process.exit(1) + }, + }, + telemetry: createNoopTelemetryStore(), + workDir: process.cwd(), + }) +} + +main().catch((error) => { + if (error instanceof InitError) { + if (error.message) { + console.error(error.message) + } + process.exit(error.exitCode) + } + console.error(error) + process.exit(1) +}) diff --git a/packages/create-sanity/src/noopTelemetry.ts b/packages/create-sanity/src/noopTelemetry.ts new file mode 100644 index 000000000..245e00b74 --- /dev/null +++ b/packages/create-sanity/src/noopTelemetry.ts @@ -0,0 +1,25 @@ +import {type CLITelemetryStore} from '@sanity/cli-core' + +/** + * No-op telemetry logger for standalone create-sanity. + * Real telemetry will be wired up in a follow-up. + */ +export function createNoopTelemetryStore(): CLITelemetryStore { + const store: CLITelemetryStore = { + updateUserProperties() {}, + log() {}, + trace() { + return { + start() {}, + log() {}, + complete() {}, + error() {}, + newContext() { + return store + }, + await: (promise) => promise, + } + }, + } + return store +} From 3d5d02f1cccbc54d818ea9e1b886fef7f2f4c2ce Mon Sep 17 00:00:00 2001 From: Espen Hovlandsdal Date: Fri, 20 Mar 2026 15:38:03 -0700 Subject: [PATCH 04/27] feat(create-sanity): replace esbuild with Rollup for bundling Switch from esbuild to Rollup for the create-sanity standalone bundle to enable better tree-shaking of barrel exports. Rollup resolves @sanity/cli-core source directly via alias plugin so it can tree-shake unused exports from the barrel. Plugins: alias, node-resolve, commonjs, json, esbuild (transpile only). Bundle size is ~13MB (same as esbuild - tree-shaking improvements will come in subsequent tasks with stub removal). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/create-sanity/package.json | 21 +- packages/create-sanity/rollup.config.ts | 115 ++++++++ pnpm-lock.yaml | 365 +++++++++++++++++++++++- 3 files changed, 491 insertions(+), 10 deletions(-) create mode 100644 packages/create-sanity/rollup.config.ts diff --git a/packages/create-sanity/package.json b/packages/create-sanity/package.json index 61bdfb368..7d2535f68 100644 --- a/packages/create-sanity/package.json +++ b/packages/create-sanity/package.json @@ -21,21 +21,30 @@ "url": "git+https://github.com/sanity-io/cli.git", "directory": "packages/create-sanity" }, - "bin": "./index.js", + "bin": "./dist/index.js", "files": [ - "index.js" + "dist", + "!dist/stats.html" ], "type": "module", "scripts": { + "build": "rollup -c rollup.config.ts --configPlugin esbuild", "publint": "publint", "test": "vitest" }, - "dependencies": { - "@sanity/cli": "workspace:*", - "import-meta-resolve": "catalog:" - }, "devDependencies": { + "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-commonjs": "^29.0.2", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@sanity/cli": "workspace:*", + "@sanity/cli-core": "workspace:*", + "debug": "catalog:", + "esbuild": "^0.25.0", "publint": "catalog:", + "rollup": "^4.59.0", + "rollup-plugin-esbuild": "^6.2.1", + "rollup-plugin-visualizer": "^7.0.1", "vitest": "catalog:" }, "engines": { diff --git a/packages/create-sanity/rollup.config.ts b/packages/create-sanity/rollup.config.ts new file mode 100644 index 000000000..9184bfc42 --- /dev/null +++ b/packages/create-sanity/rollup.config.ts @@ -0,0 +1,115 @@ +import {createRequire} from 'node:module' +import path from 'node:path' +import {fileURLToPath} from 'node:url' + +import alias from '@rollup/plugin-alias' +import commonjs from '@rollup/plugin-commonjs' +import json from '@rollup/plugin-json' +import nodeResolve from '@rollup/plugin-node-resolve' +import {defineConfig} from 'rollup' +import esbuildPlugin from 'rollup-plugin-esbuild' +import {visualizer} from 'rollup-plugin-visualizer' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const cliCoreSrc = path.resolve(__dirname, '../@sanity/cli-core/src') + +// debug's index.js conditionally requires both browser.js and node.js at runtime. +// The commonjs plugin bundles both. Alias to the node entry directly. +const require = createRequire(import.meta.url) +const debugNodeEntry = require.resolve('debug/src/node.js') + +export default defineConfig({ + input: 'src/index.ts', + treeshake: { + // The aliased @sanity/cli-core source has sideEffects: false in its package.json, + // but since we alias to raw source files, Rollup doesn't see the annotation. + // Mark the entire cli-core source tree as side-effect-free for proper tree-shaking. + moduleSideEffects: (id) => { + if (id.includes('/cli-core/src/')) return false + return true + }, + }, + output: { + banner: '#!/usr/bin/env node', + file: 'dist/index.js', + format: 'esm', + inlineDynamicImports: true, + }, + external: (id) => { + // Node builtins + if (id.startsWith('node:')) return true + // @oclif/core's read-tsconfig.js dynamically requires typescript inside try-catch. + // It's never used at runtime in create-sanity (no TS config resolution needed). + // Mark as external so the ~10MB typescript compiler isn't bundled. + // At runtime, the require() gracefully fails in the catch block. + if (id === 'typescript' || id.endsWith('/node_modules/typescript')) return true + return false + }, + plugins: [ + alias({ + entries: [ + // Resolve subpaths first (more specific matches before less specific) + { + find: '@sanity/cli-core/ux', + replacement: path.join(cliCoreSrc, '_exports/ux.ts'), + }, + { + find: '@sanity/cli-core/package-manager', + replacement: path.join(cliCoreSrc, '_exports/package-manager.ts'), + }, + { + find: '@sanity/cli-core/request', + replacement: path.join(cliCoreSrc, '_exports/request.ts'), + }, + // Main barrel - use exact match to avoid catching subpath imports + { + find: /^@sanity\/cli-core$/, + replacement: path.join(cliCoreSrc, 'index.ts'), + }, + // debug's index.js bundles both browser and node via conditional require. + // Alias to node entry directly since this is a Node CLI tool. + {find: 'debug', replacement: debugNodeEntry}, + ], + }), + nodeResolve({ + exportConditions: ['node', 'import', 'default'], + preferBuiltins: true, + // Don't resolve the legacy "browser" field in package.json. + // Without this, packages like `debug` resolve to their browser bundle. + browser: false, + }), + commonjs(), + json(), + esbuildPlugin({ + target: 'node20', + // Keep readable output so sourcemaps line up for source-map-explorer + minify: false, + }), + // Shorten pnpm store paths in module IDs so the treemap is readable. + // `.pnpm/pkg@1.0.0/node_modules/pkg/dist/foo.js` → `pkg/dist/foo.js` + { + name: 'clean-module-ids', + generateBundle(_options, bundle) { + // The visualizer reads from the chunk's `modules` object keys + for (const chunk of Object.values(bundle)) { + if (chunk.type !== 'chunk' || !chunk.modules) continue + const cleaned: Record = {} + for (const [id, info] of Object.entries(chunk.modules)) { + const short = id.replace(/.*\.pnpm\/[^/]+\/node_modules\//, '') + cleaned[short] = info + } + chunk.modules = cleaned + } + }, + }, + visualizer({ + filename: 'dist/stats.html', + gzipSize: true, + template: 'treemap', + }), + ], + onwarn(warning, warn) { + if (warning.code === 'CIRCULAR_DEPENDENCY') return + warn(warning) + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c56276b98..7f28f1f9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -975,17 +975,43 @@ importers: version: 0.3.18 packages/create-sanity: - dependencies: + devDependencies: + '@rollup/plugin-alias': + specifier: ^6.0.0 + version: 6.0.0(rollup@4.59.0) + '@rollup/plugin-commonjs': + specifier: ^29.0.2 + version: 29.0.2(rollup@4.59.0) + '@rollup/plugin-json': + specifier: ^6.1.0 + version: 6.1.0(rollup@4.59.0) + '@rollup/plugin-node-resolve': + specifier: ^16.0.3 + version: 16.0.3(rollup@4.59.0) '@sanity/cli': specifier: workspace:* version: link:../@sanity/cli - import-meta-resolve: + '@sanity/cli-core': + specifier: workspace:* + version: link:../@sanity/cli-core + debug: specifier: 'catalog:' - version: 4.2.0 - devDependencies: + version: 4.4.3(supports-color@8.1.1) + esbuild: + specifier: ^0.25.0 + version: 0.25.12 publint: specifier: 'catalog:' version: 0.3.18 + rollup: + specifier: ^4.59.0 + version: 4.59.0 + rollup-plugin-esbuild: + specifier: ^6.2.1 + version: 6.2.1(esbuild@0.25.12)(rollup@4.59.0) + rollup-plugin-visualizer: + specifier: ^7.0.1 + version: 7.0.1(rolldown@1.0.0-rc.9)(rollup@4.59.0) vitest: specifier: 'catalog:' version: 4.1.0(@types/node@25.0.10)(jsdom@29.0.0(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -2102,156 +2128,312 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.4': resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.4': resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.4': resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.4': resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.4': resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.4': resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.4': resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.4': resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.4': resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.4': resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.4': resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.4': resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.4': resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.4': resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.4': resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.4': resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.4': resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.4': resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.4': resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.4': resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.4': resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.4': resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} @@ -5306,6 +5488,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + clone-deep@4.0.1: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} @@ -5737,6 +5923,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} @@ -7876,6 +8067,19 @@ packages: esbuild: '>=0.18.0' rollup: ^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 + rollup-plugin-visualizer@7.0.1: + resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -8908,10 +9112,18 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yauzl@3.2.0: resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} engines: {node: '>=12'} @@ -10737,81 +10949,159 @@ snapshots: '@emotion/weak-memoize@0.4.0': {} + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.4': optional: true @@ -14406,6 +14696,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrap-ansi: 9.0.2 + clone-deep@4.0.1: dependencies: is-plain-object: 2.0.4 @@ -14802,6 +15098,35 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -17076,6 +17401,17 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 + rollup-plugin-esbuild@6.2.1(esbuild@0.25.12)(rollup@4.59.0): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + esbuild: 0.25.12 + get-tsconfig: 4.13.6 + rollup: 4.59.0 + unplugin-utils: 0.2.5 + transitivePeerDependencies: + - supports-color + rollup-plugin-esbuild@6.2.1(esbuild@0.27.4)(rollup@4.59.0): dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -17087,6 +17423,16 @@ snapshots: transitivePeerDependencies: - supports-color + rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.9)(rollup@4.59.0): + dependencies: + open: 11.0.0 + picomatch: 4.0.3 + source-map: 0.7.6 + yargs: 18.0.0 + optionalDependencies: + rolldown: 1.0.0-rc.9 + rollup: 4.59.0 + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -18233,6 +18579,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -18243,6 +18591,15 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yauzl@3.2.0: dependencies: buffer-crc32: 0.2.13 From 3490d891df672fca4f56f891185b8dca5638b88d Mon Sep 17 00:00:00 2001 From: Espen Hovlandsdal Date: Mon, 23 Mar 2026 13:03:40 -0700 Subject: [PATCH 05/27] feat(cli-core): add CLIError and warn() utilities to replace oclif ux Add lightweight error formatting utilities that match oclif's pretty-printed error output (bang prefix, word wrap, clean stacks, suggestions) without depending on @oclif/core. - CLIError: formatted error with oclif-compatible shape so oclif's error handler still recognizes it when thrown from commands - CLIWarning: same but with yellow bang prefix - error()/warn(): standalone functions for printing to stderr Update NonInteractiveError and ProjectRootNotFoundError to extend our CLIError instead of oclif's. Replace oclif ux imports in apiClient.ts and getCliTelemetry.ts with node:util styleText and our warn(). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/@sanity/cli-core/package.json | 3 + packages/@sanity/cli-core/src/_exports/ux.ts | 1 + .../src/errors/NonInteractiveError.ts | 2 +- .../src/errors/ProjectRootNotFoundError.ts | 3 +- .../cli-core/src/services/apiClient.ts | 5 +- .../cli-core/src/util/getCliTelemetry.ts | 4 +- packages/@sanity/cli-core/src/ux/errors.ts | 156 ++++++++++++++++++ pnpm-lock.yaml | 55 ++++++ 8 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 packages/@sanity/cli-core/src/ux/errors.ts diff --git a/packages/@sanity/cli-core/package.json b/packages/@sanity/cli-core/package.json index 1154e0dbb..12a0e7110 100644 --- a/packages/@sanity/cli-core/package.json +++ b/packages/@sanity/cli-core/package.json @@ -68,11 +68,13 @@ "@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", "get-tsconfig": "catalog:", "import-meta-resolve": "catalog:", + "indent-string": "^5.0.0", "jsdom": "catalog:", "json-lexer": "^1.2.0", "log-symbols": "^7.0.1", @@ -82,6 +84,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/ux.ts b/packages/@sanity/cli-core/src/_exports/ux.ts index fc826aff7..fa5063b3a 100644 --- a/packages/@sanity/cli-core/src/_exports/ux.ts +++ b/packages/@sanity/cli-core/src/_exports/ux.ts @@ -1,5 +1,6 @@ export {NonInteractiveError} from '../errors/NonInteractiveError.js' export * from '../ux/boxen.js' +export * from '../ux/errors.js' export * from '../ux/logSymbols.js' export * from '../ux/prompts.js' export * from '../ux/spinner.js' diff --git a/packages/@sanity/cli-core/src/errors/NonInteractiveError.ts b/packages/@sanity/cli-core/src/errors/NonInteractiveError.ts index cf2e50851..ddb0ffbbe 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 '../ux/errors.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..c31e0cf4e 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 '../ux/errors.js' /** * Error thrown when a project root directory cannot be found. 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..48853abf0 --- /dev/null +++ b/packages/@sanity/cli-core/src/ux/errors.ts @@ -0,0 +1,156 @@ +import cleanStack from 'clean-stack' +import indentString from 'indent-string' +import {styleText} from 'node:util' +import wrapAnsi from 'wrap-ansi' + +// --------------------------------------------------------------------------- +// Settings / helpers +// --------------------------------------------------------------------------- + +const settings: {debug?: boolean} = (globalThis as Record).oclif ?? {} + +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 bang(color: 'red' | 'yellow'): string | undefined { + try { + return styleText(color, process.platform === 'win32' ? '»' : '›') + } catch { + return undefined + } +} + +// --------------------------------------------------------------------------- +// CLIError +// --------------------------------------------------------------------------- + +export interface PrettyPrintableError { + code?: string + message?: string + ref?: string + suggestions?: string[] +} + +/** + * A formatted CLI error that pretty-prints to stderr. + * + * The `oclif` property is shaped so oclif's error handler recognises it + * when thrown inside an oclif command, preserving the correct exit code + * and suppressing redundant stack traces. + */ +export class CLIError extends Error { + oclif: {exit?: number} = {exit: 2} + code?: string + suggestions?: string[] + ref?: string + skipOclifErrorHandling?: boolean + + constructor(error: Error | string, options: {exit?: false | number} & PrettyPrintableError = {}) { + 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 || undefined + this.code = options.code + this.suggestions = options.suggestions + this.ref = options.ref + } + + get bang(): string | undefined { + return bang('red') + } + + get prettyStack(): string { + return cleanStack(super.stack!, {pretty: true}) + } +} + +/** + * 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 bang('yellow') + } +} + +// --------------------------------------------------------------------------- +// Pretty-print +// --------------------------------------------------------------------------- + +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 +} + +// --------------------------------------------------------------------------- +// error() and warn() +// --------------------------------------------------------------------------- + +/** + * Print a formatted error to stderr without throwing, when `exit: false`. + */ +export function error(input: Error | string, options: {exit: false} & PrettyPrintableError): void +/** + * Throw a formatted {@link CLIError}. + */ +export function error( + input: Error | string, + options?: {exit?: number} & PrettyPrintableError, +): never +export function error( + input: Error | string, + options: {exit?: false | number} & PrettyPrintableError = {}, +): 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) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f28f1f9b..663c4b872 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -766,6 +766,9 @@ importers: boxen: specifier: ^8.0.1 version: 8.0.1 + clean-stack: + specifier: ^6.0.0 + version: 6.0.0 configstore: specifier: ^7.0.0 version: 7.1.0 @@ -781,6 +784,9 @@ importers: import-meta-resolve: specifier: 'catalog:' version: 4.2.0 + indent-string: + specifier: ^5.0.0 + version: 5.0.0 jsdom: specifier: 'catalog:' version: 29.0.0(@noble/hashes@2.0.1) @@ -808,6 +814,9 @@ importers: vite-node: specifier: ^5.3.0 version: 5.3.0(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + wrap-ansi: + specifier: ^10.0.0 + version: 10.0.0 zod: specifier: 'catalog:' version: 4.3.6 @@ -5460,6 +5469,10 @@ packages: resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} engines: {node: '>=10'} + clean-stack@6.0.0: + resolution: {integrity: sha512-IXdAdynbe+4e5n31+SkraWUXwKm3NBamsFFpDswUsAMJbMu+uZgLBxays+jDQ8ix6c0phpt5FtY+N1NGLvEIaA==} + engines: {node: '>=20'} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -5945,6 +5958,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-compat-utils@0.5.1: resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} engines: {node: '>=12'} @@ -6346,6 +6363,10 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -8353,6 +8374,10 @@ packages: resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} engines: {node: '>=20'} + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -8777,6 +8802,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-extras@0.1.0: + resolution: {integrity: sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==} + engines: {node: '>=20'} + urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} @@ -9036,6 +9065,10 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -14673,6 +14706,11 @@ snapshots: dependencies: escape-string-regexp: 4.0.0 + clean-stack@6.0.0: + dependencies: + escape-string-regexp: 5.0.0 + url-extras: 0.1.0 + cli-boxes@3.0.0: {} cli-cursor@5.0.0: @@ -15162,6 +15200,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-compat-utils@0.5.1(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -15629,6 +15669,8 @@ snapshots: get-east-asian-width@1.4.0: {} + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -17842,6 +17884,11 @@ snapshots: get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.1.2 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -18263,6 +18310,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-extras@0.1.0: {} + urlpattern-polyfill@10.1.0: {} use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): @@ -18528,6 +18577,12 @@ snapshots: wordwrap@1.0.0: {} + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.0 + strip-ansi: 7.1.2 + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 From 09d27f41b52b2e61e269074020093273638f4d6a Mon Sep 17 00:00:00 2001 From: Espen Hovlandsdal Date: Mon, 23 Mar 2026 13:03:54 -0700 Subject: [PATCH 06/27] refactor(cli): remove oclif imports from init-reachable code paths Replace oclif ux.warn/ux.stdout with our own warn() from @sanity/cli-core/ux in setupMCP.ts. Replace console.log for stdout output. Replace CLIError with InitError in createOrAppendEnvVars.ts. Remove oclif import from promptForDefaultConfig.ts. These files are transitively bundled into create-sanity, so removing oclif imports here prevents the entire oclif runtime from being pulled into the standalone bundle. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../actions/init/env/createOrAppendEnvVars.ts | 4 ++-- .../@sanity/cli/src/actions/mcp/setupMCP.ts | 19 +++++++++---------- .../cli/src/prompts/promptForDefaultConfig.ts | 3 +-- 3 files changed, 12 insertions(+), 14 deletions(-) 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/mcp/setupMCP.ts b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts index cb39d6f43..f9a82c41f 100644 --- a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts +++ b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts @@ -1,6 +1,5 @@ -import {ux} from '@oclif/core' import {subdebug} from '@sanity/cli-core' -import {logSymbols} from '@sanity/cli-core/ux' +import {logSymbols, warn} from '@sanity/cli-core/ux' import {createMCPToken, MCP_SERVER_URL} from '../../services/mcp.js' import {detectAvailableEditors} from './detectAvailableEditors.js' @@ -66,7 +65,7 @@ export async function setupMCP(options?: MCPSetupOptions): Promise e.configured && e.authStatus === 'valid') .map((e) => e.name) if (explicit) { - ux.stdout(`${logSymbols.success} All detected editors are already configured`) + console.log(`${logSymbols.success} All detected editors are already configured`) } return { alreadyConfiguredEditors, @@ -106,7 +105,7 @@ export async function setupMCP(options?: MCPSetupOptions): Promise { - ux.stdout(DATASET_INFO_TEXT) + console.log(DATASET_INFO_TEXT) return confirm({ default: true, message: 'Use the default dataset configuration?', From ec1570b262d41af9ed9cb30c1e72d146d0cc3fe8 Mon Sep 17 00:00:00 2001 From: Espen Hovlandsdal Date: Mon, 23 Mar 2026 13:04:19 -0700 Subject: [PATCH 07/27] refactor(cli): define init flags as POJOs with oclif adapter Extract flag definitions from InitCommand into plain objects in flags.ts with zero @oclif/core imports. These can be consumed by both the oclif command (via toOclifFlags adapter) and create-sanity's standalone entry point (via node:util parseArgs). - flags.ts: POJO flag/arg definitions shared across consumers - flagAdapter.ts: toOclifFlags/toOclifArgs converters - init.ts: uses adapter instead of direct oclif flag helpers - types.ts: env flag validation moved to flagsToInitOptions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../@sanity/cli/src/actions/init/flags.ts | 241 ++++++++++++++++++ .../@sanity/cli/src/actions/init/types.ts | 5 + packages/@sanity/cli/src/commands/init.ts | 181 +------------ packages/@sanity/cli/src/util/flagAdapter.ts | 74 ++++++ 4 files changed, 330 insertions(+), 171 deletions(-) create mode 100644 packages/@sanity/cli/src/actions/init/flags.ts create mode 100644 packages/@sanity/cli/src/util/flagAdapter.ts 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..904e68d8e --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/flags.ts @@ -0,0 +1,241 @@ +/** + * 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': { + type: 'boolean', + allowNo: true, + default: true, + description: 'Enable auto updates of studio versions', + exclusive: ['bare'], + }, + bare: { + type: 'boolean', + description: + 'Skip the Studio initialization and only print the selected project ID and dataset name to stdout', + }, + coupon: { + type: 'string', + description: + 'Optionally select a coupon for a new project (cannot be used with --project-plan)', + exclusive: ['project-plan'], + helpValue: '', + }, + 'create-project': { + type: 'string', + deprecated: {message: 'Use --project-name instead'}, + description: 'Create a new project with the given name', + helpValue: '', + hidden: true, + }, + dataset: { + type: 'string', + description: 'Dataset name for the studio', + exclusive: ['dataset-default'], + helpValue: '', + }, + 'dataset-default': { + type: 'boolean', + description: 'Set up a project with a public dataset named "production"', + }, + env: { + type: 'string', + description: 'Write environment variables to file', + exclusive: ['bare'], + helpValue: '', + }, + 'from-create': { + type: 'boolean', + description: 'Internal flag to indicate that the command is run from create-sanity', + hidden: true, + }, + git: { + type: 'string', + 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: '', + }, + 'import-dataset': { + type: 'boolean', + allowNo: true, + default: undefined, + description: 'Import template sample dataset', + }, + mcp: { + type: 'boolean', + allowNo: true, + default: true, + description: 'Enable AI editor integration (MCP) setup', + }, + 'nextjs-add-config-files': { + type: 'boolean', + allowNo: true, + default: undefined, + description: 'Add config files to Next.js project', + helpGroup: 'Next.js', + }, + 'nextjs-append-env': { + type: 'boolean', + allowNo: true, + default: undefined, + description: 'Append project ID and dataset to .env file', + helpGroup: 'Next.js', + }, + 'nextjs-embed-studio': { + type: 'boolean', + allowNo: true, + default: undefined, + description: 'Embed the Studio in Next.js application', + helpGroup: 'Next.js', + }, + // 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': { + type: 'boolean', + description: 'Disable git initialization', + exclusive: ['git'], + hidden: true, + }, + organization: { + type: 'string', + description: 'Organization ID to use for the project', + helpValue: '', + }, + 'output-path': { + type: 'string', + description: 'Path to write studio project to', + exclusive: ['bare'], + helpValue: '', + }, + 'overwrite-files': { + type: 'boolean', + allowNo: true, + default: undefined, + description: 'Overwrite existing files', + }, + 'package-manager': { + type: 'string', + description: 'Specify which package manager to use [allowed: npm, yarn, pnpm]', + exclusive: ['bare'], + helpValue: '', + options: ['npm', 'yarn', 'pnpm'], + }, + project: { + type: 'string', + aliases: ['project-id'], + description: 'Project ID to use for the studio', + exclusive: ['create-project', 'project-name'], + helpValue: '', + }, + 'project-name': { + type: 'string', + description: 'Create a new project with the given name', + exclusive: ['project', 'create-project'], + helpValue: '', + }, + 'project-plan': { + type: 'string', + description: 'Optionally select a plan for a new project', + helpValue: '', + }, + provider: { + type: 'string', + description: 'Login provider to use', + helpValue: '', + }, + quickstart: { + type: 'boolean', + deprecated: true, + description: + 'Used for initializing a project from a server schema that is saved in the Journey API', + hidden: true, + }, + reconfigure: { + type: 'boolean', + deprecated: { + message: 'This flag is no longer supported', + version: '3.0.0', + }, + description: 'Reconfigure an existing project', + hidden: true, + }, + template: { + type: 'string', + description: 'Project template to use [default: "clean"]', + exclusive: ['bare'], + helpValue: '