@sanity/cli
Version:
Sanity CLI tool for managing Sanity installations, managing plugins, schemas and datasets
351 lines (298 loc) • 11.7 kB
text/typescript
/* eslint-disable no-console, no-process-exit, no-sync */
import {existsSync} from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import chalk from 'chalk'
import dotenv from 'dotenv'
import resolveFrom from 'resolve-from'
import {CliCommand} from './__telemetry__/cli.telemetry'
import {getCliRunner} from './CommandRunner'
import {baseCommands} from './commands'
import {debug} from './debug'
import {getInstallCommand} from './packageManager'
import {type CommandRunnerOptions, type TelemetryUserProperties} from './types'
import {createTelemetryStore} from './util/createTelemetryStore'
import {detectRuntime} from './util/detectRuntime'
import {type CliConfigResult, getCliConfig} from './util/getCliConfig'
import {loadEnv} from './util/loadEnv'
import {mergeCommands} from './util/mergeCommands'
import {neatStack} from './util/neatStack'
import {parseArguments} from './util/parseArguments'
import {resolveRootDir} from './util/resolveRootDir'
import {telemetryDisclosure} from './util/telemetryDisclosure'
import {runUpdateCheck} from './util/updateNotifier'
const sanityEnv = process.env.SANITY_INTERNAL_ENV || 'production' // eslint-disable-line no-process-env
const knownEnvs = ['development', 'staging', 'production']
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function installProcessExitHack(finalTask: () => Promise<unknown>) {
const originalProcessExit = process.exit
// @ts-expect-error ignore TS2534
process.exit = (exitCode?: number | undefined): never => {
finalTask().finally(() => originalProcessExit(exitCode))
}
}
export async function runCli(cliRoot: string, {cliVersion}: {cliVersion: string}): Promise<void> {
installUnhandledRejectionsHandler()
const pkg = {name: '@sanity/cli', version: cliVersion}
const args = parseArguments()
const isInit = args.groupOrCommand === 'init' && args.argsWithoutOptions[0] !== 'plugin'
const cwd = getCurrentWorkingDirectory()
let workDir: string | undefined
try {
workDir = isInit ? process.cwd() : resolveRootDir(cwd)
} catch (err) {
console.error(chalk.red(err.message))
process.exit(1)
}
// Check if there are updates available for the CLI, and notify if there is
await runUpdateCheck({pkg, cwd, workDir}).notify()
// If the telemetry disclosure message has not yet been shown, show it.
telemetryDisclosure()
// Try to figure out if we're in a v2 or v3 context by finding a config
debug(`Reading CLI config from "${workDir}"`)
let cliConfig = await getCliConfig(workDir, {forked: true})
if (!cliConfig) {
debug('No CLI config found')
}
// Figure out if the app is a studio or an app from the CLI config
const isApp = Boolean(cliConfig && 'app' in cliConfig)
// Load the environment variables from
loadAndSetEnvFromDotEnvFiles({workDir, cmd: args.groupOrCommand, isApp})
maybeFixMissingWindowsEnvVar()
// Reload the the cli config so env vars can work.
cliConfig = await getCliConfig(workDir, {forked: true})
const {logger: telemetry, flush: flushTelemetry} = createTelemetryStore<TelemetryUserProperties>({
projectId: cliConfig?.config?.api?.projectId,
env: process.env,
})
// UGLY HACK: process.exit(<code>) causes abrupt exit, we want to flush telemetry before exiting
installProcessExitHack(() =>
// When process.exit() is called, flush telemetry events first, but wait no more than x amount of ms before exiting process
Promise.race([wait(2000), flushTelemetry()]),
)
telemetry.updateUserProperties({
runtimeVersion: process.version,
runtime: detectRuntime(),
cliVersion: pkg.version,
machinePlatform: process.platform,
cpuArchitecture: process.arch,
projectId: cliConfig?.config?.api?.projectId,
dataset: cliConfig?.config?.api?.dataset,
})
const options: CommandRunnerOptions = {
cliRoot: cliRoot,
workDir: workDir,
corePath: await getCoreModulePath(workDir, cliConfig),
cliConfig,
telemetry,
}
warnOnNonProductionEnvironment()
warnOnInferredProjectDir(isInit, cwd, workDir)
const core = args.coreOptions
const commands = await mergeCommands(baseCommands, options.corePath, {cliVersion, cwd, workDir})
if (core.v || core.version) {
console.log(`${pkg.name} version ${pkg.version}`)
process.exit()
}
// Translate `sanity -h <command>` to `sanity help <command>`
if (core.h || core.help) {
if (args.groupOrCommand) {
args.argsWithoutOptions.unshift(args.groupOrCommand)
}
args.groupOrCommand = 'help'
}
if (args.groupOrCommand === 'logout') {
// flush telemetry events before logging out
await flushTelemetry()
}
const cliRunner = getCliRunner(commands)
const cliCommandTrace = telemetry.trace(CliCommand, {
groupOrCommand: args.groupOrCommand,
extraArguments: args.extraArguments,
commandArguments: args.argsWithoutOptions.slice(0, 10),
coreOptions: {
help: args.coreOptions.help || undefined,
version: args.coreOptions.help || undefined,
debug: args.coreOptions.help || undefined,
},
...(!args.groupOrCommand && {emptyCommand: true}), // user did not entry a command
})
cliCommandTrace.start()
cliRunner
.runCommand(args.groupOrCommand, args, {
...options,
telemetry: cliCommandTrace.newContext(args.groupOrCommand),
})
.then(() => cliCommandTrace.complete())
.catch(async (err) => {
await flushTelemetry()
const error = typeof err.details === 'string' ? err.details : err
// eslint-disable-next-line no-console
console.error(`\n${error.stack ? neatStack(err) : error}`)
if (err.cause) {
console.error(`\nCaused by:\n\n${err.cause.stack ? neatStack(err.cause) : err.cause}`)
}
// eslint-disable-next-line no-process-exit
cliCommandTrace.error(error)
process.exit(1)
})
}
async function getCoreModulePath(
workDir: string,
cliConfig: CliConfigResult | null,
): Promise<string | undefined> {
const corePath = resolveFrom.silent(workDir, '@sanity/core')
const sanityPath = resolveFrom.silent(workDir, 'sanity/_internal')
if (corePath && sanityPath) {
const closest = corePath.startsWith(workDir) ? corePath : sanityPath
const assumedVersion = closest === corePath ? 'v2' : 'v3'
console.warn(
chalk.yellow(
`Both \`@sanity/core\` AND \`sanity\` installed - assuming Sanity ${assumedVersion} project.`,
),
)
return closest
}
if (sanityPath) {
// On v3 and everything installed
return sanityPath
}
if (corePath && cliConfig && cliConfig?.version < 3) {
// On v2 and everything installed
return corePath
}
const isInstallCommand = process.argv.indexOf('install') === -1
if (cliConfig && cliConfig?.version < 3 && !corePath && !isInstallCommand) {
const installCmd = await getInstallCommand({workDir})
console.warn(
chalk.yellow(
[
'The `@sanity/core` module is not installed in current project',
`Project-specific commands not available until you run \`${installCmd}\``,
].join('\n'),
),
)
}
if (cliConfig && cliConfig.version >= 3 && !sanityPath) {
const installCmd = await getInstallCommand({workDir})
console.warn(
chalk.yellow(
[
'The `sanity` module is not installed in current project',
`Project-specific commands not available until you run \`${installCmd}\``,
].join('\n'),
),
)
}
return undefined
}
/**
* Returns the current working directory, but also handles a weird edge case where
* the folder the terminal is currently in has been removed
*/
function getCurrentWorkingDirectory(): string {
let pwd
try {
pwd = process.cwd()
} catch (err) {
if (err.code === 'ENOENT') {
console.error('[ERR] Could not resolve working directory, does the current folder exist?')
process.exit(1)
} else {
throw err
}
}
return pwd
}
function installUnhandledRejectionsHandler() {
process.on('unhandledRejection', (reason) => {
if (rejectionHasStack(reason)) {
console.error('Unhandled rejection:', reason.stack)
} else {
console.error('Unhandled rejection\n', reason)
}
})
}
function rejectionHasStack(reason: unknown): reason is {stack: string} {
return Boolean(
reason && typeof reason === 'object' && 'stack' in reason && typeof reason.stack === 'string',
)
}
function warnOnInferredProjectDir(isInit: boolean, cwd: string, workDir: string): void {
if (isInit || cwd === workDir) {
return
}
console.log(`Not in project directory, assuming context of project at ${workDir}`)
}
function warnOnNonProductionEnvironment(): void {
if (sanityEnv === 'production') {
return
}
console.warn(
chalk.yellow(
knownEnvs.includes(sanityEnv)
? `[WARN] Running in ${sanityEnv} environment mode\n`
: `[WARN] Running in ${chalk.red('UNKNOWN')} "${sanityEnv}" environment mode\n`,
),
)
}
function loadAndSetEnvFromDotEnvFiles({
workDir,
cmd,
isApp,
}: {
workDir: string
cmd: string
isApp: boolean
}) {
/* eslint-disable no-process-env */
// Do a cheap lookup for a sanity.json file. If there is one, assume it is a v2 project,
// and apply the old behavior for environment variables. Otherwise, use the Vite-style
// behavior. We need to do this "cheap" lookup because when loading the v3 config, env vars
// may be used in the configuration file, meaning we'd have to load the config twice.
if (existsSync(path.join(workDir, 'sanity.json'))) {
// v2
debug('sanity.json exists, assuming v2 project and loading .env files using old behavior')
const env = process.env.SANITY_ACTIVE_ENV || process.env.NODE_ENV || 'development'
debug('Loading environment files using %s mode', env)
dotenv.config({path: path.join(workDir, `.env.${env}`)})
return
}
// v3+
debug('No sanity.json exists, assuming v3 project and loading .env files using new behavior')
// Use `production` for `sanity build` / `sanity deploy`,
// but default to `development` for everything else unless `SANITY_ACTIVE_ENV` is set
const isProdCmd = ['build', 'deploy'].includes(cmd)
let mode = process.env.SANITY_ACTIVE_ENV
if (!mode && (isProdCmd || process.env.NODE_ENV === 'production')) {
mode = 'production'
} else if (!mode) {
mode = 'development'
}
if (mode === 'production' && !isProdCmd) {
console.warn(chalk.yellow(`[WARN] Running in ${sanityEnv} environment mode\n`))
}
debug('Loading environment files using %s mode', mode)
const studioEnv = loadEnv(mode, workDir, [isApp ? 'SANITY_APP_' : 'SANITY_STUDIO_'])
process.env = {...process.env, ...studioEnv}
/* eslint-disable no-process-env */
}
/**
* Apparently, Windows environment variables are supposed to be case-insensitive,
* (https://nodejs.org/api/process.html#processenv). However, it seems they are not?
* `process.env.SYSTEMROOT` is sometimes `undefined`, whereas `process.env.SystemRoot` is _NOT_.
*
* The `open` npm module uses the former to open a browser on Powershell, and Sindre seems
* unwilling to fix it (https://github.com/sindresorhus/open/pull/299#issuecomment-1447587598),
* so this is a (temporary?) workaround in order to make opening browsers on windows work,
* which several commands does (`sanity login`, `sanity docs` etc)
*/
function maybeFixMissingWindowsEnvVar() {
/* eslint-disable no-process-env */
if (os.platform() === 'win32' && !('SYSTEMROOT' in process.env) && 'SystemRoot' in process.env) {
process.env.SYSTEMROOT = process.env.SystemRoot
}
/* eslint-enable no-process-env */
}