@sanity/cli
Version:
Sanity CLI tool for managing Sanity installations, managing plugins, schemas and datasets
1,501 lines (1,309 loc) • 56.5 kB
text/typescript
import {existsSync} from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import {type DatasetAclMode, type SanityProject} from '@sanity/client'
import {type Framework} from '@vercel/frameworks'
import {type detectFrameworkRecord} from '@vercel/fs-detectors'
import dotenv from 'dotenv'
import execa, {type CommonOptions} from 'execa'
import {deburr, noop} from 'lodash'
import pMap from 'p-map'
import resolveFrom from 'resolve-from'
import semver from 'semver'
import {CLIInitStepCompleted} from '../../__telemetry__/init.telemetry'
import {type InitFlags} from '../../commands/init/initCommand'
import {debug} from '../../debug'
import {
getPackageManagerChoice,
installDeclaredPackages,
installNewPackages,
} from '../../packageManager'
import {
ALLOWED_PACKAGE_MANAGERS,
allowedPackageManagersString,
getPartialEnvWithNpmPath,
type PackageManager,
} from '../../packageManager/packageManagerChoice'
import {
type CliApiClient,
type CliCommandArguments,
type CliCommandContext,
type CliCommandDefinition,
type SanityCore,
type SanityModuleInternal,
type SanityUser,
} from '../../types'
import {getClientWrapper} from '../../util/clientWrapper'
import {dynamicRequire} from '../../util/dynamicRequire'
import {getProjectDefaults, type ProjectDefaults} from '../../util/getProjectDefaults'
import {getProviderName} from '../../util/getProviderName'
import {getUserConfig} from '../../util/getUserConfig'
import {isCommandGroup} from '../../util/isCommandGroup'
import {isInteractive} from '../../util/isInteractive'
import {fetchJourneyConfig} from '../../util/journeyConfig'
import {checkIsRemoteTemplate, getGitHubRepoInfo, type RepoInfo} from '../../util/remoteTemplate'
import {login, type LoginFlags} from '../login/login'
import {createProject} from '../project/createProject'
import {bootstrapLocalTemplate} from './bootstrapLocalTemplate'
import {bootstrapRemoteTemplate} from './bootstrapRemoteTemplate'
import {type GenerateConfigOptions} from './createStudioConfig'
import {determineAppTemplate} from './determineAppTemplate'
import {absolutify, validateEmptyPath} from './fsUtils'
import {tryGitInit} from './git'
import {promptForDatasetName} from './promptForDatasetName'
import {promptForAclMode, promptForDefaultConfig, promptForTypeScript} from './prompts'
import {
promptForAppendEnv,
promptForEmbeddedStudio,
promptForNextTemplate,
promptForStudioPath,
} from './prompts/nextjs'
import {readPackageJson} from './readPackageJson'
import templates from './templates'
import {
sanityCliTemplate,
sanityConfigTemplate,
sanityFolder,
sanityStudioTemplate,
} from './templates/nextjs'
// eslint-disable-next-line no-process-env
const isCI = Boolean(process.env.CI)
/**
* @deprecated - No longer used
*/
export interface InitOptions {
template: string
outputDir: string
name: string
displayName: string
dataset: string
projectId: string
author: string | undefined
description: string | undefined
gitRemote: string | undefined
license: string | undefined
outputPath: string | undefined
projectName: string
useTypeScript: boolean
}
export interface ProjectTemplate {
datasetUrl?: string
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
importPrompt?: string
configTemplate?: string | ((variables: GenerateConfigOptions['variables']) => string)
typescriptOnly?: boolean
entry?: string
scripts?: Record<string, string>
}
export interface ProjectOrganization {
id: string
name: string
slug: string
}
interface OrganizationCreateResponse {
id: string
name: string
createdByUserId: string
slug: string | null
defaultRoleName: string | null
members: unknown[]
features: unknown[]
}
// eslint-disable-next-line max-statements, complexity
export default async function initSanity(
args: CliCommandArguments<InitFlags>,
context: CliCommandContext & {
detectedFramework: Awaited<ReturnType<typeof detectFrameworkRecord>>
},
): Promise<void> {
const {output, prompt, workDir, apiClient, chalk, telemetry, detectedFramework} = context
const trace = telemetry.trace(CLIInitStepCompleted)
const cliFlags = args.extOptions
const unattended = cliFlags.y || cliFlags.yes
const print = unattended ? noop : output.print
const success = output.success
const warn = output.warn
const intendedPlan = cliFlags['project-plan']
const intendedCoupon = cliFlags.coupon
const reconfigure = cliFlags.reconfigure
const commitMessage = cliFlags.git
const useGit = typeof commitMessage === 'undefined' ? true : Boolean(commitMessage)
const bareOutput = cliFlags.bare
const env = cliFlags.env
const packageManager = cliFlags['package-manager']
let remoteTemplateInfo: RepoInfo | undefined
if (cliFlags.template && checkIsRemoteTemplate(cliFlags.template)) {
remoteTemplateInfo = await getGitHubRepoInfo(cliFlags.template, cliFlags['template-token'])
}
let defaultConfig = cliFlags['dataset-default']
let showDefaultConfigPrompt = !defaultConfig
trace.start()
trace.log({
step: 'start',
flags: {
defaultConfig,
unattended,
plan: intendedPlan,
coupon: intendedCoupon,
reconfigure,
git: commitMessage,
bare: bareOutput,
env,
},
})
if (detectedFramework && detectedFramework.slug !== 'sanity' && remoteTemplateInfo) {
throw new Error(
`A remote template cannot be used with a detected framework. Detected: ${detectedFramework.name}`,
)
}
// Only allow either --project-plan or --coupon
if (intendedCoupon && intendedPlan) {
throw new Error(
'Error! --project-plan and --coupon cannot be used together; please select only one flag',
)
}
// Don't allow --coupon and --project
if (intendedCoupon && cliFlags.project) {
throw new Error(
'Error! --project and --coupon cannot be used together; coupons can only be applied to new projects',
)
}
let selectedPlan: string | undefined
if (intendedCoupon) {
try {
selectedPlan = await getPlanFromCoupon(apiClient, intendedCoupon)
print(`Coupon "${intendedCoupon}" validated!\n`)
} catch (err) {
if (err.statusCode == '404') {
const useDefaultPlan =
unattended ??
(await prompt.single({
type: 'confirm',
message: `Coupon "${intendedCoupon}" is not available, use default plan instead?`,
default: true,
}))
if (unattended) {
output.warn(`Coupon "${intendedCoupon}" is not available - using default plan`)
}
trace.log({
step: 'useDefaultPlanCoupon',
selectedOption: useDefaultPlan ? 'yes' : 'no',
coupon: intendedCoupon,
})
if (useDefaultPlan) {
print(`Using default plan.`)
} else {
throw new Error(`Coupon "${intendedCoupon}" does not exist`)
}
} else {
throw new Error(`Unable to validate coupon, please try again later:\n\n${err.message}`)
}
}
} else if (intendedPlan) {
try {
selectedPlan = await getPlanFromId(apiClient, intendedPlan)
} catch (err) {
if (err.statusCode == '404') {
const useDefaultPlan =
unattended ??
(await prompt.single({
type: 'confirm',
message: `Project plan "${intendedPlan}" does not exist, use default plan instead?`,
default: true,
}))
if (unattended) {
output.warn(`Project plan "${intendedPlan}" does not exist - using default plan`)
}
trace.log({
step: 'useDefaultPlanId',
selectedOption: useDefaultPlan ? 'yes' : 'no',
planId: intendedPlan,
})
if (useDefaultPlan) {
print(`Using default plan.`)
} else {
throw new Error(`Plan id "${intendedPlan}" does not exist`)
}
} else {
throw new Error(`Unable to validate plan, please try again later:\n\n${err.message}`)
}
}
}
if (reconfigure) {
throw new Error('`--reconfigure` is deprecated - manual configuration is now required')
}
let envFilenameDefault = '.env'
if (detectedFramework && detectedFramework.slug === 'nextjs') {
envFilenameDefault = '.env.local'
}
const envFilename = typeof env === 'string' ? env : envFilenameDefault
if (!envFilename.startsWith('.env')) {
throw new Error('Env filename must start with .env')
}
// If the user isn't already authenticated, make it so
const userConfig = getUserConfig()
const hasToken = userConfig.get('authToken')
debug(hasToken ? 'User already has a token' : 'User has no token')
let user: SanityUser | undefined
if (hasToken) {
trace.log({step: 'login', alreadyLoggedIn: true})
user = await getUserData(apiClient)
success('You are logged in as %s using %s', user.email, getProviderName(user.provider))
} else if (!unattended) {
trace.log({step: 'login'})
user = await getOrCreateUser()
}
// skip project / dataset prompting
const isAppTemplate = cliFlags.template ? determineAppTemplate(cliFlags.template) : false // Default to false
let introMessage = 'Fetching existing projects'
if (cliFlags.quickstart) {
introMessage = "Eject your existing project's Sanity configuration"
}
if (!isAppTemplate) {
success(introMessage)
print('')
}
const isNextJs = detectedFramework?.slug === 'nextjs'
const flags = await prepareFlags()
// We're authenticated, now lets select or create a project (for studios) or org (for custom apps)
const {projectId, displayName, isFirstProject, datasetName, schemaUrl, organizationId} =
await getProjectDetails()
const sluggedName = deburr(displayName.toLowerCase())
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
// If user doesn't want to output any template code
if (bareOutput) {
success('Below are your project details')
print('')
print(`Project ID: ${chalk.cyan(projectId)}`)
print(`Dataset: ${chalk.cyan(datasetName)}`)
print(
`\nYou can find your project on Sanity Manage — https://www.sanity.io/manage/project/${projectId}\n`,
)
return
}
let initNext = false
if (isNextJs) {
initNext =
unattended ||
(await prompt.single({
type: 'confirm',
message:
'Would you like to add configuration files for a Sanity project in this Next.js folder?',
default: true,
}))
trace.log({
step: 'useDetectedFramework',
selectedOption: initNext ? 'yes' : 'no',
detectedFramework: detectedFramework?.name,
})
}
// add more frameworks to this as we add support for them
// this is used to skip the getProjectInfo prompt
const initFramework = initNext
let outputPath = workDir
// Gather project defaults based on environment
const defaults = await getProjectDefaults(workDir, {isPlugin: false, context})
// Prompt the user for required information
const answers = await getProjectInfo()
// Ensure we are using the output path provided by user
outputPath = answers.outputPath
if (isNextJs) {
const packageJson = readPackageJson(`${outputPath}/package.json`)
const reactVersion = packageJson?.dependencies?.react
if (reactVersion) {
const isUsingReact19 = semver.coerce(reactVersion)?.major === 19
const isUsingNextJs15 = semver.coerce(detectedFramework?.detectedVersion)?.major === 15
if (isUsingNextJs15 && isUsingReact19) {
warn('╭────────────────────────────────────────────────────────────╮')
warn('│ │')
warn('│ It looks like you are using Next.js 15 and React 19 │')
warn('│ Please read our compatibility guide. │')
warn('│ https://www.sanity.io/help/react-19 │')
warn('│ │')
warn('╰────────────────────────────────────────────────────────────╯')
}
}
}
if (initNext) {
const useTypeScript = unattended ? true : await promptForTypeScript(prompt)
trace.log({step: 'useTypeScript', selectedOption: useTypeScript ? 'yes' : 'no'})
const fileExtension = useTypeScript ? 'ts' : 'js'
const embeddedStudio = unattended ? true : await promptForEmbeddedStudio(prompt)
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)) {
await fs
.mkdir(srcPath, {recursive: true})
.catch(() => debug('Error creating folder %s', srcPath))
}
}
const studioPath = unattended ? '/studio' : await promptForStudioPath(prompt)
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:',
new Array(countNestedFolders(embeddedStudioRouteFilePath.slice(workDir.length)))
.join('../')
.concat('sanity.config'),
),
)
const sanityConfigPath = path.join(workDir, `sanity.config.${fileExtension}`)
await writeOrOverwrite(
sanityConfigPath,
sanityConfigTemplate(hasSrcFolder)
.replace(':route:', embeddedStudioRouteFilePath.slice(workDir.length).replace('src/', ''))
.replace(':basePath:', studioPath),
)
}
const sanityCliPath = path.join(workDir, `sanity.cli.${fileExtension}`)
await writeOrOverwrite(sanityCliPath, sanityCliTemplate)
// write sanity folder files
const writeSourceFiles = async (
files: Record<string, string | Record<string, string>>,
folderPath?: string,
srcFolderPrefix?: boolean,
) => {
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,
)
} else {
await fs.mkdir(path.join(workDir, srcFolderPrefix ? 'src' : '', 'sanity', filePath), {
recursive: true,
})
if (typeof content === 'object') {
await writeSourceFiles(content, filePath, srcFolderPrefix)
}
}
}
}
// ask what kind of schema setup the user wants
const templateToUse = unattended ? 'clean' : await promptForNextTemplate(prompt)
await writeSourceFiles(sanityFolder(useTypeScript, templateToUse), undefined, hasSrcFolder)
const appendEnv = unattended ? true : await promptForAppendEnv(prompt, envFilename)
if (appendEnv) {
await createOrAppendEnvVars(envFilename, detectedFramework, {log: true})
}
if (embeddedStudio) {
const nextjsLocalDevOrigin = 'http://localhost:3000'
const existingCorsOrigins = await apiClient({api: {projectId}}).request({
method: 'GET',
uri: '/cors',
})
const hasExistingCorsOrigin = existingCorsOrigins.some(
(item: {origin: string}) => item.origin === nextjsLocalDevOrigin,
)
if (!hasExistingCorsOrigin) {
await apiClient({api: {projectId}})
.request({
method: 'POST',
url: '/cors',
body: {origin: nextjsLocalDevOrigin, allowCredentials: true},
maxRedirects: 0,
})
.then((res) => {
print(
res.id
? `Added ${nextjsLocalDevOrigin} to CORS origins`
: `Failed to add ${nextjsLocalDevOrigin} to CORS origins`,
)
})
.catch((error) => {
print(`Failed to add ${nextjsLocalDevOrigin} to CORS origins`, error)
})
}
}
const {chosen} = await getPackageManagerChoice(workDir, {interactive: false})
trace.log({step: 'selectPackageManager', selectedOption: chosen})
const packages = ['@sanity/vision@3', 'sanity@3', '@sanity/image-url@1', 'styled-components@6']
if (templateToUse === 'blog') {
packages.push('@sanity/icons')
}
await installNewPackages(
{
packageManager: chosen,
packages,
},
{
output: context.output,
workDir,
},
)
// will refactor this later
const execOptions: CommonOptions<'utf8'> = {
encoding: 'utf8',
env: getPartialEnvWithNpmPath(workDir),
cwd: workDir,
stdio: 'inherit',
}
if (chosen === 'npm') {
await execa('npm', ['install', '--legacy-peer-deps', 'next-sanity@9'], execOptions)
} else if (chosen === 'yarn') {
await execa('npx', ['install-peerdeps', '--yarn', 'next-sanity@9'], execOptions)
} else if (chosen === 'pnpm') {
await execa('pnpm', ['install', 'next-sanity@9'], execOptions)
}
print(
`\n${chalk.green('Success!')} Your Sanity configuration files has been added to this project`,
)
return
}
// eslint-disable-next-line @typescript-eslint/no-shadow
function countNestedFolders(path: string): number {
const separator = path.includes('\\') ? '\\' : '/'
return path.split(separator).filter(Boolean).length
}
async function writeOrOverwrite(filePath: string, content: string) {
if (existsSync(filePath)) {
const overwrite = await prompt.single({
type: 'confirm',
message: `File ${chalk.yellow(
filePath.replace(workDir, ''),
)} already exists. Do you want to overwrite it?`,
default: false,
})
if (!overwrite) {
return
}
}
// make folder if not exists
const folderPath = path.dirname(filePath)
await fs
.mkdir(folderPath, {recursive: true})
.catch(() => debug('Error creating folder %s', folderPath))
await fs.writeFile(filePath, content, {
encoding: 'utf8',
})
}
// user wants to write environment variables to file
if (env) {
await createOrAppendEnvVars(envFilename, detectedFramework)
return
}
// Prompt for template to use
const templateName = await selectProjectTemplate()
trace.log({step: 'selectProjectTemplate', selectedOption: templateName})
const template = templates[templateName]
if (!remoteTemplateInfo && !template) {
throw new Error(`Template "${templateName}" not found`)
}
// Use typescript?
let useTypeScript = true
if (!remoteTemplateInfo && template) {
const typescriptOnly = template.typescriptOnly === true
if (!typescriptOnly && typeof cliFlags.typescript === 'boolean') {
useTypeScript = cliFlags.typescript
} else if (!typescriptOnly && !unattended) {
useTypeScript = await promptForTypeScript(prompt)
trace.log({step: 'useTypeScript', selectedOption: useTypeScript ? 'yes' : 'no'})
}
}
// we enable auto-updates by default, but allow users to specify otherwise
let autoUpdates = true
if (typeof cliFlags['auto-updates'] === 'boolean') {
autoUpdates = cliFlags['auto-updates']
}
// If the template has a sample dataset, prompt the user whether or not we should import it
const shouldImport =
!unattended && template?.datasetUrl && (await promptForDatasetImport(template.importPrompt))
trace.log({step: 'importTemplateDataset', selectedOption: shouldImport ? 'yes' : 'no'})
const [_, bootstrapPromise] = await Promise.allSettled([
updateProjectCliInitializedMetadata(),
bootstrapTemplate(),
])
if (bootstrapPromise.status === 'rejected' && bootstrapPromise.reason instanceof Error) {
throw bootstrapPromise.reason
}
let pkgManager: PackageManager
// If the user has specified a package manager, and it's allowed use that
if (packageManager && ALLOWED_PACKAGE_MANAGERS.includes(packageManager)) {
pkgManager = packageManager
} else {
// Otherwise, try to find the most optimal package manager to use
pkgManager = (
await getPackageManagerChoice(outputPath, {
prompt,
interactive: unattended ? false : isInteractive,
})
).chosen
// only log warning if a package manager flag is passed
if (packageManager) {
output.warn(
chalk.yellow(
`Given package manager "${packageManager}" is not supported. Supported package managers are ${allowedPackageManagersString}.`,
),
)
output.print(`Using ${pkgManager} as package manager`)
}
}
trace.log({step: 'selectPackageManager', selectedOption: pkgManager})
// Now for the slow part... installing dependencies
await installDeclaredPackages(outputPath, pkgManager, context)
// 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) {
const importCommand = getImportCommand(outputPath, 3)
await doDatasetImport({
projectId,
outputPath,
importCommand,
template,
datasetName,
context,
})
print('')
print('If you want to delete the imported data, use')
print(` ${chalk.cyan(`npx sanity dataset delete ${datasetName}`)}`)
print('and create a new clean dataset with')
print(` ${chalk.cyan(`npx sanity dataset create <name>`)}\n`)
}
const devCommandMap: Record<PackageManager, string> = {
yarn: 'yarn dev',
npm: 'npm run dev',
pnpm: 'pnpm dev',
bun: 'bun dev',
manual: 'npm run dev',
}
const devCommand = devCommandMap[pkgManager]
const isCurrentDir = outputPath === process.cwd()
const goToProjectDir = `(${chalk.cyan(`cd ${outputPath}`)} to navigate to your new project directory)`
if (isAppTemplate) {
//output for custom apps here
print(`✅ ${chalk.green.bold('Success!')} Your custom app has been scaffolded.`)
if (!isCurrentDir) print(goToProjectDir)
print(
`\n${chalk.bold('Next')}, configure the project(s) and dataset(s) your app should work with.`,
)
print('\nGet started in `src/App.tsx`, or refer to our documentation for a walkthrough:')
print(chalk.blue.underline('https://www.sanity.io/docs/app-sdk/sdk-configuration'))
print('\n')
print(`Other helpful commands:`)
print(`npx sanity docs to open the documentation in a browser`)
print(`npx sanity dev to start the development server for your app`)
print(`npx sanity deploy to deploy your app`)
} else {
//output for Studios here
print(`✅ ${chalk.green.bold('Success!')} Your Studio has been created.`)
if (!isCurrentDir) print(goToProjectDir)
print(
`Get started by running ${chalk.cyan(devCommand)} to launch your Studio’s development server`,
)
print('\n')
print(`Other helpful commands:`)
print(`npx sanity docs to open the documentation in a browser`)
print(`npx sanity manage to open the project settings in a browser`)
print(`npx sanity help to explore the CLI manual`)
}
if (isFirstProject) {
trace.log({step: 'sendCommunityInvite', selectedOption: 'yes'})
const DISCORD_INVITE_LINK = 'https://snty.link/community'
print(`\nJoin our wonderful developer community as well: ${chalk.cyan(DISCORD_INVITE_LINK)}`)
print('We look forward to seeing you there!\n')
}
trace.complete()
async function getOrCreateUser() {
warn('No authentication credentials found in your Sanity config')
print('')
// Provide login options (`sanity login`)
const {extOptions: _extOptions, ...otherArgs} = args
const loginArgs: CliCommandArguments<LoginFlags> = {...otherArgs, extOptions: {}}
await login(loginArgs, {...context, telemetry: trace.newContext('login')})
return getUserData(apiClient)
}
async function getProjectDetails(): Promise<{
projectId: string
datasetName: string
displayName: string
isFirstProject: boolean
schemaUrl?: string
organizationId?: string
}> {
// If we're doing a quickstart, we don't need to prompt for project details
if (flags.quickstart) {
debug('Fetching project details from Journey API')
const data = await fetchJourneyConfig(apiClient, flags.quickstart)
trace.log({
step: 'fetchJourneyConfig',
projectId: data.projectId,
datasetName: data.datasetName,
displayName: data.displayName,
isFirstProject: data.isFirstProject,
})
return data
}
if (isAppTemplate) {
const client = apiClient({requireUser: true, requireProject: false})
const organizations = await client.request({
uri: '/organizations',
query: {
includeMembers: 'true',
includeImplicitMemberships: 'true',
},
})
const appOrganizationId = await getOrganizationIdForAppTemplate(organizations)
return {
projectId: '',
displayName: '',
datasetName: '',
isFirstProject: false,
organizationId: appOrganizationId,
}
}
debug('Prompting user to select or create a project')
const project = await getOrCreateProject()
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 getOrCreateDataset({
projectId: project.projectId,
displayName: project.displayName,
dataset: flags.dataset,
aclMode: flags.visibility,
defaultConfig: flags['dataset-default'],
})
debug(`Dataset with name ${dataset.datasetName} selected`)
trace.log({
step: 'createOrSelectDataset',
selectedOption: dataset.userAction,
datasetName: dataset.datasetName,
visibility: flags.visibility as 'private' | 'public',
})
return {
projectId: project.projectId,
displayName: project.displayName,
isFirstProject: project.isFirstProject,
datasetName: dataset.datasetName,
}
}
// eslint-disable-next-line complexity
async function getOrCreateProject(): Promise<{
projectId: string
displayName: string
isFirstProject: boolean
userAction: 'create' | 'select'
}> {
const client = apiClient({requireUser: true, requireProject: false})
let projects
let organizations: ProjectOrganization[]
try {
const [allProjects, allOrgs] = await Promise.all([
client.projects.list({includeMembers: false}),
client.request({uri: '/organizations'}),
])
projects = allProjects.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
organizations = allOrgs
} catch (err) {
if (unattended && flags.project) {
return {
projectId: flags.project,
displayName: 'Unknown project',
isFirstProject: false,
userAction: 'select',
}
}
throw new Error(`Failed to communicate with the Sanity API:\n${err.message}`)
}
if (projects.length === 0 && unattended) {
throw new Error('No projects found for current user')
}
if (flags.project) {
const project = projects.find((proj) => proj.id === flags.project)
if (!project && !unattended) {
throw new Error(
`Given project ID (${flags.project}) not found, or you do not have access to it`,
)
}
return {
projectId: flags.project,
displayName: project ? project.displayName : 'Unknown project',
isFirstProject: false,
userAction: 'select',
}
}
if (flags.organization) {
const organization =
organizations.find((org) => org.id === flags.organization) ||
organizations.find((org) => org.slug === flags.organization)
if (!organization) {
throw new Error(
`Given organization ID (${flags.organization}) not found, or you do not have access to it`,
)
}
if (!(await hasProjectAttachGrant(flags.organization))) {
throw new Error(
'You lack the necessary permissions to attach a project to this organization',
)
}
}
// 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 || intendedCoupon) {
debug(
isUsersFirstProject
? 'No projects found for user, prompting for name'
: 'Using a coupon - skipping project selection',
)
const projectName = await prompt.single({
type: 'input',
message: 'Project name:',
default: 'My Sanity Project',
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
},
})
return createProject(apiClient, {
displayName: projectName,
organizationId: cliFlags.organization || (await getOrganizationId(organizations)),
subscription: selectedPlan ? {planId: selectedPlan} : undefined,
metadata: {coupon: intendedCoupon},
}).then((response) => ({
...response,
isFirstProject: isUsersFirstProject,
userAction: 'create',
coupon: intendedCoupon,
}))
}
debug(`User has ${projects.length} project(s) already, showing list of choices`)
const projectChoices = projects.map((project) => ({
value: project.id,
name: `${project.displayName} (${project.id})`,
}))
const selected = await prompt.single({
message: 'Create a new project or select an existing one',
type: 'list',
choices: [
{value: 'new', name: 'Create new project'},
new prompt.Separator(),
...projectChoices,
],
})
if (selected === 'new') {
debug('User wants to create a new project, prompting for name')
return createProject(apiClient, {
displayName: await prompt.single({
type: 'input',
message: 'Your project name:',
default: 'My Sanity Project',
}),
organizationId: cliFlags.organization || (await getOrganizationId(organizations)),
subscription: selectedPlan ? {planId: selectedPlan} : undefined,
metadata: {coupon: intendedCoupon},
}).then((response) => ({
...response,
isFirstProject: isUsersFirstProject,
userAction: 'create',
}))
}
debug(`Returning selected project (${selected})`)
return {
projectId: selected,
displayName: projects.find((proj) => proj.id === selected)?.displayName || '',
isFirstProject: isUsersFirstProject,
userAction: 'select',
}
}
async function getOrCreateDataset(opts: {
projectId: string
displayName: string
dataset?: string
aclMode?: string
defaultConfig?: boolean
}): Promise<{datasetName: string; userAction: 'create' | 'select' | 'none'}> {
if (opts.dataset && (isCI || unattended)) {
return {datasetName: opts.dataset, userAction: 'none'}
}
const client = apiClient({api: {projectId: opts.projectId}})
const [datasets, projectFeatures] = await Promise.all([
client.datasets.list(),
client.request({uri: '/features'}),
])
const privateDatasetsAllowed = projectFeatures.includes('privateDataset')
const allowedModes = privateDatasetsAllowed ? ['public', 'private'] : ['public']
if (opts.aclMode && !allowedModes.includes(opts.aclMode)) {
throw new Error(`Visibility mode "${opts.aclMode}" not allowed`)
}
// Getter in order to present prompts in a more logical order
const getAclMode = async (): Promise<string> => {
if (opts.aclMode) {
return opts.aclMode
}
if (unattended || !privateDatasetsAllowed || defaultConfig) {
return 'public'
}
if (privateDatasetsAllowed) {
const mode = await promptForAclMode(prompt, output)
return mode
}
return 'public'
}
if (opts.dataset) {
debug('User has specified dataset through a flag (%s)', opts.dataset)
const existing = datasets.find((ds) => ds.name === opts.dataset)
if (!existing) {
debug('Specified dataset not found, creating it')
const aclMode = await getAclMode()
const spinner = context.output.spinner('Creating dataset').start()
await client.datasets.create(opts.dataset, {aclMode: aclMode as DatasetAclMode})
spinner.succeed()
}
return {datasetName: opts.dataset, userAction: 'none'}
}
const datasetInfo =
'Your content will be stored in a dataset that can be public or private, depending on\nwhether you want to query your content with or without authentication.\nThe default dataset configuration has a public dataset named "production".'
if (datasets.length === 0) {
debug('No datasets found for project, prompting for name')
if (showDefaultConfigPrompt) {
output.print(datasetInfo)
defaultConfig = await promptForDefaultConfig(prompt)
}
const name = defaultConfig
? 'production'
: await promptForDatasetName(prompt, {
message: 'Name of your first dataset:',
})
const aclMode = await getAclMode()
const spinner = context.output.spinner('Creating dataset').start()
await client.datasets.create(name, {aclMode: aclMode as DatasetAclMode})
spinner.succeed()
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 prompt.single({
message: 'Select dataset to use',
type: 'list',
choices: [
{value: 'new', name: 'Create new dataset'},
new prompt.Separator(),
...datasetChoices,
],
})
if (selected === 'new') {
const existingDatasetNames = datasets.map((ds) => ds.name)
debug('User wants to create a new dataset, prompting for name')
if (showDefaultConfigPrompt && !existingDatasetNames.includes('production')) {
output.print(datasetInfo)
defaultConfig = await promptForDefaultConfig(prompt)
}
const newDatasetName = defaultConfig
? 'production'
: await promptForDatasetName(
prompt,
{
message: 'Dataset name:',
},
existingDatasetNames,
)
const aclMode = await getAclMode()
const spinner = context.output.spinner('Creating dataset').start()
await client.datasets.create(newDatasetName, {aclMode: aclMode as DatasetAclMode})
spinner.succeed()
return {datasetName: newDatasetName, userAction: 'create'}
}
debug(`Returning selected dataset (${selected})`)
return {datasetName: selected, userAction: 'select'}
}
function promptForDatasetImport(message?: string) {
return prompt.single({
type: 'confirm',
message: message || 'This template includes a sample dataset, would you like to use it?',
default: true,
})
}
function selectProjectTemplate() {
// Make sure the --quickstart and --template are not used together
if (flags.quickstart) {
return 'quickstart'
}
const defaultTemplate = unattended || flags.template ? flags.template || 'clean' : null
if (defaultTemplate) {
return defaultTemplate
}
return prompt.single({
message: 'Select project template',
type: 'list',
choices: [
{
value: 'clean',
name: 'Clean project with no predefined schema types',
},
{
value: 'blog',
name: 'Blog (schema)',
},
{
value: 'shopify',
name: 'E-commerce (Shopify)',
},
{
value: 'moviedb',
name: 'Movie project (schema + sample data)',
},
],
})
}
async function updateProjectCliInitializedMetadata() {
try {
const client = apiClient({api: {projectId}})
const project = await client.request<SanityProject>({uri: `/projects/${projectId}`})
if (!project?.metadata?.cliInitializedAt) {
await client.request({
method: 'PATCH',
uri: `/projects/${projectId}`,
body: {metadata: {cliInitializedAt: new Date().toISOString()}},
})
}
} catch {
// Non-critical update
debug('Failed to update cliInitializedAt metadata')
}
}
async function bootstrapTemplate() {
const bootstrapVariables: GenerateConfigOptions['variables'] = {
autoUpdates,
dataset: datasetName,
projectId,
projectName: displayName || answers.projectName,
organizationId,
}
if (remoteTemplateInfo) {
return bootstrapRemoteTemplate(
{
outputPath,
packageName: sluggedName,
repoInfo: remoteTemplateInfo,
bearerToken: cliFlags['template-token'],
variables: bootstrapVariables,
},
context,
)
}
return bootstrapLocalTemplate(
{
outputPath,
packageName: sluggedName,
templateName,
schemaUrl,
useTypeScript,
variables: bootstrapVariables,
},
context,
)
}
async function getProjectInfo(): Promise<ProjectDefaults & {outputPath: string}> {
const specifiedPath = flags['output-path'] && path.resolve(flags['output-path'])
if (unattended || specifiedPath || env || initFramework) {
return {
...defaults,
outputPath: specifiedPath || workDir,
}
}
const workDirIsEmpty = (await fs.readdir(workDir)).length === 0
const projectOutputPath = await prompt.single({
type: 'input',
message: 'Project output path:',
default: workDirIsEmpty ? workDir : path.join(workDir, sluggedName),
validate: validateEmptyPath,
filter: absolutify,
})
return {
...defaults,
outputPath: projectOutputPath,
}
}
// eslint-disable-next-line complexity
async function prepareFlags() {
const createProjectName = cliFlags['create-project']
if (cliFlags.dataset || cliFlags.visibility || cliFlags['dataset-default'] || unattended) {
showDefaultConfigPrompt = false
}
if (cliFlags.project && createProjectName) {
throw new Error(
'Both `--project` and `--create-project` specified, only a single is supported',
)
}
if (cliFlags.project && cliFlags.organization) {
throw new Error(
'You have specified both a project and an organization. To move a project to an organization please visit https://www.sanity.io/manage',
)
}
if (
cliFlags.quickstart &&
(cliFlags.project || cliFlags.dataset || cliFlags.visibility || cliFlags.template)
) {
const disallowed = ['project', 'dataset', 'visibility', 'template']
const usedDisallowed = disallowed.filter((flag) => cliFlags[flag as keyof InitFlags])
const usedDisallowedStr = usedDisallowed.map((flag) => `--${flag}`).join(', ')
throw new Error(`\`--quickstart\` cannot be combined with ${usedDisallowedStr}`)
}
if (createProjectName === true) {
throw new Error('Please specify a project name (`--create-project <name>`)')
}
if (typeof createProjectName === 'string' && createProjectName.trim().length === 0) {
throw new Error('Please specify a project name (`--create-project <name>`)')
}
if (unattended) {
debug('Unattended mode, validating required options')
if (!cliFlags['dataset' as const]) {
throw new Error(`\`--dataset\` must be specified in unattended mode`)
}
// output-path is not used in unattended mode within nextjs
if (!isNextJs && !cliFlags['output-path' as const]) {
throw new Error(`\`--output-path\` must be specified in unattended mode`)
}
if (!cliFlags.project && !createProjectName) {
throw new Error(
'`--project <id>` or `--create-project <name>` must be specified in unattended mode',
)
}
if (createProjectName && !cliFlags.organization) {
throw new Error(
'--create-project is not supported in unattended mode without an organization, please specify an organization with `--organization <id>`',
)
}
}
if (createProjectName) {
debug('--create-project specified, creating a new project')
let orgForCreateProjectFlag = cliFlags.organization
if (!orgForCreateProjectFlag) {
debug('no organization specified, selecting one')
const client = apiClient({requireUser: true, requireProject: false})
const organizations = await client.request({uri: '/organizations'})
orgForCreateProjectFlag = await getOrganizationId(organizations)
}
debug('creating a new project')
const createdProject = await createProject(apiClient, {
displayName: createProjectName.trim(),
organizationId: orgForCreateProjectFlag,
subscription: selectedPlan ? {planId: selectedPlan} : undefined,
metadata: {coupon: intendedCoupon},
})
debug('Project with ID %s created', createdProject.projectId)
if (cliFlags.dataset) {
debug('--dataset specified, creating dataset (%s)', cliFlags.dataset)
const client = apiClient({api: {projectId: createdProject.projectId}})
const spinner = context.output.spinner('Creating dataset').start()
const createBody = cliFlags.visibility
? {aclMode: cliFlags.visibility as DatasetAclMode}
: {}
await client.datasets.create(cliFlags.dataset, createBody)
spinner.succeed()
}
const newFlags = {...cliFlags, project: createdProject.projectId}
delete newFlags['create-project']
return newFlags
}
return cliFlags
}
async function createOrganization(
props: {name?: string} = {},
): Promise<OrganizationCreateResponse> {
const name =
props.name ||
(await prompt.single({
type: 'input',
message: 'Organization name:',
default: user ? user.name : undefined,
validate(input) {
if (input.length === 0) {
return 'Organization name cannot be empty'
} else if (input.length > 100) {
return 'Organization name cannot be longer than 100 characters'
}
return true
},
}))
const spinner = context.output.spinner('Creating organization').start()
const client = apiClient({requireProject: false, requireUser: true})
const organization = await client.request({
uri: '/organizations',
method: 'POST',
body: {name},
})
spinner.succeed()
return organization
}
async function getOrganizationIdForAppTemplate(organizations: ProjectOrganization[]) {
// If the user is using an app template, we don't need to check for attach access
const organizationChoices = [
...organizations.map((organization) => ({
value: organization.id,
name: `${organization.name} [${organization.id}]`,
})),
new prompt.Separator(),
{value: '-new-', name: 'Create new organization'},
new prompt.Separator(),
]
// If the user only has a single organization, we'll default to that one.
const defaultOrganizationId =
organizations.length === 1
? organizations[0].id
: organizations.find((org) => org.name === user?.name)?.id
const chosenOrg = await prompt.single({
message: 'Select organization:',
type: 'list',
default: defaultOrganizationId || undefined,
choices: organizationChoices,
})
if (chosenOrg === '-new-') {
return createOrganization().then((org) => org.id)
}
return chosenOrg || undefined
}
async function getOrganizationId(organizations: ProjectOrganization[]) {
// 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) {
return createOrganization().then((org) => org.id)
}
// If the user has organizations, let them choose from them, but also allow them to
// create a new one in case they do not have access to any of them, or they want to
// create a personal/other organization.
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)
const organizationChoices = [
...withGrantInfo.map(({organization, hasAttachGrant}) => ({
value: organization.id,
name: `${organization.name} [${organization.id}]`,
disabled: hasAttachGrant ? false : 'Insufficient permissions',
})),
new prompt.Separator(),
{value: '-new-', name: 'Create new organization'},
new prompt.Separator(),
]
// If the user only has a single organization (and they have attach access to it),
// we'll default to that one. Otherwise, we'll default to the organization with the
// same name as the user if it exists.
const defaultOrganizationId =
withAttach.length === 1
? withAttach[0].organization.id
: organizations.find((org) => org.name === user?.name)?.id
const chosenOrg = await prompt.single({
message: 'Select organization:',
type: 'list',
default: defaultOrganizationId || undefined,
choices: organizationChoices,
})
if (chosenOrg === '-new-') {
return createOrganization().then((org) => org.id)
}
return chosenOrg || undefined
}
async function hasProjectAttachGrant(orgId: string) {
const requiredGrantGroup = 'sanity.organization.projects'
const requiredGrant = 'attach'
const client = apiClient({requireProject: false, requireUser: true})
.clone()
.config({apiVersion: 'v2021-06-07'})
try {
const grants = await client.request({uri: `organizations/${orgId}/grants`})
const group: {grants: {name: string}[]}[] = grants[requiredGrantGroup] || []
return group.some(
(resource) =>
resource.grants && resource.grants.some((grant) => grant.name === requiredGrant),
)
} catch (err) {
// If we get a 401, it means we don't have access to this organization
// probably because of implicit membership
if (err.statusCode === 401) {
debug('No access to organization %s (401)', orgId)
return false
}
// For other errors, log them but still return false
debug('Error checking grants for organization %s: %s', orgId, err.message)
return false
}
}
function getOrganizationsWithAttachGrantInfo(organizations: ProjectOrganization[]) {
return pMap(
organizations,
async (organization) => ({
hasAttachGrant: await hasProjectAttachGrant(organization.id),
organization,
}),
{concurrency: 3},
)
}
async function createOrAppendEnvVars(
filename: string,
framework: Framework | null,
options?: {log?: boolean},
) {
// we will prepend SANITY_ to these variables later, together with the prefix
const envVars = {
PROJECT_ID: projectId,
DATASET: datasetName,
}
try {
if (framework && framework.envPrefix && !options?.log) {
print(
`\nDetected framework ${chalk.blue(framework?.name)}, using prefix '${
framework.envPrefix
}'`,
)
}
await writeEnvVarsToFile(filename, envVars, {
framework,
outputPath,
log: options?.log,
})
} catch (err) {
print(err)
throw new Error('An error occurred while creating .env', {cause: err})
}
}
async function writeEnvVarsToFile(
filename: string,
envVars: Record<string, string>,
options: {framework: Framework | null; outputPath: string; log?: boolean},
) {
const envPrefix = options.framework?.envPrefix || ''
const keyPrefix = envPrefix.includes('SANITY') ? envPrefix : `${envPrefix}SANITY_`
const fileOutputPath = path.join(options.outputPath, filename)
// prepend framework and sanity prefix to envVars
for (const key of Object.keys(envVars)) {
envVars[`${keyPrefix}${key}`] = envVars[key]
delete envVars[key]
}
// make folder if not exists (if output path is specified)
await fs
.mkdir(options.outputPath, {recursive: true})
.catch(() => d