Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/pr-759.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!-- auto-generated -->
---
'@sanity/cli-core': minor
'@sanity/cli': minor
'create-sanity': minor
---

bundle `init` logic into single file
9 changes: 5 additions & 4 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import {type KnipConfig} from 'knip'
const project = ['src/**/*.{js,jsx,ts,tsx}', '!**/docs/**']

const baseConfig = {
// For now only care about cli package
// Disabled: the changeset plugin can't resolve local file paths in the changelog config
changesets: false,
ignore: [
'packages/@sanity/cli-test/fixtures/**',

// See `helpClass` in `oclif.config.js`
'packages/@sanity/cli/src/SanityHelp.ts',
// Loaded dynamically by @changesets/cli at version time
Expand Down Expand Up @@ -82,7 +81,9 @@ const baseConfig = {
project,
},
'packages/create-sanity': {
ignoreDependencies: ['@sanity/cli'],
// @sanity/cli is imported via relative source paths (../../@sanity/cli/src/...)
// for bundling, but the workspace link is still needed for resolution
ignoreDependencies: ['@sanity/cli', 'rxjs'],
},
},
} satisfies KnipConfig
Expand Down
6 changes: 6 additions & 0 deletions packages/@sanity/cli-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
"source": "./src/_exports/index.ts",
"default": "./dist/_exports/index.js"
},
"./errors": {
"source": "./src/_exports/errors.ts",
"default": "./dist/_exports/errors.js"
},
"./ux": {
"source": "./src/_exports/ux.ts",
"default": "./dist/_exports/ux.js"
Expand Down Expand Up @@ -69,6 +73,7 @@
"@sanity/client": "catalog:",
"babel-plugin-react-compiler": "^1.0.0",
"boxen": "^8.0.1",
"clean-stack": "^6.0.0",
"debug": "catalog:",
"get-it": "^8.7.0",
"get-tsconfig": "catalog:",
Expand All @@ -82,6 +87,7 @@
"tsx": "catalog:",
"vite": "catalog:",
"vite-node": "^5.3.0",
"wrap-ansi": "^10.0.0",
"zod": "catalog:"
},
"devDependencies": {
Expand Down
5 changes: 5 additions & 0 deletions packages/@sanity/cli-core/src/_exports/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from '../errors/CLIError.js'
export * from '../errors/CLIWarning.js'
export * from '../errors/NonInteractiveError.js'
export * from '../errors/NotFoundError.js'
export * from '../errors/ProjectRootNotFoundError.js'
22 changes: 19 additions & 3 deletions packages/@sanity/cli-core/src/_exports/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {deprecate} from 'node:util'

import {NonInteractiveError as _NonInteractiveError} from '../errors/NonInteractiveError.js'
import {NotFoundError as _NotFoundError} from '../errors/NotFoundError.js'
import {ProjectRootNotFoundError as _ProjectRootNotFoundError} from '../errors/ProjectRootNotFoundError.js'

export * from '../config/cli/getCliConfig.js'
export * from '../config/cli/getCliConfigSync.js'
export {type CliConfig} from '../config/cli/types/cliConfig.js'
Expand All @@ -11,9 +17,6 @@ export * from '../config/util/findConfigsPaths.js'
export * from '../config/util/findStudioConfigPath.js'
export {type ProjectRootResult} from '../config/util/recursivelyResolveProjectRoot.js'
export * from '../debug.js'
export * from '../errors/NonInteractiveError.js'
export * from '../errors/NotFoundError.js'
export * from '../errors/ProjectRootNotFoundError.js'
export * from '../exitCodes.js'
export * from '../loaders/studio/studioWorkerTask.js'
export * from '../loaders/tsx/tsxWorkerTask.js'
Expand Down Expand Up @@ -50,3 +53,16 @@ export * from '../util/resolveLocalPackage.js'
export * from '../util/safeStructuredClone.js'
export * from '../ux/colorizeJson.js'
export * from '../ux/timer.js'

export const NonInteractiveError = deprecate(
_NonInteractiveError,
'Import `NonInteractiveError` from `@sanity/cli-core/errors`',
)
export const NotFoundError = deprecate(
_NotFoundError,
'Import `NotFoundError` from `@sanity/cli-core/errors`',
)
export const ProjectRootNotFoundError = deprecate(
_ProjectRootNotFoundError,
'Import `ProjectRootNotFoundError` from `@sanity/cli-core/errors`',
)
2 changes: 2 additions & 0 deletions packages/@sanity/cli-core/src/_exports/ux.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export {NonInteractiveError} from '../errors/NonInteractiveError.js'
export * from '../ux/boxen.js'
export * from '../ux/errors.js'
export * from '../ux/logSymbols.js'
export * from '../ux/output.js'
export * from '../ux/prompts.js'
export * from '../ux/spinner.js'
46 changes: 46 additions & 0 deletions packages/@sanity/cli-core/src/errors/CLIError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {styleText} from 'node:util'

import {type PrettyPrintableError} from '@oclif/core/interfaces'
import cleanStack from 'clean-stack'

/**
* A formatted CLI error that pretty-prints to stderr.
*
* This is a lightweight reimplementation of `@oclif/core`'s `CLIError`.
* We can't import the original because `@oclif/core` is a CJS barrel that
* pulls in the entire oclif runtime (~10MB) when bundled - defeating
* tree-shaking in the standalone `create-sanity` bundle. By owning the
* error class here, code in `@sanity/cli-core` and the init action tree
* can throw formatted errors without depending on oclif at all.
*
* The `oclif` property is shaped so oclif's error handler still recognises
* these errors when thrown inside an oclif command, preserving the correct
* exit code and suppressing redundant stack traces.
*/
export class CLIError extends Error {
code?: string
oclif: {exit?: number} = {exit: 2}
ref?: string
skipOclifErrorHandling?: boolean
suggestions?: string[]

constructor(error: Error | string, options: PrettyPrintableError & {exit?: false | number} = {}) {
super(error instanceof Error ? error.message : error)
if (error instanceof Error && error.stack) {
this.stack = error.stack
}
if (options.exit !== undefined)
this.oclif.exit = options.exit === false ? undefined : options.exit
this.code = options.code
this.suggestions = options.suggestions
this.ref = options.ref
}

get bang(): string | undefined {
return styleText('red', process.platform === 'win32' ? '»' : '›')
}

get prettyStack(): string {
return cleanStack(super.stack!, {pretty: true})
}
}
18 changes: 18 additions & 0 deletions packages/@sanity/cli-core/src/errors/CLIWarning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {styleText} from 'node:util'

import {CLIError} from './CLIError.js'

/**
* A warning-level CLI error. Identical to {@link CLIError} except the
* bang prefix is yellow instead of red.
*/
export class CLIWarning extends CLIError {
constructor(input: Error | string) {
super(input instanceof Error ? input.message : input)
this.name = 'Warning'
}

override get bang(): string | undefined {
return styleText('yellow', process.platform === 'win32' ? '»' : '›')
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {CLIError} from '@oclif/core/errors'
import {CLIError} from './CLIError.js'

/**
* Error thrown when a prompt is attempted in a non-interactive environment
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {CLIError} from '@oclif/core/errors'

import {isRecord} from '../util/isRecord.js'
import {CLIError} from './CLIError.js'

/**
* Error thrown when a project root directory cannot be found.
Expand Down
5 changes: 2 additions & 3 deletions packages/@sanity/cli-core/src/telemetry/getCliTelemetry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {ux} from '@oclif/core'

import {warn} from '../ux/errors.js'
import {noopLogger} from './noopTelemetry.js'
import {type CLITelemetryStore} from './types.js'

Expand Down Expand Up @@ -33,7 +32,7 @@ export function getCliTelemetry(): CLITelemetryStore {
const state = getState()
// This should never happen, but if it does, we return a noop logger to avoid errors.
if (!state) {
ux.warn('CLI telemetry not initialized, returning noop logger')
warn('CLI telemetry not initialized, returning noop logger')
return noopLogger
}

Expand Down
16 changes: 11 additions & 5 deletions packages/@sanity/cli-core/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import {type Command} from '@oclif/core'

/**
* Abstraction over console output methods shared by both the oclif CLI
* (`SanityCommand`) and the standalone `create-sanity` entry point.
*
* Signatures are defined explicitly to avoid a type-level dependency on
* `@oclif/core` — keeping `Output` usable from oclif-free code paths.
*/
export interface Output {
error: Command['error']
log: Command['log']
warn: Command['warn']
error(input: Error | string, options?: {exit?: number; suggestions?: string[]}): never
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches oclif's Command.log signature
log(message?: string, ...args: any[]): void
warn(input: Error | string): Error | string
}

export type RequireProps<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
Expand Down
84 changes: 84 additions & 0 deletions packages/@sanity/cli-core/src/ux/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* eslint-disable no-console -- this is the error output layer */
import {type PrettyPrintableError} from '@oclif/core/interfaces'
import wrapAnsi from 'wrap-ansi'

import {CLIError} from '../errors/CLIError.js'
import {CLIWarning} from '../errors/CLIWarning.js'

const settings: {debug?: boolean} = (globalThis as Record<string, unknown>).oclif ?? {}

/**
* Print a formatted error to stderr without throwing, when `exit: false`.
*/
export function error(input: Error | string, options: PrettyPrintableError & {exit: false}): void
/**
* Throw a formatted {@link CLIError}.
*/
export function error(
input: Error | string,
options?: PrettyPrintableError & {exit?: number},
): never
export function error(
input: Error | string,
options: PrettyPrintableError & {exit?: false | number} = {},
): void {
const err = new CLIError(input, options)

if (options.exit === false) {
const message = prettyPrint(err)
if (message) console.error(message)
} else {
throw err
}
}

/**
* Print a formatted warning to stderr.
*/
export function warn(input: Error | string): void {
const err = new CLIWarning(input)
const message = prettyPrint(err)
if (message) console.error(message)
}

function indentString(str: string, count: number, options?: {indent?: string}): string {
const indent = options?.indent ?? ' '
if (count === 0) return str
return str.replaceAll(/^(?!\s*$)/gm, indent.repeat(count))
}

function stderrWidth(): number {
const env = Number.parseInt(process.env.OCLIF_COLUMNS!, 10)
if (env) return env
if (!process.stderr.isTTY) return 80
const w = (process.stderr as {getWindowSize?: () => number[]}).getWindowSize?.()[0] ?? 80
return Math.max(w < 1 ? 80 : w, 40)
}

function formatSuggestions(suggestions?: string[]): string | undefined {
const label = 'Try this:'
if (!suggestions || suggestions.length === 0) return undefined
if (suggestions.length === 1) return `${label} ${suggestions[0]}`
return `${label}\n${indentString(suggestions.map((s) => `* ${s}`).join('\n'), 2)}`
}

function prettyPrint(error: CLIError): string | undefined {
if (settings.debug) return error.prettyStack

const {bang: prefix, code, message, name: errorSuffix, ref, suggestions} = error
const formattedHeader = message ? `${errorSuffix || 'Error'}: ${message}` : undefined
const formattedCode = code ? `Code: ${code}` : undefined
const formattedSuggestions = formatSuggestions(suggestions)
const formattedReference = ref ? `Reference: ${ref}` : undefined

const formatted = [formattedHeader, formattedCode, formattedSuggestions, formattedReference]
.filter(Boolean)
.join('\n')

const width = stderrWidth()
let output = wrapAnsi(formatted, width - 6, {hard: true, trim: false})
output = indentString(output, 3)
output = indentString(output, 1, {indent: prefix || ''})
output = indentString(output, 1)
return output
}
19 changes: 19 additions & 0 deletions packages/@sanity/cli-core/src/ux/output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/* eslint-disable no-console -- these are intentional output helpers */

/**
* Write a message to stdout. Equivalent to `console.log`.
*
* Prefer this over `process.stdout.write` since console.log
* handles buffering correctly and won't silently drop output
* if the process exits before the write buffer flushes.
*/
export function stdout(...args: Parameters<typeof console.log>): void {
console.log(...args)
}

/**
* Write a message to stderr. Equivalent to `console.error`.
*/
export function stderr(...args: Parameters<typeof console.error>): void {
console.error(...args)
}
2 changes: 1 addition & 1 deletion packages/@sanity/cli-e2e/__tests__/createSanity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ describe.skipIf(!createSanityBinary)('create-sanity', () => {
})

if (error) throw error
expect(stdout).toContain('Initialize a new Sanity Studio')
expect(stdout).toContain('Initialize a new Sanity project')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe.skipIf(!isRegistryMode)('create-sanity via package managers', {timeout:
throw new Error(`${cmd} failed:\n${String(stderr || err)}`, {cause: err})
}

expect(result).toContain('Initialize a new Sanity Studio')
expect(result).toContain('Initialize a new Sanity project')
},
)
})
Expand Down
12 changes: 1 addition & 11 deletions packages/@sanity/cli/src/SanityHelp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,7 @@ const IS_README_GENERATION = (process.argv[process.argv.indexOf('readme') - 1] ?
*/
export default class SanityHelp extends Help {
protected formatCommand(command: Command.Loadable): string {
let help = super.formatCommand(command)

// When `sanity init` is called, but originates from the `create-sanity`
// package/binary (eg the one used by `npm create sanity@latest` etc), we want to
// customize the help text to show that command instead of `sanity init`.
const isFromCreate = process.argv.includes('--from-create') && command.id === 'init'
if (isFromCreate) {
help = replaceInitWithCreateCommand(help)
}

return prefixBinName(help)
return prefixBinName(super.formatCommand(command))
}

protected formatRoot(): string {
Expand Down
Loading
Loading