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..528a31bef --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/initApp.ts @@ -0,0 +1,121 @@ +import {styleText} from 'node:util' + +import {type Output, 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 {getPostInitMCPPrompt} from './initHelpers.js' +import {type RepoInfo} from './remoteTemplate.js' +import {scaffoldAndInstall, selectTemplate} from './scaffoldTemplate.js' + +export async function initApp({ + autoUpdates, + defaults, + error, + git, + noGit, + mcpConfigured, + organizationId, + output, + outputPath, + overwriteFiles, + packageManager, + remoteTemplateInfo, + sluggedName, + template, + templateToken, + trace, + typescript, + unattended, + workDir, +}: { + autoUpdates: boolean + defaults: {projectName: string} + error: Output['error'] + git?: boolean | string + noGit?: boolean + mcpConfigured: EditorName[] + organizationId: string | undefined + output: Output + outputPath: string + overwriteFiles?: boolean + packageManager?: string + remoteTemplateInfo: RepoInfo | undefined + sluggedName: string + template?: string + templateToken?: string + trace: TelemetryTrace + typescript?: boolean + unattended: boolean + workDir: string +}): Promise { + const { + template: resolvedTemplate, + templateName, + useTypeScript, + } = await selectTemplate({ + remoteTemplateInfo, + template, + trace, + typescript, + unattended, + }) + + if (!remoteTemplateInfo && !resolvedTemplate) { + error(`Template "${templateName}" not found`, {exit: 1}) + } + + await scaffoldAndInstall({ + autoUpdates, + datasetName: '', + defaults, + displayName: '', + git, + noGit, + organizationId, + output, + outputPath, + overwriteFiles, + packageManager, + projectId: '', + remoteTemplateInfo, + sluggedName, + templateName, + templateToken, + trace, + unattended, + useTypeScript, + workDir, + }) + + const isCurrentDir = outputPath === process.cwd() + const goToProjectDir = `\n(${styleText('cyan', `cd ${outputPath}`)} to navigate to your new project directory)` + + //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`) +} 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..6daa3d4de --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/initHelpers.ts @@ -0,0 +1,44 @@ +import {type Output} from '@sanity/cli-core' + +import {getSanityEnv} from '../../util/getSanityEnv.js' +import {type EditorName} from '../mcp/editorConfigs.js' +import {createOrAppendEnvVars} from './env/createOrAppendEnvVars.js' +import {fetchPostInitPrompt} from './fetchPostInitPrompt.js' + +/** + * Returns `true` when the user should be prompted for a flag value: + * i.e. we are NOT in unattended mode AND the flag was not explicitly provided. + */ +export function shouldPrompt(unattended: boolean, flagValue: unknown): boolean { + return !unattended && flagValue === undefined +} + +/** + * Returns the flag value if it is a boolean, otherwise returns the default. + */ +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: 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..e1d6cd629 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/initNextJs.ts @@ -0,0 +1,363 @@ +import {existsSync} from 'node:fs' +import {mkdir, writeFile} from 'node:fs/promises' +import path from 'node:path' +import {styleText} from 'node:util' + +import {type Output, 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 { + flagOrDefault, + getPostInitMCPPrompt, + shouldPrompt, + writeStagingEnvIfNeeded, +} from './initHelpers.js' +import {resolvePackageManager} from './resolvePackageManager.js' +import { + sanityCliTemplate, + sanityConfigTemplate, + sanityFolder, + sanityStudioTemplate, +} from './templates/nextjs/index.js' +import {type VersionedFramework} from './types.js' + +const debug = subdebug('init') + +async function writeOrOverwrite( + filePath: string, + content: string, + workDir: string, + params: {overwriteFiles?: boolean; unattended: boolean}, +) { + if (existsSync(filePath)) { + let overwrite = flagOrDefault(params.overwriteFiles, false) + if (shouldPrompt(params.unattended, params.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, + params, + srcFolderPrefix, + workDir, +}: { + fileExtension: string + files: Record | string> + folderPath?: string + params: {overwriteFiles?: boolean; unattended: boolean} + 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 writeOrOverwrite( + path.join( + workDir, + srcFolderPrefix ? 'src' : '', + 'sanity', + folderPath || '', + `${filePath}${fileExtension}`, + ), + content, + workDir, + params, + ) + } else { + await mkdir(path.join(workDir, srcFolderPrefix ? 'src' : '', 'sanity', filePath), { + recursive: true, + }) + if (typeof content === 'object') { + await writeSourceFiles({ + fileExtension, + files: content, + folderPath: filePath, + params, + srcFolderPrefix, + workDir, + }) + } + } + } +} + +export async function initNextJs({ + datasetName, + detectedFramework, + envFilename, + mcpConfigured, + nextjsAppendEnv, + nextjsEmbedStudio, + output, + overwriteFiles, + packageManager, + projectId, + template, + trace, + typescript, + unattended, + workDir, +}: { + datasetName: string + detectedFramework: VersionedFramework | null + envFilename: string + mcpConfigured: EditorName[] + nextjsAppendEnv?: boolean + nextjsEmbedStudio?: boolean + output: Output + overwriteFiles?: boolean + packageManager?: string + projectId: string + template?: string + trace: TelemetryTrace + typescript?: boolean + unattended: boolean + workDir: string +}): Promise { + let useTypeScript = flagOrDefault(typescript, true) + if (shouldPrompt(unattended, typescript)) { + useTypeScript = await promptForTypeScript() + } + trace.log({ + selectedOption: useTypeScript ? 'yes' : 'no', + step: 'useTypeScript', + }) + + const fileExtension = useTypeScript ? 'ts' : 'js' + let embeddedStudio = flagOrDefault(nextjsEmbedStudio, true) + if (shouldPrompt(unattended, nextjsEmbedStudio)) { + embeddedStudio = await promptForEmbeddedStudio() + } + let hasSrcFolder = false + + const writeParams = {overwriteFiles, unattended} + + 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 = 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(path.dirname(embeddedStudioRouteFilePath.slice(workDir.length))))}sanity.config`, + ), + workDir, + writeParams, + ) + + 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, + writeParams, + ) + } + + const sanityCliPath = path.join(workDir, `sanity.cli.${fileExtension}`) + await writeOrOverwrite(sanityCliPath, sanityCliTemplate, workDir, writeParams) + + let templateToUse = template ?? 'clean' + if (shouldPrompt(unattended, template)) { + templateToUse = await promptForNextTemplate() + } + + await writeSourceFiles({ + fileExtension, + files: sanityFolder(useTypeScript, templateToUse as 'blog' | 'clean'), + folderPath: undefined, + params: writeParams, + srcFolderPrefix: hasSrcFolder, + workDir, + }) + + let appendEnv = flagOrDefault(nextjsAppendEnv, true) + if (shouldPrompt(unattended, 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 (corsError) { + debug(`Error creating new CORS Origin ${nextjsLocalDevOrigin}: ${corsError}`) + output.error(`Failed to add ${nextjsLocalDevOrigin} to CORS origins: ${corsError}`, { + exit: 1, + }) + } + } + } + + const chosen = await resolvePackageManager({ + interactive: !unattended, + output, + packageManager: packageManager as PackageManager, + targetDir: workDir, + }) + trace.log({selectedOption: chosen, step: 'selectPackageManager'}) + const packages = ['@sanity/vision@5', 'sanity@5', '@sanity/image-url@2', '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', 'next-sanity@12'], execOptions) + break + } + case 'pnpm': { + await execa('pnpm', ['install', 'next-sanity@12'], execOptions) + break + } + case 'yarn': { + const peerDeps = await getPeerDependencies('next-sanity@12', workDir) + await installNewPackages( + {packageManager: 'yarn', packages: ['next-sanity@12', ...peerDeps]}, + {output, workDir}, + ) + break + } + default: { + // bun and manual - do nothing or handle differently + 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) +} 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..1661781b8 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/initStudio.ts @@ -0,0 +1,202 @@ +import {styleText} from 'node:util' + +import {getCliToken, type Output, subdebug, type TelemetryUserProperties} from '@sanity/cli-core' +import {confirm} from '@sanity/cli-core/ux' +import {type TelemetryTrace} from '@sanity/telemetry' + +import {ImportDatasetCommand} from '../../commands/datasets/import.js' +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 {getPostInitMCPPrompt} from './initHelpers.js' +import {type RepoInfo} from './remoteTemplate.js' +import {scaffoldAndInstall, selectTemplate} from './scaffoldTemplate.js' + +const debug = subdebug('init') + +async function promptForDatasetImport(message?: string): Promise { + return confirm({ + default: true, + message: message || 'This template includes a sample dataset, would you like to use it?', + }) +} + +export async function initStudio({ + autoUpdates, + datasetName, + defaults, + displayName, + error, + git, + noGit, + importDataset, + isFirstProject, + mcpConfigured, + organizationId, + output, + outputPath, + overwriteFiles, + packageManager, + projectId, + remoteTemplateInfo, + sluggedName, + template, + templateToken, + trace, + typescript, + unattended, + workDir, +}: { + autoUpdates: boolean + datasetName: string + defaults: {projectName: string} + displayName: string + error: Output['error'] + git?: boolean | string + noGit?: boolean + importDataset?: boolean + isFirstProject: boolean + mcpConfigured: EditorName[] + organizationId: string | undefined + output: Output + outputPath: string + overwriteFiles?: boolean + packageManager?: string + projectId: string + remoteTemplateInfo: RepoInfo | undefined + sluggedName: string + template?: string + templateToken?: string + trace: TelemetryTrace + typescript?: boolean + unattended: boolean + workDir: string +}): Promise { + const { + template: resolvedTemplate, + templateName, + useTypeScript, + } = await selectTemplate({ + remoteTemplateInfo, + template, + trace, + typescript, + unattended, + }) + + if (!remoteTemplateInfo && !resolvedTemplate) { + error(`Template "${templateName}" not found`, {exit: 1}) + } + + // If the template has a sample dataset, prompt the user whether or not we should import it + const shouldImport = + resolvedTemplate?.datasetUrl && + (importDataset ?? + (!unattended && (await promptForDatasetImport(resolvedTemplate.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) + } + + const {pkgManager} = await scaffoldAndInstall({ + autoUpdates, + datasetName, + defaults, + displayName, + git, + noGit, + organizationId, + output, + outputPath, + overwriteFiles, + packageManager, + projectId, + remoteTemplateInfo, + sluggedName, + templateName, + templateToken, + trace, + unattended, + useTypeScript, + workDir, + }) + + // Prompt for dataset import (if a dataset is defined) + if (shouldImport && resolvedTemplate?.datasetUrl) { + const token = await getCliToken() + if (!token) { + return error('Authentication required to import dataset', {exit: 1}) + } + await ImportDatasetCommand.run( + [ + resolvedTemplate.datasetUrl, + '--project-id', + projectId, + '--dataset', + datasetName, + '--token', + token, + '--missing', + ], + { + 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 === process.cwd() + const goToProjectDir = `\n(${styleText('cyan', `cd ${outputPath}`)} to navigate to your new project directory)` + + //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') + } +} 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..c4684406f --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/scaffoldTemplate.ts @@ -0,0 +1,188 @@ +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 {writeStagingEnvIfNeeded} from './initHelpers.js' +import {type RepoInfo} from './remoteTemplate.js' +import {resolvePackageManager} from './resolvePackageManager.js' +import templates from './templates/index.js' +import {type ProjectTemplate} from './types.js' + +interface SelectedTemplate { + template: ProjectTemplate | undefined + templateName: string + useTypeScript: boolean | undefined +} + +async function promptForTemplate(params: { + template?: string + unattended: boolean +}): Promise { + const defaultTemplate = params.unattended || params.template ? params.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', + }) +} + +export async function selectTemplate({ + remoteTemplateInfo, + template, + trace, + typescript, + unattended, +}: { + remoteTemplateInfo: RepoInfo | undefined + template?: string + trace: TelemetryTrace + typescript?: boolean + unattended: boolean +}): Promise { + const templateName = await promptForTemplate({template, unattended}) + trace.log({ + selectedOption: templateName, + step: 'selectProjectTemplate', + }) + + const resolvedTemplate = templates[templateName] + + let useTypeScript = typescript + if (!remoteTemplateInfo && resolvedTemplate && resolvedTemplate.typescriptOnly === true) { + useTypeScript = true + } else if (!unattended && typescript === undefined) { + useTypeScript = await promptForTypeScript() + trace.log({ + selectedOption: useTypeScript ? 'yes' : 'no', + step: 'useTypeScript', + }) + } + + return { + template: resolvedTemplate, + templateName, + useTypeScript, + } +} + +export async function scaffoldAndInstall({ + autoUpdates, + datasetName, + defaults, + displayName, + git, + noGit, + organizationId, + output, + outputPath, + overwriteFiles, + packageManager, + projectId, + remoteTemplateInfo, + sluggedName, + templateName, + templateToken, + trace, + unattended, + useTypeScript, + workDir, +}: { + autoUpdates: boolean + datasetName: string + defaults: {projectName: string} + displayName: string + git?: boolean | string + noGit?: boolean + organizationId: string | undefined + output: Output + outputPath: string + overwriteFiles?: boolean + packageManager?: string + projectId: string + remoteTemplateInfo: RepoInfo | undefined + sluggedName: string + templateName: string + templateToken?: string + trace: TelemetryTrace + unattended: boolean + useTypeScript: boolean | undefined + workDir: string +}): Promise<{pkgManager: PackageManager}> { + try { + await bootstrapTemplate({ + autoUpdates, + bearerToken: templateToken, + dataset: datasetName, + organizationId, + output, + outputPath, + overwriteFiles: overwriteFiles as boolean, + packageName: sluggedName, + projectId, + projectName: displayName || defaults.projectName, + remoteTemplateInfo, + templateName, + useTypeScript: useTypeScript as boolean, + }) + } catch (error) { + if (error instanceof Error) { + throw error + } + throw new Error(String(error), {cause: error}) + } + + const pkgManager = await resolvePackageManager({ + interactive: !unattended, + output, + packageManager: 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 = !noGit && (git === undefined || Boolean(git)) + const commitMessage = git + await writeStagingEnvIfNeeded(output, outputPath) + + // Try initializing a git repository + if (useGit) { + tryGitInit(outputPath, typeof commitMessage === 'string' ? commitMessage : undefined) + } + + return {pkgManager} +} 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/commands/__tests__/init/init.nextjs.test.ts b/packages/@sanity/cli/src/commands/__tests__/init/init.nextjs.test.ts index adb8316d7..75c5f9bb1 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/init.ts b/packages/@sanity/cli/src/commands/init.ts index 4b0349fd4..9583ff724 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -1,12 +1,9 @@ -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, @@ -16,53 +13,34 @@ import {confirm, input, logSymbols, select, Separator, spinner} from '@sanity/cl 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 {initApp} from '../actions/init/initApp.js' +import {flagOrDefault, shouldPrompt, writeStagingEnvIfNeeded} from '../actions/init/initHelpers.js' +import {initNextJs} from '../actions/init/initNextJs.js' +import {initStudio} from '../actions/init/initStudio.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 {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 { @@ -72,23 +50,13 @@ 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 {ImportDatasetCommand} from './datasets/import.js' const debug = subdebug('init') @@ -434,8 +402,8 @@ export class InitCommand extends SanityCommand { return } - let initNext = this.flagOrDefault('nextjs-add-config-files', false) - if (isNextJs && this.promptForUndefinedFlag(this.flags['nextjs-add-config-files'])) { + let initNext = flagOrDefault(this.flags['nextjs-add-config-files'], false) + if (isNextJs && shouldPrompt(this.isUnattended(), this.flags['nextjs-add-config-files'])) { initNext = await promptForConfigFiles() } @@ -502,14 +470,25 @@ export class InitCommand extends SanityCommand { } if (initNext) { - await this.initNextJs({ + await initNextJs({ datasetName, detectedFramework, envFilename, mcpConfigured, + nextjsAppendEnv: this.flags['nextjs-append-env'], + nextjsEmbedStudio: this.flags['nextjs-embed-studio'], + output: this.output, + overwriteFiles: this.flags['overwrite-files'], + packageManager: this.flags['package-manager'], projectId, + template: this.flags.template, + trace: this._trace, + typescript: this.flags.typescript, + unattended: this.isUnattended(), workDir, }) + this._trace.complete() + return } // user wants to write environment variables to file @@ -525,199 +504,42 @@ export class InitCommand extends SanityCommand { output: this.output, outputPath, }) - await this.writeStagingEnvIfNeeded(outputPath) + await writeStagingEnvIfNeeded(this.output, 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, - output: this.output, - outputPath, - overwriteFiles: this.flags['overwrite-files'], - packageName: sluggedName, - projectId, - projectName: displayName || defaults.projectName, - remoteTemplateInfo, - templateName, - useTypeScript, - }) - } catch (error) { - if (error instanceof Error) { - throw error - } - throw new Error(String(error), {cause: 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, { + const sharedParams = { + autoUpdates: this.flags['auto-updates'], + defaults, + error: this.error.bind(this) as typeof this.error, + git: this.flags.git, + noGit: this.flags['no-git'], + mcpConfigured, + organizationId, output: this.output, + outputPath, + overwriteFiles: this.flags['overwrite-files'], + packageManager: this.flags['package-manager'], + remoteTemplateInfo, + sluggedName, + template: this.flags.template, + templateToken: this.flags['template-token'], + trace: this._trace, + typescript: this.flags.typescript, + unattended: this.isUnattended(), workDir, - }) - - const useGit = - !this.flags['no-git'] && (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', + await (isAppTemplate + ? initApp(sharedParams) + : initStudio({ + ...sharedParams, datasetName, - '--token', - token, - '--missing', - ], - { - 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') - } + displayName, + importDataset: this.flags['import-dataset'], + isFirstProject, + projectId, + })) this._trace.complete() } @@ -860,10 +682,6 @@ export class InitCommand extends SanityCommand { 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 @@ -1115,10 +933,6 @@ export class InitCommand extends SanityCommand { } } - private async getPostInitMCPPrompt(editorsNames: EditorName[]): Promise { - return fetchPostInitPrompt(new Intl.ListFormat('en').format(editorsNames)) - } - private async getProjectDetails({ isAppTemplate, newProject, @@ -1225,222 +1039,6 @@ export class InitCommand extends SanityCommand { 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(path.dirname(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}) - } - } - } - - 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@5', 'sanity@5', '@sanity/image-url@2', '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', 'next-sanity@12'], execOptions) - break - } - case 'pnpm': { - await execa('pnpm', ['install', 'next-sanity@12'], execOptions) - break - } - case 'yarn': { - const peerDeps = await getPeerDependencies('next-sanity@12', workDir) - await installNewPackages( - {packageManager: 'yarn', packages: ['next-sanity@12', ...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, @@ -1487,41 +1085,6 @@ export class InitCommand extends SanityCommand { } } - 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 { @@ -1658,100 +1221,4 @@ export class InitCommand extends SanityCommand { } } } - - 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, - }) - } }