From aee0d2c89c20af84453cd8536c04eb21e430cb06 Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Thu, 23 Jul 2026 12:03:31 -0400 Subject: [PATCH 01/14] feat: add embedded context to run cli commands --- packages/@sanity/cli-core/package.json | 4 ++ .../@sanity/cli-core/src/SanityCommand.ts | 57 +++++++++++++++- .../src/__tests__/SanityCommand.test.ts | 67 +++++++++++++++++++ .../config/__tests__/findProjectRoot.test.ts | 15 +++++ .../__tests__/findProjectRootSync.test.ts | 16 +++++ .../cli/__tests__/cliUserConfig.test.ts | 26 +++++++ .../cli-core/src/config/cli/cliUserConfig.ts | 10 +++ .../cli-core/src/config/findProjectRoot.ts | 12 ++++ .../src/config/findProjectRootSync.ts | 9 +++ .../cli-core/src/util/isInteractive.ts | 6 ++ packages/@sanity/cli/package.json | 4 ++ packages/@sanity/cli/src/commands/cors/add.ts | 23 +++++-- .../@sanity/cli/src/commands/cors/delete.ts | 3 + .../@sanity/cli/src/commands/cors/list.ts | 3 + .../@sanity/cli/src/commands/projects/list.ts | 3 + packages/@sanity/cli/tsconfig.json | 3 + 16 files changed, 253 insertions(+), 8 deletions(-) diff --git a/packages/@sanity/cli-core/package.json b/packages/@sanity/cli-core/package.json index f7e0f42814..437f063b4e 100644 --- a/packages/@sanity/cli-core/package.json +++ b/packages/@sanity/cli-core/package.json @@ -53,6 +53,10 @@ "source": "./src/_exports/errors.ts", "default": "./dist/_exports/errors.js" }, + "./executionContext": { + "source": "./src/executionContext.ts", + "default": "./dist/executionContext.js" + }, "./package-manager": { "source": "./src/_exports/package-manager.ts", "default": "./dist/_exports/package-manager.js" diff --git a/packages/@sanity/cli-core/src/SanityCommand.ts b/packages/@sanity/cli-core/src/SanityCommand.ts index d9cbb0f72e..6e944c1890 100644 --- a/packages/@sanity/cli-core/src/SanityCommand.ts +++ b/packages/@sanity/cli-core/src/SanityCommand.ts @@ -1,4 +1,4 @@ -import {styleText} from 'node:util' +import {format, styleText} from 'node:util' import {Command, Interfaces} from '@oclif/core' import {type CommandError} from '@oclif/core/interfaces' @@ -16,6 +16,7 @@ import {findProjectRoot} from './config/findProjectRoot.js' import {type ProjectRootResult} from './config/util/recursivelyResolveProjectRoot.js' import {NonInteractiveError} from './errors/NonInteractiveError.js' import {ProjectRootNotFoundError} from './errors/ProjectRootNotFoundError.js' +import {getCliExecutionContext} from './executionContext.js' import {exitCodes} from './exitCodes.js' import {getCliTelemetry, reportCliTraceError} from './telemetry/getCliTelemetry.js' import {type CLITelemetryStore} from './telemetry/types.js' @@ -247,6 +248,46 @@ export abstract class SanityCommand return this.flags.yes || this.flags.json || !this.resolveIsInteractive() } + /** + * Write to stdout — or, when running under an execution context (e.g. from + * an MCP server), to the context's `stdout` sink. Mirrors oclif's `log` + * semantics: suppressed when `--json` is enabled, printf-style formatting. + */ + public override log(message = '', ...args: unknown[]): void { + const context = getCliExecutionContext() + if (!context?.stdout) { + super.log(message, ...args) + return + } + if (!this.jsonEnabled()) context.stdout(format(message, ...args)) + } + + /** + * Pretty-print JSON to stdout — or plain (un-colorized) JSON to the + * execution context's `stdout` sink, so programmatic callers don't have to + * strip ANSI codes. + */ + public override logJson(json: unknown): void { + const context = getCliExecutionContext() + if (!context?.stdout) { + super.logJson(json) + return + } + context.stdout(JSON.stringify(json, null, 2)) + } + + /** + * Like `log`, but for stderr / the context's `stderr` sink. + */ + public override logToStderr(message = '', ...args: unknown[]): void { + const context = getCliExecutionContext() + if (!context?.stderr) { + super.logToStderr(message, ...args) + return + } + if (!this.jsonEnabled()) context.stderr(format(message, ...args)) + } + /** * Resolver for checking if the terminal is interactive. Override in tests to provide mock values. * @@ -272,4 +313,18 @@ export abstract class SanityCommand return {} } } + + /** + * Emit a warning — routed to the execution context's `stderr` sink when one + * is active, otherwise through oclif's standard warning printer. + */ + public override warn(input: Error | string): Error | string { + const context = getCliExecutionContext() + if (!context?.stderr) return super.warn(input) + + if (!this.jsonEnabled()) { + context.stderr(`Warning: ${input instanceof Error ? input.message : input}`) + } + return input + } } diff --git a/packages/@sanity/cli-core/src/__tests__/SanityCommand.test.ts b/packages/@sanity/cli-core/src/__tests__/SanityCommand.test.ts index bf80c68775..ebe451c3b8 100644 --- a/packages/@sanity/cli-core/src/__tests__/SanityCommand.test.ts +++ b/packages/@sanity/cli-core/src/__tests__/SanityCommand.test.ts @@ -1,6 +1,7 @@ import {Command, Flags} from '@oclif/core' import {afterEach, beforeEach, describe, expect, Mock, test, vi} from 'vitest' +import {runWithCliExecutionContext} from '../executionContext.js' import {SanityCommand} from '../SanityCommand.js' function createMockedRunCommand(mocks: { @@ -171,4 +172,70 @@ describe('SanityCommand', () => { await cmdClass.run([]) }) }) + + describe('execution context output routing', () => { + test('log, warn, logToStderr and logJson route to context sinks', async () => { + const out: string[] = [] + const err: string[] = [] + const cmdClass = createMockedRunCommand({ + run: async function () { + this.log('hello %s', 'world') + this.warn('careful now') + this.warn(new Error('warned error')) + this.logToStderr('diagnostics') + this.logJson({origins: ['https://example.com']}) + }, + }) + + await runWithCliExecutionContext( + {stderr: (line) => err.push(line), stdout: (line) => out.push(line)}, + () => cmdClass.run([]), + ) + + expect(out).toEqual([ + 'hello world', + JSON.stringify({origins: ['https://example.com']}, null, 2), + ]) + expect(err).toEqual(['Warning: careful now', 'Warning: warned error', 'diagnostics']) + }) + + test('concurrent invocations with different sinks do not cross-talk', async () => { + const makeCommand = (label: string) => + createMockedRunCommand({ + run: async function () { + await new Promise((resolve) => setTimeout(resolve, label === 'a' ? 20 : 5)) + this.log(`output from ${label}`) + }, + }) + + const outA: string[] = [] + const outB: string[] = [] + await Promise.all([ + runWithCliExecutionContext({stdout: (line) => outA.push(line)}, () => + makeCommand('a').run([]), + ), + runWithCliExecutionContext({stdout: (line) => outB.push(line)}, () => + makeCommand('b').run([]), + ), + ]) + + expect(outA).toEqual(['output from a']) + expect(outB).toEqual(['output from b']) + }) + + test('without a context, log writes to process stdout as before', async () => { + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined) + try { + const cmdClass = createMockedRunCommand({ + run: async function () { + this.log('regular cli output') + }, + }) + await cmdClass.run([]) + expect(consoleLog).toHaveBeenCalledWith('regular cli output') + } finally { + consoleLog.mockRestore() + } + }) + }) }) diff --git a/packages/@sanity/cli-core/src/config/__tests__/findProjectRoot.test.ts b/packages/@sanity/cli-core/src/config/__tests__/findProjectRoot.test.ts index 9ac80b6733..08fc19bd8c 100644 --- a/packages/@sanity/cli-core/src/config/__tests__/findProjectRoot.test.ts +++ b/packages/@sanity/cli-core/src/config/__tests__/findProjectRoot.test.ts @@ -3,6 +3,7 @@ import {join} from 'node:path' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {runWithCliExecutionContext} from '../../executionContext' import {readJsonFile} from '../../util/readJsonFile' import {findProjectRoot} from '../findProjectRoot' @@ -198,4 +199,18 @@ describe('#findProjectRoot', () => { type: 'studio', }) }) + + test('throws without touching the filesystem when an execution context is active', async () => { + // A config file is present, but the guard must reject before looking + vi.mocked(access).mockResolvedValue(undefined) + + await runWithCliExecutionContext({token: 'test-token'}, async () => { + await expect(findProjectRoot(mockCwd)).rejects.toThrow( + 'Project root resolution from the filesystem is disabled for programmatic invocations', + ) + }) + + expect(access).not.toHaveBeenCalled() + expect(readJsonFile).not.toHaveBeenCalled() + }) }) diff --git a/packages/@sanity/cli-core/src/config/__tests__/findProjectRootSync.test.ts b/packages/@sanity/cli-core/src/config/__tests__/findProjectRootSync.test.ts index 7cf0384013..92cee360b5 100644 --- a/packages/@sanity/cli-core/src/config/__tests__/findProjectRootSync.test.ts +++ b/packages/@sanity/cli-core/src/config/__tests__/findProjectRootSync.test.ts @@ -2,6 +2,7 @@ import {join} from 'node:path' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {runWithCliExecutionContext} from '../../executionContext' import {findProjectRootSync} from '../findProjectRootSync' function convertToSystemPath(unixPath: string): string { @@ -144,4 +145,19 @@ describe('findProjectRootSync', () => { `Found 'sanity.json' at ${convertToSystemPath('/mock/project/path')} - Sanity Studio < v3 is no longer supported`, ) }) + + test('throws without touching the filesystem when an execution context is active', async () => { + const {existsSync} = await import('node:fs') + + // A config file is present, but the guard must reject before looking + vi.mocked(existsSync).mockReturnValue(true) + + runWithCliExecutionContext({token: 'test-token'}, () => { + expect(() => findProjectRootSync(mockCwd)).toThrow( + 'Project root resolution from the filesystem is disabled for programmatic invocations', + ) + }) + + expect(existsSync).not.toHaveBeenCalled() + }) }) diff --git a/packages/@sanity/cli-core/src/config/cli/__tests__/cliUserConfig.test.ts b/packages/@sanity/cli-core/src/config/cli/__tests__/cliUserConfig.test.ts index ea2f74da50..59afe5273b 100644 --- a/packages/@sanity/cli-core/src/config/cli/__tests__/cliUserConfig.test.ts +++ b/packages/@sanity/cli-core/src/config/cli/__tests__/cliUserConfig.test.ts @@ -2,6 +2,7 @@ import {mkdirSync} from 'node:fs' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {runWithCliExecutionContext} from '../../../executionContext.js' import {readJsonFileSync} from '../../../util/readJsonFileSync' import {writeJsonFileSync} from '../../../util/writeJsonFileSync.js' import {clearCliTokenCache, getCachedToken, setCachedToken} from '../cliTokenCache.js' @@ -34,6 +35,9 @@ describe('cliUserConfig', () => { describe('getCliToken()', () => { beforeEach(() => { mockGetCliUserConfig.mockReturnValue('mock-token') + // clearAllMocks() does not reset implementations, so make the cache + // state explicit for every test + vi.mocked(getCachedToken).mockReturnValue(undefined) }) afterEach(() => { vi.unstubAllEnvs() @@ -96,6 +100,28 @@ describe('cliUserConfig', () => { expect(token).toBe('trimmed-token') }) + test('should prefer execution context token over env, config and cache', async () => { + vi.mocked(getCachedToken).mockReturnValue('cached-token') + vi.stubEnv('SANITY_AUTH_TOKEN', 'env-token') + + const token = await runWithCliExecutionContext({token: 'context-token'}, () => getCliToken()) + + expect(token).toBe('context-token') + // The context token must never touch the process-wide cache: reading it + // could leak another invocation's token, writing it would leak this one's + expect(vi.mocked(getCachedToken)).not.toHaveBeenCalled() + expect(vi.mocked(setCachedToken)).not.toHaveBeenCalled() + expect(mockGetCliUserConfig).not.toHaveBeenCalled() + }) + + test('execution context without token falls back to normal resolution', async () => { + vi.stubEnv('SANITY_AUTH_TOKEN', 'env-token') + + const token = await runWithCliExecutionContext({}, () => getCliToken()) + + expect(token).toBe('env-token') + }) + test('should re-read after clearCliTokenCache', async () => { mockGetCliUserConfig.mockReturnValueOnce('first-token') diff --git a/packages/@sanity/cli-core/src/config/cli/cliUserConfig.ts b/packages/@sanity/cli-core/src/config/cli/cliUserConfig.ts index 8ae0276c17..99fb166834 100644 --- a/packages/@sanity/cli-core/src/config/cli/cliUserConfig.ts +++ b/packages/@sanity/cli-core/src/config/cli/cliUserConfig.ts @@ -4,6 +4,7 @@ import {dirname, join as joinPath} from 'node:path' import {z} from 'zod/mini' import {debug} from '../../_exports/debug.js' +import {getCliExecutionContext} from '../../executionContext.js' import {getSanityConfigDir} from '../../util/getSanityConfigDir.js' import {readJsonFileSync} from '../../util/readJsonFileSync.js' import {writeJsonFileSync} from '../../util/writeJsonFileSync.js' @@ -25,6 +26,15 @@ export const _internals = { * @internal */ export async function getCliToken(): Promise { + // A per-invocation execution context token (e.g. per-request from an MCP + // server) takes precedence and must bypass the process-wide cache entirely: + // reading the cache would leak another invocation's token, and writing it + // would leak this one's. + const contextToken = getCliExecutionContext()?.token + if (contextToken) { + return contextToken + } + const cached = getCachedToken() if (cached !== undefined) { return cached diff --git a/packages/@sanity/cli-core/src/config/findProjectRoot.ts b/packages/@sanity/cli-core/src/config/findProjectRoot.ts index d1ced2ae3e..e46973eb01 100644 --- a/packages/@sanity/cli-core/src/config/findProjectRoot.ts +++ b/packages/@sanity/cli-core/src/config/findProjectRoot.ts @@ -1,4 +1,5 @@ import {ProjectRootNotFoundError} from '../errors/ProjectRootNotFoundError.js' +import {getCliExecutionContext} from '../executionContext.js' import {findAppConfigPath} from './util/findAppConfigPath.js' import {tryFindStudioConfigPath} from './util/findStudioConfigPath.js' import { @@ -19,6 +20,17 @@ import { * @internal */ export async function findProjectRoot(cwd: string): Promise { + // Programmatic invocations (execution context active) must never resolve + // project context from the host's filesystem: walking up from the host + // process's cwd could pick up an unrelated Sanity project, and resolving the + // CLI config from it would even execute that project's `sanity.cli.ts`. + // Callers must provide explicit flags (e.g. `--project-id`) instead. + if (getCliExecutionContext()) { + throw new ProjectRootNotFoundError( + 'Project root resolution from the filesystem is disabled for programmatic invocations', + ) + } + try { // First try to find a studio project root, looks for `sanity.config.(ts|js)` const studioProjectRoot = await resolveProjectRootForStudio(cwd) diff --git a/packages/@sanity/cli-core/src/config/findProjectRootSync.ts b/packages/@sanity/cli-core/src/config/findProjectRootSync.ts index 5ca24c2a89..521dae2486 100644 --- a/packages/@sanity/cli-core/src/config/findProjectRootSync.ts +++ b/packages/@sanity/cli-core/src/config/findProjectRootSync.ts @@ -1,6 +1,7 @@ import {dirname, resolve} from 'node:path' import {ProjectRootNotFoundError} from '../errors/ProjectRootNotFoundError.js' +import {getCliExecutionContext} from '../executionContext.js' import {findAppConfigPathSync, findStudioConfigPathSync} from './util/configPathsSync.js' import {ProjectRootResult} from './util/recursivelyResolveProjectRoot.js' @@ -54,6 +55,14 @@ function recursivelyResolveProjectRootSync( * @internal */ export function findProjectRootSync(cwd: string): ProjectRootResult { + // See the matching guard in findProjectRoot: programmatic invocations must + // never resolve project context from the host's filesystem. + if (getCliExecutionContext()) { + throw new ProjectRootNotFoundError( + 'Project root resolution from the filesystem is disabled for programmatic invocations', + ) + } + try { // First try to find a studio project root, looks for `sanity.config.(ts|js)` const studioProjectRoot = resolveProjectRootForStudioSync(cwd) diff --git a/packages/@sanity/cli-core/src/util/isInteractive.ts b/packages/@sanity/cli-core/src/util/isInteractive.ts index 55b8aa6889..894448a32e 100644 --- a/packages/@sanity/cli-core/src/util/isInteractive.ts +++ b/packages/@sanity/cli-core/src/util/isInteractive.ts @@ -1,3 +1,9 @@ +import {getCliExecutionContext} from '../executionContext.js' + export function isInteractive(): boolean { + // Programmatic invocations (execution context active) are never interactive, + // even if the host process happens to have a TTY. + if (getCliExecutionContext()) return false + return Boolean(process.stdin.isTTY) && process.env.TERM !== 'dumb' && !('CI' in process.env) } diff --git a/packages/@sanity/cli/package.json b/packages/@sanity/cli/package.json index 5d068b7aa3..a1ea38a290 100644 --- a/packages/@sanity/cli/package.json +++ b/packages/@sanity/cli/package.json @@ -45,6 +45,10 @@ "source": "./src/exports/_internal.ts", "default": "./dist/exports/_internal.js" }, + "./commands": { + "source": "./src/exports/commands.ts", + "default": "./dist/exports/commands.js" + }, "./runtime": { "source": "./src/exports/runtime.ts", "default": "./dist/exports/runtime.js" diff --git a/packages/@sanity/cli/src/commands/cors/add.ts b/packages/@sanity/cli/src/commands/cors/add.ts index 11b0f09004..8aa269de7d 100644 --- a/packages/@sanity/cli/src/commands/cors/add.ts +++ b/packages/@sanity/cli/src/commands/cors/add.ts @@ -4,6 +4,7 @@ import {styleText} from 'node:util' import {Args, Flags} from '@oclif/core' import {exitCodes, SanityCommand, subdebug} from '@sanity/cli-core' +import {getCliExecutionContext} from '@sanity/cli-core/executionContext' import {confirm, logSymbols} from '@sanity/cli-core/ux' import {oneline} from 'oneline' @@ -14,6 +15,9 @@ import {getProjectIdFlag} from '../../util/sharedFlags.js' const addCorsDebug = subdebug('cors:add') +/** + * @internal + */ export class Add extends SanityCommand { static override args = { origin: Args.string({ @@ -72,14 +76,19 @@ export class Add extends SanityCommand { }), }) - // Check if the origin argument looks like a file path and warn - try { - const isFile = fs.existsSync(path.join(process.cwd(), args.origin)) - if (isFile) { - this.warn(`Origin "${args.origin}?" Remember to quote values (sanity cors add "*")`) + // Check if the origin argument looks like a file path and warn. This only + // makes sense for shell usage (unquoted globs expanding to filenames), so + // programmatic invocations skip it — they must never probe the host's + // filesystem. + if (!getCliExecutionContext()) { + try { + const isFile = fs.existsSync(path.join(process.cwd(), args.origin)) + if (isFile) { + this.warn(`Origin "${args.origin}?" Remember to quote values (sanity cors add "*")`) + } + } catch { + // Ignore errors checking if it's a file } - } catch { - // Ignore errors checking if it's a file } const filteredOrigin = await filterAndValidateOrigin(origin, this.output) diff --git a/packages/@sanity/cli/src/commands/cors/delete.ts b/packages/@sanity/cli/src/commands/cors/delete.ts index 0ad3a446a4..faceddce09 100644 --- a/packages/@sanity/cli/src/commands/cors/delete.ts +++ b/packages/@sanity/cli/src/commands/cors/delete.ts @@ -8,6 +8,9 @@ import {getProjectIdFlag} from '../../util/sharedFlags.js' const deleteCorsDebug = subdebug('cors:delete') +/** + * @internal + */ export class Delete extends SanityCommand { static override args = { origin: Args.string({ diff --git a/packages/@sanity/cli/src/commands/cors/list.ts b/packages/@sanity/cli/src/commands/cors/list.ts index e53a222ad5..83bc93f156 100644 --- a/packages/@sanity/cli/src/commands/cors/list.ts +++ b/packages/@sanity/cli/src/commands/cors/list.ts @@ -6,6 +6,9 @@ import {getProjectIdFlag} from '../../util/sharedFlags.js' const listCorsDebug = subdebug('cors:list') +/** + * @internal + */ export class List extends SanityCommand { static override description = 'List CORS origins for the project' static override examples = [ diff --git a/packages/@sanity/cli/src/commands/projects/list.ts b/packages/@sanity/cli/src/commands/projects/list.ts index 6b77e2a961..e8ad25c6d1 100644 --- a/packages/@sanity/cli/src/commands/projects/list.ts +++ b/packages/@sanity/cli/src/commands/projects/list.ts @@ -12,6 +12,9 @@ const sortFields = ['id', 'members', 'name', 'url', 'created'] const projectsDebug = subdebug('projects') +/** + * @internal + */ export class List extends SanityCommand { static override description = 'List your projects' static override examples = [ diff --git a/packages/@sanity/cli/tsconfig.json b/packages/@sanity/cli/tsconfig.json index dfdfe4ac8e..9ce976806b 100644 --- a/packages/@sanity/cli/tsconfig.json +++ b/packages/@sanity/cli/tsconfig.json @@ -10,6 +10,9 @@ "@sanity/cli-core": ["./node_modules/@sanity/cli-core/src/_exports/index.ts"], "@sanity/cli-core/*": ["./node_modules/@sanity/cli-core/src/_exports/*"], "@sanity/cli-core/apiClient": ["./node_modules/@sanity/cli-core/src/apiClient.ts"], + "@sanity/cli-core/executionContext": [ + "./node_modules/@sanity/cli-core/src/executionContext.ts" + ], "@sanity/cli-core/ExitCodes": ["./node_modules/@sanity/cli-core/src/exitCodes.ts"], "@sanity/cli-core/SanityCommand": ["./node_modules/@sanity/cli-core/src/SanityCommand.ts"], "@sanity/cli-test": ["./node_modules/@sanity/cli-test/src/index.ts"], From 8b5fd44c1269c71ea9d32260e889f6a7a3399e68 Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Thu, 23 Jul 2026 12:07:17 -0400 Subject: [PATCH 02/14] feat: execution context --- .../src/__tests__/executionContext.test.ts | 49 +++++ .../@sanity/cli-core/src/executionContext.ts | 67 ++++++ .../src/exports/__tests__/commands.test.ts | 206 ++++++++++++++++++ packages/@sanity/cli/src/exports/commands.ts | 193 ++++++++++++++++ .../util/__tests__/tokenizeCliArgs.test.ts | 75 +++++++ .../@sanity/cli/src/util/tokenizeCliArgs.ts | 55 +++++ 6 files changed, 645 insertions(+) create mode 100644 packages/@sanity/cli-core/src/__tests__/executionContext.test.ts create mode 100644 packages/@sanity/cli-core/src/executionContext.ts create mode 100644 packages/@sanity/cli/src/exports/__tests__/commands.test.ts create mode 100644 packages/@sanity/cli/src/exports/commands.ts create mode 100644 packages/@sanity/cli/src/util/__tests__/tokenizeCliArgs.test.ts create mode 100644 packages/@sanity/cli/src/util/tokenizeCliArgs.ts diff --git a/packages/@sanity/cli-core/src/__tests__/executionContext.test.ts b/packages/@sanity/cli-core/src/__tests__/executionContext.test.ts new file mode 100644 index 0000000000..0b93dfb30b --- /dev/null +++ b/packages/@sanity/cli-core/src/__tests__/executionContext.test.ts @@ -0,0 +1,49 @@ +import {describe, expect, test} from 'vitest' + +import {getCliExecutionContext, runWithCliExecutionContext} from '../executionContext.js' + +describe('executionContext', () => { + test('returns undefined outside a context', () => { + expect(getCliExecutionContext()).toBeUndefined() + }) + + test('context is visible inside the async call graph and gone after', async () => { + const context = {token: 'test-token'} + await runWithCliExecutionContext(context, async () => { + expect(getCliExecutionContext()).toBe(context) + await Promise.resolve() + expect(getCliExecutionContext()).toBe(context) + }) + expect(getCliExecutionContext()).toBeUndefined() + }) + + test('concurrent contexts are isolated from each other', async () => { + const seen: Record = {} + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + + await Promise.all([ + runWithCliExecutionContext({token: 'token-a'}, async () => { + await sleep(15) + seen.a = getCliExecutionContext()?.token + }), + runWithCliExecutionContext({token: 'token-b'}, async () => { + await sleep(5) + seen.b = getCliExecutionContext()?.token + }), + ]) + + expect(seen).toEqual({a: 'token-a', b: 'token-b'}) + }) + + test('makes isInteractive report non-interactive', async () => { + const {isInteractive} = await import('../util/isInteractive.js') + await runWithCliExecutionContext({}, () => { + expect(isInteractive()).toBe(false) + }) + }) + + test('returns the wrapped function result', () => { + const result = runWithCliExecutionContext({}, () => 42) + expect(result).toBe(42) + }) +}) diff --git a/packages/@sanity/cli-core/src/executionContext.ts b/packages/@sanity/cli-core/src/executionContext.ts new file mode 100644 index 0000000000..b2a7047a64 --- /dev/null +++ b/packages/@sanity/cli-core/src/executionContext.ts @@ -0,0 +1,67 @@ +import {AsyncLocalStorage} from 'node:async_hooks' + +/** + * Explicit, per-invocation execution context for running CLI commands + * programmatically (e.g. from an MCP server or another embedding host). + * + * When a context is active, it overrides the process-global defaults the CLI + * normally relies on: + * + * - `token` takes precedence over `SANITY_AUTH_TOKEN` and the stored CLI + * config token, and never touches the process-wide token cache + * - `stdout`/`stderr` receive the output that commands would otherwise write + * to the process streams + * - interactivity checks report non-interactive, so commands fail fast with + * actionable errors instead of prompting + * - project root resolution from the filesystem is disabled: commands never + * read (or execute) `sanity.config.ts`/`sanity.cli.ts` files found near the + * host process's cwd, so project context must come from explicit flags + * (e.g. `--project-id`) + * + * The CLI binary never enters a context, so its behavior is unchanged. + * + * @public + */ +export interface CliExecutionContext { + /** + * Sink for output the command would otherwise write to `process.stderr`. + * Called once per line, without a trailing newline. + */ + stderr?: (line: string) => void + + /** + * Sink for output the command would otherwise write to `process.stdout`. + * Called once per line, without a trailing newline. + */ + stdout?: (line: string) => void + + /** + * Auth token to use for API clients created during this invocation. + * Overrides `SANITY_AUTH_TOKEN` and the stored CLI config token. + */ + token?: string +} + +const storage = new AsyncLocalStorage() + +/** + * Run `fn` with the given execution context active. The context is visible + * (via {@link getCliExecutionContext}) to all code in the async call graph of + * `fn`, and invisible to everything else — concurrent invocations with + * different contexts are fully isolated. + * + * @public + */ +export function runWithCliExecutionContext(context: CliExecutionContext, fn: () => T): T { + return storage.run(context, fn) +} + +/** + * Get the active execution context, if any. Returns `undefined` when running + * as the regular CLI (process-global behavior applies). + * + * @public + */ +export function getCliExecutionContext(): CliExecutionContext | undefined { + return storage.getStore() +} diff --git a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts new file mode 100644 index 0000000000..cf50143389 --- /dev/null +++ b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts @@ -0,0 +1,206 @@ +import {fileURLToPath} from 'node:url' + +import {Config} from '@oclif/core' +import {mockApi} from '@sanity/cli-test' +import {cleanAll, pendingMocks} from 'nock' +import {afterAll, afterEach, beforeAll, describe, expect, test, vi} from 'vitest' + +import {CORS_API_VERSION} from '../../services/cors.js' +import {runSanityCli} from '../commands.js' + +const projectId = 'test-project' + +let config: Config + +beforeAll(async () => { + // Keep tests hermetic: never let token resolution fall back to the + // developer's real stored CLI login. + vi.stubEnv('SANITY_CLI_CONFIG_PATH', '/nonexistent/sanity-cli-test-config.json') + + // Bare oclif config rooted at the monorepo root (which has no oclif + // configuration) — same approach as @sanity/cli-test's testCommand. Avoids + // loading this package's plugins/hooks and does not require a dist build. + config = await Config.load(fileURLToPath(new URL('../../../../../..', import.meta.url))) +}) + +afterAll(() => { + vi.unstubAllEnvs() +}) + +function corsOrigin(origin: string, id = 1) { + return { + allowCredentials: true, + createdAt: '2026-01-01T00:00:00Z', + deletedAt: null, + id, + origin, + projectId, + updatedAt: null, + } +} + +describe('runSanityCli', () => { + afterEach(() => { + const pending = pendingMocks() + cleanAll() + expect(pending, 'pending mocks').toEqual([]) + }) + + test('runs a command from string args, using the provided token', async () => { + mockApi({apiVersion: CORS_API_VERSION, uri: `/projects/${projectId}/cors`}) + .matchHeader('authorization', 'Bearer user-token') + .reply(200, [corsOrigin('https://example.com')]) + + const result = await runSanityCli({ + args: `cors list --project-id ${projectId}`, + config, + token: 'user-token', + }) + + expect(result).toEqual({exitCode: 0, output: 'https://example.com'}) + }) + + test('accepts a pre-split argv array', async () => { + mockApi({apiVersion: CORS_API_VERSION, uri: `/projects/${projectId}/cors`}) + .matchHeader('authorization', 'Bearer user-token') + .reply(200, [corsOrigin('https://example.com')]) + + const result = await runSanityCli({ + args: ['cors', 'list', '--project-id', projectId], + config, + token: 'user-token', + }) + + expect(result).toEqual({exitCode: 0, output: 'https://example.com'}) + }) + + test('tolerates a leading `sanity` token and quoted values', async () => { + mockApi({apiVersion: CORS_API_VERSION, uri: `/projects/${projectId}/cors`}) + .matchHeader('authorization', 'Bearer user-token') + .reply(200, [corsOrigin('https://example.com')]) + + const result = await runSanityCli({ + args: `sanity cors list --project-id "${projectId}"`, + config, + token: 'user-token', + }) + + expect(result).toEqual({exitCode: 0, output: 'https://example.com'}) + }) + + test('supports colon-separated command ids', async () => { + mockApi({apiVersion: CORS_API_VERSION, uri: `/projects/${projectId}/cors`}) + .matchHeader('authorization', 'Bearer user-token') + .reply(200, [corsOrigin('https://example.com')]) + + const result = await runSanityCli({ + args: `cors:list --project-id ${projectId}`, + config, + token: 'user-token', + }) + + expect(result).toEqual({exitCode: 0, output: 'https://example.com'}) + }) + + test('concurrent invocations use their own token and capture their own output', async () => { + mockApi({apiVersion: CORS_API_VERSION, uri: `/projects/project-a/cors`}) + .matchHeader('authorization', 'Bearer token-a') + .delay(50) + .reply(200, [{...corsOrigin('https://user-a.example.com'), projectId: 'project-a'}]) + mockApi({apiVersion: CORS_API_VERSION, uri: `/projects/project-b/cors`}) + .matchHeader('authorization', 'Bearer token-b') + .delay(5) + .reply(200, [{...corsOrigin('https://user-b.example.com'), projectId: 'project-b'}]) + + const [resultA, resultB] = await Promise.all([ + runSanityCli({args: 'cors list --project-id project-a', config, token: 'token-a'}), + runSanityCli({args: 'cors list --project-id project-b', config, token: 'token-b'}), + ]) + + expect(resultA).toEqual({exitCode: 0, output: 'https://user-a.example.com'}) + expect(resultB).toEqual({exitCode: 0, output: 'https://user-b.example.com'}) + }) + + test('reports command failures through exitCode and output without throwing', async () => { + mockApi({apiVersion: CORS_API_VERSION, uri: `/projects/${projectId}/cors`}) + .matchHeader('authorization', 'Bearer bogus-token') + .reply(401, {error: 'Unauthorized', message: 'Session not found', statusCode: 401}) + + const previousExitCode = process.exitCode + const result = await runSanityCli({ + args: `cors list --project-id ${projectId}`, + config, + token: 'bogus-token', + }) + + expect(result.exitCode).toBe(1) + expect(result.output).toContain('CORS origins list retrieval failed') + expect(result.output).toContain('Session not found') + // A failed invocation must not change the host process's exit status + expect(process.exitCode).toBe(previousExitCode) + }) + + test('reports invalid flags as a usage error', async () => { + const result = await runSanityCli({ + args: 'cors list --no-such-flag', + config, + token: 'user-token', + }) + + expect(result.exitCode).toBe(2) + expect(result.output).toContain('Nonexistent flag') + }) + + test('never resolves project context from the host filesystem', async () => { + // Run from inside a fixture that has a resolvable sanity.cli.ts. Without + // the execution-context guard, the command would walk up from cwd, find + // the fixture project, and use its projectId. Instead it must fail with + // an explicit "provide a project ID" error, proving cwd is never read. + const fixtureDir = fileURLToPath( + new URL('../../../../../../fixtures/basic-studio', import.meta.url), + ) + const previousCwd = process.cwd() + process.chdir(fixtureDir) + try { + const result = await runSanityCli({args: 'cors list', config, token: 'user-token'}) + + expect(result.exitCode).toBe(1) + expect(result.output).toContain('Unable to determine project ID') + } finally { + process.chdir(previousCwd) + } + }) + + test('rejects commands outside the allowlist', async () => { + const result = await runSanityCli({args: 'login', config, token: 'user-token'}) + + expect(result.exitCode).toBe(2) + expect(result.output).toContain('Unknown or unsupported command: login') + expect(result.output).toContain('cors list') + }) + + test('rejects real CLI commands that are not allowlisted', async () => { + const result = await runSanityCli({args: 'datasets list', config, token: 'user-token'}) + + expect(result.exitCode).toBe(2) + expect(result.output).toContain('Unknown or unsupported command: datasets list') + }) + + test('rejects empty args', async () => { + const result = await runSanityCli({args: '', config, token: 'user-token'}) + + expect(result.exitCode).toBe(2) + expect(result.output).toContain('Unknown or unsupported command') + }) + + test('reports unterminated quotes as a usage error', async () => { + const result = await runSanityCli({ + args: 'cors add "https://example.com', + config, + token: 'user-token', + }) + + expect(result.exitCode).toBe(2) + expect(result.output).toContain('Unterminated double quote') + }) +}) diff --git a/packages/@sanity/cli/src/exports/commands.ts b/packages/@sanity/cli/src/exports/commands.ts new file mode 100644 index 0000000000..6b6f51aab9 --- /dev/null +++ b/packages/@sanity/cli/src/exports/commands.ts @@ -0,0 +1,193 @@ +/** + * Programmatic (in-process) invocation of CLI commands, e.g. from an MCP + * server. This is a curated allowlist: only commands that are pure API + * operations — no filesystem access, no interactive-only flows — are + * invokable here. + * + * Most hosts should use {@link runSanityCli}, which handles arg parsing, + * command dispatch, per-invocation auth, and output capture: + * ```ts + * import {runSanityCli} from '@sanity/cli/commands' + * + * const {exitCode, output} = await runSanityCli({ + * args: 'cors list --project-id abc123', + * token: extra.authInfo.token, + * }) + * ``` + * + * The command classes and `loadCliCommandConfig` are also exported for hosts + * that want to invoke a specific command directly via `Command.run(argv, config)` + * inside their own `runWithCliExecutionContext` wrapper. + */ +import {fileURLToPath} from 'node:url' + +import {Config} from '@oclif/core' +import {CLI_TELEMETRY_SYMBOL, exitCodes, noopLogger, setCliTelemetry} from '@sanity/cli-core' +import {runWithCliExecutionContext} from '@sanity/cli-core/executionContext' + +import {Add as CorsAdd} from '../commands/cors/add.js' +import {Delete as CorsDelete} from '../commands/cors/delete.js' +import {List as CorsList} from '../commands/cors/list.js' +import {List as ProjectsList} from '../commands/projects/list.js' +import {tokenizeCliArgs} from '../util/tokenizeCliArgs.js' + +/** + * Load the oclif `Config` for this package, needed as the second argument to + * `Command.run(argv, config)`. Loading it once and reusing it across + * invocations avoids re-reading the command manifest per call. + * + * @internal + */ +export function loadCliCommandConfig(): Promise { + // Resolves to the package root from both src/exports (dev) and dist/exports (built) + return Config.load(fileURLToPath(new URL('../..', import.meta.url))) +} + +/** + * Minimal structural type for an invokable oclif command class. + */ +interface InvokableCommand { + run(argv: string[], config: Config): Promise +} + +/** + * Dispatch table from command id to command class. This doubles as the + * allowlist: anything not in this table cannot be invoked through + * {@link runSanityCli}. + */ +const invokableCommands: ReadonlyMap = new Map([ + ['cors add', CorsAdd], + ['cors delete', CorsDelete], + ['cors list', CorsList], + ['projects list', ProjectsList], +]) + +function resolveCommand(argv: string[]): {argv: string[]; command: InvokableCommand} | undefined { + // Accept both separator styles: `cors list` and `cors:list` + const tokens = argv[0]?.includes(':') ? [...argv[0].split(':'), ...argv.slice(1)] : argv + + // Longest command id first, so future single-token commands can coexist + for (const idLength of [2, 1]) { + const command = invokableCommands.get(tokens.slice(0, idLength).join(' ')) + if (command) return {argv: tokens.slice(idLength), command} + } + return undefined +} + +/** + * @internal + */ +export interface RunSanityCliOptions { + /** + * Arguments after `sanity` (a leading `sanity` token is tolerated), either + * as a single string — shell-style quoting is supported, but no shell is + * ever executed — or as a pre-split argv array. + */ + args: string | string[] + + /** + * Auth token for this invocation. Scoped to this call via the CLI execution + * context: it never touches process env or the process-wide token cache, so + * concurrent invocations with different tokens are fully isolated. + */ + token: string + + /** + * Optional oclif config override (mainly for tests). Defaults to this + * package's config, loaded once and cached across invocations. + */ + config?: Config +} + +/** + * @internal + */ +export interface RunSanityCliResult { + /** `0` on success, the command's exit code otherwise. */ + exitCode: number + + /** Combined stdout and stderr output, in emission order. */ + output: string +} + +let cachedConfig: Promise | undefined + +/** + * Run an allowlisted CLI command in-process and capture its result. + * + * Command-level failures (unknown command, bad flags, API errors) are + * reported through `exitCode`/`output` rather than thrown, so callers can + * relay them verbatim. + * + * @internal + */ +export async function runSanityCli({ + args, + config, + token, +}: RunSanityCliOptions): Promise { + // Commands log through the global telemetry store; default it to a noop + // store so embedding hosts need no telemetry wiring (and see no warnings), + // without clobbering a store the host may have installed itself. + if (!(globalThis as Record)[CLI_TELEMETRY_SYMBOL]) { + setCliTelemetry(noopLogger) + } + + let argv: string[] + try { + argv = typeof args === 'string' ? tokenizeCliArgs(args) : [...args] + } catch (err) { + return { + exitCode: exitCodes.USAGE_ERROR, + output: err instanceof Error ? err.message : String(err), + } + } + if (argv[0] === 'sanity') argv = argv.slice(1) + + const resolved = resolveCommand(argv) + if (!resolved) { + return { + exitCode: exitCodes.USAGE_ERROR, + output: [ + `Unknown or unsupported command: ${argv.slice(0, 2).join(' ') || '(none)'}`, + `Available commands: ${[...invokableCommands.keys()].join(', ')}`, + ].join('\n'), + } + } + + const output: string[] = [] + const sink = (line: string) => output.push(line) + + // oclif's error handling sets `process.exitCode` as a side effect; restore + // it so a failed invocation can't change the host process's exit status. + const previousExitCode = process.exitCode + try { + const oclifConfig = config ?? (await (cachedConfig ??= loadCliCommandConfig())) + + await runWithCliExecutionContext({stderr: sink, stdout: sink, token}, () => + resolved.command.run(resolved.argv, oclifConfig), + ) + return {exitCode: exitCodes.SUCCESS, output: output.join('\n')} + } catch (err) { + const exit = (err as {oclif?: {exit?: false | number}}).oclif?.exit + + // `this.exit(0)` throws an ExitError but is a successful outcome + if (exit === exitCodes.SUCCESS) { + return {exitCode: exitCodes.SUCCESS, output: output.join('\n')} + } + + const message = err instanceof Error ? err.message : String(err) + if (message) output.push(message) + return { + exitCode: typeof exit === 'number' ? exit : exitCodes.RUNTIME_ERROR, + output: output.join('\n'), + } + } finally { + process.exitCode = previousExitCode + } +} + +export {Add as CorsAdd} from '../commands/cors/add.js' +export {Delete as CorsDelete} from '../commands/cors/delete.js' +export {List as CorsList} from '../commands/cors/list.js' +export {List as ProjectsList} from '../commands/projects/list.js' diff --git a/packages/@sanity/cli/src/util/__tests__/tokenizeCliArgs.test.ts b/packages/@sanity/cli/src/util/__tests__/tokenizeCliArgs.test.ts new file mode 100644 index 0000000000..bb66e46423 --- /dev/null +++ b/packages/@sanity/cli/src/util/__tests__/tokenizeCliArgs.test.ts @@ -0,0 +1,75 @@ +import {describe, expect, test} from 'vitest' + +import {tokenizeCliArgs} from '../tokenizeCliArgs.js' + +describe('tokenizeCliArgs', () => { + test('splits on whitespace', () => { + expect(tokenizeCliArgs('cors list --project-id abc123')).toEqual([ + 'cors', + 'list', + '--project-id', + 'abc123', + ]) + }) + + test('collapses repeated whitespace, tabs and newlines', () => { + expect(tokenizeCliArgs(' cors\t list \n --json ')).toEqual(['cors', 'list', '--json']) + }) + + test('preserves spaces inside double quotes', () => { + expect(tokenizeCliArgs('cors add "https://example.com/some path"')).toEqual([ + 'cors', + 'add', + 'https://example.com/some path', + ]) + }) + + test('preserves spaces inside single quotes', () => { + expect(tokenizeCliArgs("cors add 'https://example.com/some path'")).toEqual([ + 'cors', + 'add', + 'https://example.com/some path', + ]) + }) + + test('supports quotes adjacent to unquoted text', () => { + expect(tokenizeCliArgs('--name="my project"')).toEqual(['--name=my project']) + }) + + test('supports escaped double quotes inside double quotes', () => { + expect(tokenizeCliArgs('echo "say \\"hi\\""')).toEqual(['echo', 'say "hi"']) + }) + + test('supports escaped backslash inside double quotes', () => { + expect(tokenizeCliArgs('"a\\\\b"')).toEqual(['a\\b']) + }) + + test('does not treat backslash as escape inside single quotes', () => { + expect(tokenizeCliArgs("'a\\b'")).toEqual(['a\\b']) + }) + + test('supports backslash escapes outside quotes', () => { + expect(tokenizeCliArgs('a\\ b')).toEqual(['a b']) + }) + + test('produces an empty token for empty quotes', () => { + expect(tokenizeCliArgs("--flag ''")).toEqual(['--flag', '']) + }) + + test('returns an empty array for empty or blank input', () => { + expect(tokenizeCliArgs('')).toEqual([]) + expect(tokenizeCliArgs(' ')).toEqual([]) + }) + + test('throws on unterminated double quote', () => { + expect(() => tokenizeCliArgs('cors add "https://example.com')).toThrow( + 'Unterminated double quote', + ) + }) + + test('throws on unterminated single quote', () => { + expect(() => tokenizeCliArgs("cors add 'https://example.com")).toThrow( + 'Unterminated single quote', + ) + }) +}) diff --git a/packages/@sanity/cli/src/util/tokenizeCliArgs.ts b/packages/@sanity/cli/src/util/tokenizeCliArgs.ts new file mode 100644 index 0000000000..f8a9a1a750 --- /dev/null +++ b/packages/@sanity/cli/src/util/tokenizeCliArgs.ts @@ -0,0 +1,55 @@ +/** + * Tokenize a command-line argument string into an argv array without ever + * invoking a shell. Supports single quotes, double quotes, and backslash + * escapes — enough to round-trip anything a user would type after `sanity `. + * + * @throws When the input contains an unterminated quote. + * @internal + */ +export function tokenizeCliArgs(input: string): string[] { + const tokens: string[] = [] + let current = '' + let hasToken = false + let quote: "'" | '"' | null = null + + for (let i = 0; i < input.length; i++) { + const char = input[i] + + if (quote === "'") { + if (char === "'") quote = null + else current += char + } else if (quote === '"') { + if (char === '"') { + quote = null + } else if (char === '\\' && (input[i + 1] === '"' || input[i + 1] === '\\')) { + i += 1 + current += input[i] + } else { + current += char + } + } else if (char === "'" || char === '"') { + quote = char + hasToken = true + } else if (char === '\\' && i + 1 < input.length) { + i += 1 + current += input[i] + hasToken = true + } else if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { + if (hasToken) { + tokens.push(current) + current = '' + hasToken = false + } + } else { + current += char + hasToken = true + } + } + + if (quote) { + throw new Error(`Unterminated ${quote === '"' ? 'double' : 'single'} quote in arguments`) + } + if (hasToken) tokens.push(current) + + return tokens +} From 24a9439fbac2bcdfa46f873541f38c75db4dcffc Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Fri, 24 Jul 2026 13:33:04 -0400 Subject: [PATCH 03/14] fix: comment --- packages/@sanity/cli/src/exports/commands.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/@sanity/cli/src/exports/commands.ts b/packages/@sanity/cli/src/exports/commands.ts index 6b6f51aab9..d93e39f292 100644 --- a/packages/@sanity/cli/src/exports/commands.ts +++ b/packages/@sanity/cli/src/exports/commands.ts @@ -15,9 +15,8 @@ * }) * ``` * - * The command classes and `loadCliCommandConfig` are also exported for hosts - * that want to invoke a specific command directly via `Command.run(argv, config)` - * inside their own `runWithCliExecutionContext` wrapper. + * The command classes themselves are deliberately not exported: going through + * {@link runSanityCli} is the only supported way to invoke them in-process. */ import {fileURLToPath} from 'node:url' @@ -186,8 +185,3 @@ export async function runSanityCli({ process.exitCode = previousExitCode } } - -export {Add as CorsAdd} from '../commands/cors/add.js' -export {Delete as CorsDelete} from '../commands/cors/delete.js' -export {List as CorsList} from '../commands/cors/list.js' -export {List as ProjectsList} from '../commands/projects/list.js' From 06b0df82b76066e80afa653effe06aff9315fea7 Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Fri, 24 Jul 2026 15:10:11 -0400 Subject: [PATCH 04/14] feat: add bare help and command help --- packages/@sanity/cli/package.json | 6 +- .../src/exports/__tests__/commands.test.ts | 115 +++++++++++++++--- .../invokeSanityCli/InvokableCommands.ts | 20 +++ .../cli/src/exports/invokeSanityCli/help.ts | 105 ++++++++++++++++ .../{commands.ts => invokeSanityCli/index.ts} | 97 ++++++++------- 5 files changed, 281 insertions(+), 62 deletions(-) create mode 100644 packages/@sanity/cli/src/exports/invokeSanityCli/InvokableCommands.ts create mode 100644 packages/@sanity/cli/src/exports/invokeSanityCli/help.ts rename packages/@sanity/cli/src/exports/{commands.ts => invokeSanityCli/index.ts} (62%) diff --git a/packages/@sanity/cli/package.json b/packages/@sanity/cli/package.json index a1ea38a290..159198f093 100644 --- a/packages/@sanity/cli/package.json +++ b/packages/@sanity/cli/package.json @@ -45,9 +45,9 @@ "source": "./src/exports/_internal.ts", "default": "./dist/exports/_internal.js" }, - "./commands": { - "source": "./src/exports/commands.ts", - "default": "./dist/exports/commands.js" + "./invokeSanityCli": { + "source": "./src/exports/invokeSanityCli/index.ts", + "default": "./dist/exports/invokeSanityCli/index.js" }, "./runtime": { "source": "./src/exports/runtime.ts", diff --git a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts index cf50143389..6a846b0cb4 100644 --- a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts +++ b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts @@ -6,11 +6,23 @@ import {cleanAll, pendingMocks} from 'nock' import {afterAll, afterEach, beforeAll, describe, expect, test, vi} from 'vitest' import {CORS_API_VERSION} from '../../services/cors.js' -import {runSanityCli} from '../commands.js' +import {invokeSanityCli} from '../invokeSanityCli/index.js' const projectId = 'test-project' let config: Config +let cachedCliPackageConfig: Promise | undefined + +/** + * The CLI package's real oclif config (topics, command manifest from + * oclif.config.js and dist/). Help resolution needs it; the dist build it + * depends on is guaranteed by the pretest script. Loaded once, on demand. + */ +function loadCliPackageConfig(): Promise { + return (cachedCliPackageConfig ??= Config.load( + fileURLToPath(new URL('../../..', import.meta.url)), + )) +} beforeAll(async () => { // Keep tests hermetic: never let token resolution fall back to the @@ -39,7 +51,7 @@ function corsOrigin(origin: string, id = 1) { } } -describe('runSanityCli', () => { +describe('invokeSanityCli', () => { afterEach(() => { const pending = pendingMocks() cleanAll() @@ -51,7 +63,7 @@ describe('runSanityCli', () => { .matchHeader('authorization', 'Bearer user-token') .reply(200, [corsOrigin('https://example.com')]) - const result = await runSanityCli({ + const result = await invokeSanityCli({ args: `cors list --project-id ${projectId}`, config, token: 'user-token', @@ -65,7 +77,7 @@ describe('runSanityCli', () => { .matchHeader('authorization', 'Bearer user-token') .reply(200, [corsOrigin('https://example.com')]) - const result = await runSanityCli({ + const result = await invokeSanityCli({ args: ['cors', 'list', '--project-id', projectId], config, token: 'user-token', @@ -79,7 +91,7 @@ describe('runSanityCli', () => { .matchHeader('authorization', 'Bearer user-token') .reply(200, [corsOrigin('https://example.com')]) - const result = await runSanityCli({ + const result = await invokeSanityCli({ args: `sanity cors list --project-id "${projectId}"`, config, token: 'user-token', @@ -93,7 +105,7 @@ describe('runSanityCli', () => { .matchHeader('authorization', 'Bearer user-token') .reply(200, [corsOrigin('https://example.com')]) - const result = await runSanityCli({ + const result = await invokeSanityCli({ args: `cors:list --project-id ${projectId}`, config, token: 'user-token', @@ -113,8 +125,8 @@ describe('runSanityCli', () => { .reply(200, [{...corsOrigin('https://user-b.example.com'), projectId: 'project-b'}]) const [resultA, resultB] = await Promise.all([ - runSanityCli({args: 'cors list --project-id project-a', config, token: 'token-a'}), - runSanityCli({args: 'cors list --project-id project-b', config, token: 'token-b'}), + invokeSanityCli({args: 'cors list --project-id project-a', config, token: 'token-a'}), + invokeSanityCli({args: 'cors list --project-id project-b', config, token: 'token-b'}), ]) expect(resultA).toEqual({exitCode: 0, output: 'https://user-a.example.com'}) @@ -127,7 +139,7 @@ describe('runSanityCli', () => { .reply(401, {error: 'Unauthorized', message: 'Session not found', statusCode: 401}) const previousExitCode = process.exitCode - const result = await runSanityCli({ + const result = await invokeSanityCli({ args: `cors list --project-id ${projectId}`, config, token: 'bogus-token', @@ -141,7 +153,7 @@ describe('runSanityCli', () => { }) test('reports invalid flags as a usage error', async () => { - const result = await runSanityCli({ + const result = await invokeSanityCli({ args: 'cors list --no-such-flag', config, token: 'user-token', @@ -162,7 +174,7 @@ describe('runSanityCli', () => { const previousCwd = process.cwd() process.chdir(fixtureDir) try { - const result = await runSanityCli({args: 'cors list', config, token: 'user-token'}) + const result = await invokeSanityCli({args: 'cors list', config, token: 'user-token'}) expect(result.exitCode).toBe(1) expect(result.output).toContain('Unable to determine project ID') @@ -172,7 +184,7 @@ describe('runSanityCli', () => { }) test('rejects commands outside the allowlist', async () => { - const result = await runSanityCli({args: 'login', config, token: 'user-token'}) + const result = await invokeSanityCli({args: 'login', config, token: 'user-token'}) expect(result.exitCode).toBe(2) expect(result.output).toContain('Unknown or unsupported command: login') @@ -180,21 +192,21 @@ describe('runSanityCli', () => { }) test('rejects real CLI commands that are not allowlisted', async () => { - const result = await runSanityCli({args: 'datasets list', config, token: 'user-token'}) + const result = await invokeSanityCli({args: 'datasets list', config, token: 'user-token'}) expect(result.exitCode).toBe(2) expect(result.output).toContain('Unknown or unsupported command: datasets list') }) test('rejects empty args', async () => { - const result = await runSanityCli({args: '', config, token: 'user-token'}) + const result = await invokeSanityCli({args: '', config, token: 'user-token'}) expect(result.exitCode).toBe(2) expect(result.output).toContain('Unknown or unsupported command') }) test('reports unterminated quotes as a usage error', async () => { - const result = await runSanityCli({ + const result = await invokeSanityCli({ args: 'cors add "https://example.com', config, token: 'user-token', @@ -203,4 +215,77 @@ describe('runSanityCli', () => { expect(result.exitCode).toBe(2) expect(result.output).toContain('Unterminated double quote') }) + + test.each(['--help', '-h', 'help', 'sanity --help'])( + '`%s` renders root help scoped to the invokable command surface', + async (args) => { + const cliPackageConfig = await loadCliPackageConfig() + const result = await invokeSanityCli({args, config: cliPackageConfig, token: 'user-token'}) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('USAGE') + expect(result.output).toContain('TOPICS') + expect(result.output).toContain('Manage CORS origins for your project') + expect(result.output).toContain('Manage Sanity projects') + // Non-invokable topics must not be advertised + expect(result.output).not.toContain('datasets') + // Plain text for programmatic callers: no ANSI escape codes + expect(result.output).not.toContain('\u001B') + }, + ) + + test.each(['cors --help', 'cors -h', 'help cors'])( + '`%s` renders topic help listing only invokable commands', + async (args) => { + const cliPackageConfig = await loadCliPackageConfig() + const result = await invokeSanityCli({args, config: cliPackageConfig, token: 'user-token'}) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('Manage CORS origins for your project') + expect(result.output).toContain('cors add') + expect(result.output).toContain('cors delete') + expect(result.output).toContain('cors list') + }, + ) + + test.each(['cors list --help', 'help cors list', 'cors:list --help'])( + '`%s` renders command help with usage and flags', + async (args) => { + const cliPackageConfig = await loadCliPackageConfig() + const result = await invokeSanityCli({args, config: cliPackageConfig, token: 'user-token'}) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('List CORS origins for the project') + expect(result.output).toContain('USAGE') + expect(result.output).toContain('--project-id') + }, + ) + + test('help output is stable across invocations sharing a config', async () => { + // Regression: oclif's help formatters rewrite command ids in place + // (`cors:list` → `cors list`); without defensive copies this corrupts the + // shared config and later help calls lose commands (or execute them) + const cliPackageConfig = await loadCliPackageConfig() + const opts = {config: cliPackageConfig, token: 'user-token'} + + const first = await invokeSanityCli({args: 'cors --help', ...opts}) + await invokeSanityCli({args: 'cors list --help', ...opts}) + const second = await invokeSanityCli({args: 'cors --help', ...opts}) + + expect(second.output).toContain('cors list') + expect(second.output).toBe(first.output) + }) + + test.each([ + 'datasets --help', // real topic, not invokable + 'login --help', // real root command, not invokable + 'bogus --help', // does not exist at all + ])('`%s` is rejected identically to an unknown command', async (args) => { + const cliPackageConfig = await loadCliPackageConfig() + const result = await invokeSanityCli({args, config: cliPackageConfig, token: 'user-token'}) + + expect(result.exitCode).toBe(2) + expect(result.output).toContain('Unknown or unsupported command') + expect(result.output).toContain('Available commands: cors add, cors delete, cors list') + }) }) diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/InvokableCommands.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/InvokableCommands.ts new file mode 100644 index 0000000000..bbc1772dfa --- /dev/null +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/InvokableCommands.ts @@ -0,0 +1,20 @@ +import {type Config} from '@oclif/core' + +import {Add as CorsAdd} from '../../commands/cors/add.js' +import {Delete as CorsDelete} from '../../commands/cors/delete.js' +import {List as CorsList} from '../../commands/cors/list.js' +import {List as ProjectsList} from '../../commands/projects/list.js' + +export interface InvokableCommand { + run(argv: string[], config: Config): Promise +} + +export const invokableCommands: ReadonlyMap = new Map< + string, + InvokableCommand +>([ + ['cors add', CorsAdd], + ['cors delete', CorsDelete], + ['cors list', CorsList], + ['projects list', ProjectsList], +]) diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/help.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/help.ts new file mode 100644 index 0000000000..a42aeed532 --- /dev/null +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/help.ts @@ -0,0 +1,105 @@ +import {type Command, type Config, Help, type Interfaces} from '@oclif/core' + +import {invokableCommands} from './InvokableCommands.js' + +const invokableCommandIds = new Set( + [...invokableCommands.keys()].map((id) => id.replaceAll(' ', ':')), +) + +const invokableTopicNames = new Set( + [...invokableCommandIds].filter((id) => id.includes(':')).map((id) => id.split(':')[0]), +) + +/** Thrown when help is requested for a subject outside the invokable surface. */ +class NotInvokableError extends Error {} + +/** + * oclif help renderer scoped to the invokable command surface. Subject + * resolution (root vs topic vs command help) is oclif's own; the allowlist is + * enforced at the rendering entry points so the rest of the CLI stays + * invisible to embedding hosts, and listings are filtered to allowlisted + * topics/commands only. + * + * Output is collected in {@link InvokableHelp.lines} instead of being written + * to the process streams. + */ +class InvokableHelp extends Help { + public readonly lines: string[] = [] + + // The copies below are load-bearing: oclif's formatters rewrite ids/names + // in place (`cors:list` → `cors list`). Without copies those writes corrupt + // the shared (cached) config, breaking the allowlist checks on subsequent + // invocations. + + protected override get sortedCommands(): Command.Loadable[] { + return super.sortedCommands + .filter((command) => invokableCommandIds.has(command.id)) + .map((command) => ({...command})) + } + + protected override get sortedTopics(): Interfaces.Topic[] { + // Topic names and descriptions come from the oclif config (oclif.config.js) + return super.sortedTopics + .filter((topic) => invokableTopicNames.has(topic.name)) + .map((topic) => ({...topic})) + } + + protected override log(...args: string[]): void { + this.lines.push(...args) + } + + public override async showCommandHelp(command: Command.Loadable): Promise { + if (!invokableCommandIds.has(command.id)) throw new NotInvokableError(command.id) + return super.showCommandHelp({...command}) + } + + protected override async showTopicHelp(topic: Interfaces.Topic): Promise { + if (!invokableTopicNames.has(topic.name)) throw new NotInvokableError(topic.name) + return super.showTopicHelp({...topic}) + } +} + +/** + * Render help text for the invokable command surface: root help for a bare + * help request, or topic/command help when `argv` names a subject (e.g. + * `['cors', '--help']`), exactly as oclif would resolve it. ANSI styling is + * stripped so programmatic callers get plain text. + * + * Returns `undefined` when the subject is unknown or not invokable — callers + * should respond the same way as for an unknown command, so hosts cannot + * probe the full CLI surface through help. + * + * @internal + */ +export async function renderInvokableHelp( + config: Config, + argv: string[], +): Promise { + const help = new InvokableHelp(config, {stripAnsi: true}) + try { + await help.showHelp(argv) + } catch (err) { + // Subjects outside the allowlist (NotInvokableError) and subjects oclif + // itself cannot resolve (CLIError, marked with an `oclif` property) both + // yield no help output. + if (err instanceof NotInvokableError || err.oclif) return undefined + throw err + } + return help.lines.join('\n').trimEnd() +} + +/** + * Whether `argv` asks for help rather than a command invocation: a leading + * `help` (oclif's help command), or a help flag before any `--` terminator + * (same rule as oclif's own dispatch, plus `-h` as a convenience). + * + * @internal + */ +export function isHelpRequest(argv: string[]): boolean { + if (argv[0] === 'help') return true + for (const token of argv) { + if (token === '--') return false + if (token === '--help' || token === '-h') return true + } + return false +} diff --git a/packages/@sanity/cli/src/exports/commands.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts similarity index 62% rename from packages/@sanity/cli/src/exports/commands.ts rename to packages/@sanity/cli/src/exports/invokeSanityCli/index.ts index d93e39f292..574a33a4e5 100644 --- a/packages/@sanity/cli/src/exports/commands.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts @@ -4,19 +4,23 @@ * operations — no filesystem access, no interactive-only flows — are * invokable here. * - * Most hosts should use {@link runSanityCli}, which handles arg parsing, - * command dispatch, per-invocation auth, and output capture: + * {@link invokeSanityCli} handles arg parsing, + * command dispatch, per-invocation auth, and output capture. * ```ts - * import {runSanityCli} from '@sanity/cli/commands' + * import {invokeSanityCli} from '@sanity/cli/invokeSanityCli' * - * const {exitCode, output} = await runSanityCli({ + * const {exitCode, output} = await invokeSanityCli({ * args: 'cors list --project-id abc123', * token: extra.authInfo.token, * }) * ``` * * The command classes themselves are deliberately not exported: going through - * {@link runSanityCli} is the only supported way to invoke them in-process. + * {@link invokeSanityCli} is the only supported way to invoke them in-process. + * + * Help works like the regular CLI, scoped to the invokable surface: `--help` + * (or `help` / `-h`) renders root help listing the invokable topics, and a + * subject (`cors --help`, `cors list --help`) renders topic or command help. */ import {fileURLToPath} from 'node:url' @@ -24,11 +28,9 @@ import {Config} from '@oclif/core' import {CLI_TELEMETRY_SYMBOL, exitCodes, noopLogger, setCliTelemetry} from '@sanity/cli-core' import {runWithCliExecutionContext} from '@sanity/cli-core/executionContext' -import {Add as CorsAdd} from '../commands/cors/add.js' -import {Delete as CorsDelete} from '../commands/cors/delete.js' -import {List as CorsList} from '../commands/cors/list.js' -import {List as ProjectsList} from '../commands/projects/list.js' -import {tokenizeCliArgs} from '../util/tokenizeCliArgs.js' +import {tokenizeCliArgs} from '../../util/tokenizeCliArgs.js' +import {isHelpRequest, renderInvokableHelp} from './help.js' +import {type InvokableCommand, invokableCommands} from './InvokableCommands.js' /** * Load the oclif `Config` for this package, needed as the second argument to @@ -37,30 +39,21 @@ import {tokenizeCliArgs} from '../util/tokenizeCliArgs.js' * * @internal */ -export function loadCliCommandConfig(): Promise { +function loadCliCommandConfig(): Promise { // Resolves to the package root from both src/exports (dev) and dist/exports (built) return Config.load(fileURLToPath(new URL('../..', import.meta.url))) } -/** - * Minimal structural type for an invokable oclif command class. - */ -interface InvokableCommand { - run(argv: string[], config: Config): Promise +function unknownCommandResult(argv: string[]): InvokeSanityCliResult { + return { + exitCode: exitCodes.USAGE_ERROR, + output: [ + `Unknown or unsupported command: ${argv.slice(0, 2).join(' ') || '(none)'}`, + `Available commands: ${[...invokableCommands.keys()].join(', ')}`, + ].join('\n'), + } } -/** - * Dispatch table from command id to command class. This doubles as the - * allowlist: anything not in this table cannot be invoked through - * {@link runSanityCli}. - */ -const invokableCommands: ReadonlyMap = new Map([ - ['cors add', CorsAdd], - ['cors delete', CorsDelete], - ['cors list', CorsList], - ['projects list', ProjectsList], -]) - function resolveCommand(argv: string[]): {argv: string[]; command: InvokableCommand} | undefined { // Accept both separator styles: `cors list` and `cors:list` const tokens = argv[0]?.includes(':') ? [...argv[0].split(':'), ...argv.slice(1)] : argv @@ -76,7 +69,7 @@ function resolveCommand(argv: string[]): {argv: string[]; command: InvokableComm /** * @internal */ -export interface RunSanityCliOptions { +export interface InvokeSanityCliOptions { /** * Arguments after `sanity` (a leading `sanity` token is tolerated), either * as a single string — shell-style quoting is supported, but no shell is @@ -101,7 +94,7 @@ export interface RunSanityCliOptions { /** * @internal */ -export interface RunSanityCliResult { +export interface InvokeSanityCliResult { /** `0` on success, the command's exit code otherwise. */ exitCode: number @@ -120,11 +113,13 @@ let cachedConfig: Promise | undefined * * @internal */ -export async function runSanityCli({ +export async function invokeSanityCli({ args, config, token, -}: RunSanityCliOptions): Promise { +}: InvokeSanityCliOptions): Promise { + const resolvedConfig = config ?? (await (cachedConfig ??= loadCliCommandConfig())) + // Commands log through the global telemetry store; default it to a noop // store so embedding hosts need no telemetry wiring (and see no warnings), // without clobbering a store the host may have installed itself. @@ -143,17 +138,33 @@ export async function runSanityCli({ } if (argv[0] === 'sanity') argv = argv.slice(1) - const resolved = resolveCommand(argv) - if (!resolved) { - return { - exitCode: exitCodes.USAGE_ERROR, - output: [ - `Unknown or unsupported command: ${argv.slice(0, 2).join(' ') || '(none)'}`, - `Available commands: ${[...invokableCommands.keys()].join(', ')}`, - ].join('\n'), + // Help requests are routed through oclif's help system, scoped to the + // invokable surface: root help for a bare request, topic/command help when + // a subject is given. Non-invokable subjects get the standard + // unknown-command response (identical to a truly unknown command, so hosts + // can't probe the full CLI surface through help), and a help request never + // executes a command. + if (isHelpRequest(argv)) { + try { + // Drop a leading `help` so the rest is the subject, and present `-h` as + // `--help`, the only help flag oclif's subject resolution recognizes + const helpArgv = (argv[0] === 'help' ? argv.slice(1) : argv).map((token) => + token === '-h' ? '--help' : token, + ) + const output = await renderInvokableHelp(resolvedConfig, helpArgv) + if (output !== undefined) return {exitCode: exitCodes.SUCCESS, output} + return unknownCommandResult(helpArgv.filter((token) => token !== '--help')) + } catch (err) { + return { + exitCode: exitCodes.RUNTIME_ERROR, + output: err instanceof Error ? err.message : String(err), + } } } + const resolved = resolveCommand(argv) + if (!resolved) return unknownCommandResult(argv) + const output: string[] = [] const sink = (line: string) => output.push(line) @@ -161,14 +172,12 @@ export async function runSanityCli({ // it so a failed invocation can't change the host process's exit status. const previousExitCode = process.exitCode try { - const oclifConfig = config ?? (await (cachedConfig ??= loadCliCommandConfig())) - await runWithCliExecutionContext({stderr: sink, stdout: sink, token}, () => - resolved.command.run(resolved.argv, oclifConfig), + resolved.command.run(resolved.argv, resolvedConfig), ) return {exitCode: exitCodes.SUCCESS, output: output.join('\n')} } catch (err) { - const exit = (err as {oclif?: {exit?: false | number}}).oclif?.exit + const exit = err.oclif?.exit // `this.exit(0)` throws an ExitError but is a successful outcome if (exit === exitCodes.SUCCESS) { From c1de742aeda7b981fb8e0c2afd37778348975c0a Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:13:55 +0000 Subject: [PATCH 05/14] chore: update auto-generated changeset for PR #1598 --- .changeset/pr-1598.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/pr-1598.md diff --git a/.changeset/pr-1598.md b/.changeset/pr-1598.md new file mode 100644 index 0000000000..0ed2edf07c --- /dev/null +++ b/.changeset/pr-1598.md @@ -0,0 +1,7 @@ + +--- +'@sanity/cli-core': minor +'@sanity/cli': minor +--- + +feat: AIGRO-4919 - Invoking allowed commands from outside the cli \ No newline at end of file From b3587914823456a7370fe83e0692ce625e24b679 Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Mon, 27 Jul 2026 10:12:06 -0400 Subject: [PATCH 06/14] feat: add command policies based on source invokation --- .../@sanity/cli-core/src/executionContext.ts | 12 +- .../src/exports/__tests__/commands.test.ts | 167 ++++++++++++---- .../invokeSanityCli/InvokableCommands.ts | 20 -- .../invokeSanityCli/commandPolicies/index.ts | 6 + .../commandPolicies/mcpPolicy.ts | 186 ++++++++++++++++++ .../invokeSanityCli/commandPolicies/policy.ts | 41 ++++ .../cli/src/exports/invokeSanityCli/help.ts | 75 ++++--- .../cli/src/exports/invokeSanityCli/index.ts | 119 +++++++---- 8 files changed, 498 insertions(+), 128 deletions(-) delete mode 100644 packages/@sanity/cli/src/exports/invokeSanityCli/InvokableCommands.ts create mode 100644 packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/index.ts create mode 100644 packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts create mode 100644 packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts diff --git a/packages/@sanity/cli-core/src/executionContext.ts b/packages/@sanity/cli-core/src/executionContext.ts index b2a7047a64..9a42d26e3b 100644 --- a/packages/@sanity/cli-core/src/executionContext.ts +++ b/packages/@sanity/cli-core/src/executionContext.ts @@ -42,7 +42,17 @@ export interface CliExecutionContext { token?: string } -const storage = new AsyncLocalStorage() +// Anchored on globalThis (same pattern as CLI_TELEMETRY_SYMBOL) so that the +// context survives dual module instances: an embedding host may import this +// module from source (e.g. via vitest or a bundler) while oclif loads command +// classes from dist, and both must observe the same AsyncLocalStorage. +const STORAGE_SYMBOL = Symbol.for('sanity.cli.executionContext') + +const globalStorage = globalThis as { + [STORAGE_SYMBOL]?: AsyncLocalStorage +} + +const storage = (globalStorage[STORAGE_SYMBOL] ??= new AsyncLocalStorage()) /** * Run `fn` with the given execution context active. The context is visible diff --git a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts index 6a846b0cb4..34d41c8bdf 100644 --- a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts +++ b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts @@ -1,3 +1,4 @@ +import {readFileSync} from 'node:fs' import {fileURLToPath} from 'node:url' import {Config} from '@oclif/core' @@ -6,33 +7,25 @@ import {cleanAll, pendingMocks} from 'nock' import {afterAll, afterEach, beforeAll, describe, expect, test, vi} from 'vitest' import {CORS_API_VERSION} from '../../services/cors.js' +import {commandPolicies} from '../invokeSanityCli/commandPolicies/policy.js' import {invokeSanityCli} from '../invokeSanityCli/index.js' const projectId = 'test-project' -let config: Config -let cachedCliPackageConfig: Promise | undefined - /** - * The CLI package's real oclif config (topics, command manifest from - * oclif.config.js and dist/). Help resolution needs it; the dist build it - * depends on is guaranteed by the pretest script. Loaded once, on demand. + * All tests run against the CLI package's real oclif config (topics and + * command manifest from oclif.config.js and dist/): command resolution and + * help both need it. The dist build it depends on is guaranteed by the + * pretest script. */ -function loadCliPackageConfig(): Promise { - return (cachedCliPackageConfig ??= Config.load( - fileURLToPath(new URL('../../..', import.meta.url)), - )) -} +let config: Config beforeAll(async () => { // Keep tests hermetic: never let token resolution fall back to the // developer's real stored CLI login. vi.stubEnv('SANITY_CLI_CONFIG_PATH', '/nonexistent/sanity-cli-test-config.json') - // Bare oclif config rooted at the monorepo root (which has no oclif - // configuration) — same approach as @sanity/cli-test's testCommand. Avoids - // loading this package's plugins/hooks and does not require a dist build. - config = await Config.load(fileURLToPath(new URL('../../../../../..', import.meta.url))) + config = await Config.load(fileURLToPath(new URL('../../..', import.meta.url))) }) afterAll(() => { @@ -58,6 +51,20 @@ describe('invokeSanityCli', () => { expect(pending, 'pending mocks').toEqual([]) }) + test('the mcp policy covers exactly the commands in the oclif manifest', () => { + // Exhaustiveness keeps the policy honest: a new CLI command fails here + // until it is deliberately categorized (allow/conditional/deny), and a + // policy entry for a removed command fails too. + const manifest = JSON.parse( + readFileSync(fileURLToPath(new URL('../../../oclif.manifest.json', import.meta.url)), 'utf8'), + ) as {commands: Record} + + const manifestIds = Object.keys(manifest.commands).toSorted() + const policyIds = Object.keys(commandPolicies.mcp).toSorted() + + expect(policyIds).toEqual(manifestIds) + }) + test('runs a command from string args, using the provided token', async () => { mockApi({apiVersion: CORS_API_VERSION, uri: `/projects/${projectId}/cors`}) .matchHeader('authorization', 'Bearer user-token') @@ -66,6 +73,7 @@ describe('invokeSanityCli', () => { const result = await invokeSanityCli({ args: `cors list --project-id ${projectId}`, config, + source: 'mcp', token: 'user-token', }) @@ -80,6 +88,7 @@ describe('invokeSanityCli', () => { const result = await invokeSanityCli({ args: ['cors', 'list', '--project-id', projectId], config, + source: 'mcp', token: 'user-token', }) @@ -94,6 +103,7 @@ describe('invokeSanityCli', () => { const result = await invokeSanityCli({ args: `sanity cors list --project-id "${projectId}"`, config, + source: 'mcp', token: 'user-token', }) @@ -108,6 +118,7 @@ describe('invokeSanityCli', () => { const result = await invokeSanityCli({ args: `cors:list --project-id ${projectId}`, config, + source: 'mcp', token: 'user-token', }) @@ -125,8 +136,18 @@ describe('invokeSanityCli', () => { .reply(200, [{...corsOrigin('https://user-b.example.com'), projectId: 'project-b'}]) const [resultA, resultB] = await Promise.all([ - invokeSanityCli({args: 'cors list --project-id project-a', config, token: 'token-a'}), - invokeSanityCli({args: 'cors list --project-id project-b', config, token: 'token-b'}), + invokeSanityCli({ + args: 'cors list --project-id project-a', + config, + source: 'mcp', + token: 'token-a', + }), + invokeSanityCli({ + args: 'cors list --project-id project-b', + config, + source: 'mcp', + token: 'token-b', + }), ]) expect(resultA).toEqual({exitCode: 0, output: 'https://user-a.example.com'}) @@ -142,6 +163,7 @@ describe('invokeSanityCli', () => { const result = await invokeSanityCli({ args: `cors list --project-id ${projectId}`, config, + source: 'mcp', token: 'bogus-token', }) @@ -156,6 +178,7 @@ describe('invokeSanityCli', () => { const result = await invokeSanityCli({ args: 'cors list --no-such-flag', config, + source: 'mcp', token: 'user-token', }) @@ -174,7 +197,12 @@ describe('invokeSanityCli', () => { const previousCwd = process.cwd() process.chdir(fixtureDir) try { - const result = await invokeSanityCli({args: 'cors list', config, token: 'user-token'}) + const result = await invokeSanityCli({ + args: 'cors list', + config, + source: 'mcp', + token: 'user-token', + }) expect(result.exitCode).toBe(1) expect(result.output).toContain('Unable to determine project ID') @@ -183,23 +211,38 @@ describe('invokeSanityCli', () => { } }) - test('rejects commands outside the allowlist', async () => { - const result = await invokeSanityCli({args: 'login', config, token: 'user-token'}) + test.each([ + 'login', // denied: performs an authentication flow + 'schemas list', // denied: requires a local project + 'bogus stuff', // does not exist at all + ])('`%s` is rejected as unknown or unsupported', async (args) => { + const result = await invokeSanityCli({args, config, source: 'mcp', token: 'user-token'}) expect(result.exitCode).toBe(2) - expect(result.output).toContain('Unknown or unsupported command: login') + expect(result.output).toContain(`Unknown or unsupported command: ${args}`) expect(result.output).toContain('cors list') }) - test('rejects real CLI commands that are not allowlisted', async () => { - const result = await invokeSanityCli({args: 'datasets list', config, token: 'user-token'}) + test('denied commands are indistinguishable from unknown commands', async () => { + const denied = await invokeSanityCli({ + args: 'login', + config, + source: 'mcp', + token: 'user-token', + }) + const unknown = await invokeSanityCli({ + args: 'bogus', + config, + source: 'mcp', + token: 'user-token', + }) - expect(result.exitCode).toBe(2) - expect(result.output).toContain('Unknown or unsupported command: datasets list') + expect(denied.exitCode).toBe(unknown.exitCode) + expect(denied.output.replace('login', 'bogus')).toBe(unknown.output) }) test('rejects empty args', async () => { - const result = await invokeSanityCli({args: '', config, token: 'user-token'}) + const result = await invokeSanityCli({args: '', config, source: 'mcp', token: 'user-token'}) expect(result.exitCode).toBe(2) expect(result.output).toContain('Unknown or unsupported command') @@ -209,6 +252,7 @@ describe('invokeSanityCli', () => { const result = await invokeSanityCli({ args: 'cors add "https://example.com', config, + source: 'mcp', token: 'user-token', }) @@ -216,19 +260,45 @@ describe('invokeSanityCli', () => { expect(result.output).toContain('Unterminated double quote') }) + test.each([ + 'docs read /docs/studio/installation --web', // --web opens a browser on the host + 'graphql undeploy --api ios --force', // --api loads local GraphQL definitions + ])('`%s` is refused by a conditional policy', async (args) => { + const result = await invokeSanityCli({args, config, source: 'mcp', token: 'user-token'}) + + expect(result.exitCode).toBe(2) + expect(result.output).toContain('is not supported here') + }) + + test('conditional policies see parsed flags, not raw tokens', async () => { + // `--web` after `--` is a positional argument, not a flag, so the policy + // must not refuse it. The command is strict, so oclif's parser rejects + // the unexpected positional instead — proving the invocation got past + // the policy gate to real argument parsing. + const result = await invokeSanityCli({ + args: ['docs', 'read', '/docs/studio/installation', '--', '--web'], + config, + source: 'mcp', + token: 'user-token', + }) + + expect(result.output).not.toContain('is not supported here') + }) + test.each(['--help', '-h', 'help', 'sanity --help'])( - '`%s` renders root help scoped to the invokable command surface', + '`%s` renders root help scoped to the policy surface', async (args) => { - const cliPackageConfig = await loadCliPackageConfig() - const result = await invokeSanityCli({args, config: cliPackageConfig, token: 'user-token'}) + const result = await invokeSanityCli({args, config, source: 'mcp', token: 'user-token'}) expect(result.exitCode).toBe(0) expect(result.output).toContain('USAGE') expect(result.output).toContain('TOPICS') expect(result.output).toContain('Manage CORS origins for your project') expect(result.output).toContain('Manage Sanity projects') - // Non-invokable topics must not be advertised - expect(result.output).not.toContain('datasets') + expect(result.output).toContain('Manage datasets in your project') + // Fully denied topics must not be advertised + expect(result.output).not.toContain('migrations') + expect(result.output).not.toContain('tokens') // Plain text for programmatic callers: no ANSI escape codes expect(result.output).not.toContain('\u001B') }, @@ -237,8 +307,7 @@ describe('invokeSanityCli', () => { test.each(['cors --help', 'cors -h', 'help cors'])( '`%s` renders topic help listing only invokable commands', async (args) => { - const cliPackageConfig = await loadCliPackageConfig() - const result = await invokeSanityCli({args, config: cliPackageConfig, token: 'user-token'}) + const result = await invokeSanityCli({args, config, source: 'mcp', token: 'user-token'}) expect(result.exitCode).toBe(0) expect(result.output).toContain('Manage CORS origins for your project') @@ -248,11 +317,25 @@ describe('invokeSanityCli', () => { }, ) + test('topic help omits denied commands within the topic', async () => { + const result = await invokeSanityCli({ + args: 'datasets --help', + config, + source: 'mcp', + token: 'user-token', + }) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('datasets list') + // datasets export/import touch the local filesystem and are denied + expect(result.output).not.toContain('datasets export') + expect(result.output).not.toContain('datasets import') + }) + test.each(['cors list --help', 'help cors list', 'cors:list --help'])( '`%s` renders command help with usage and flags', async (args) => { - const cliPackageConfig = await loadCliPackageConfig() - const result = await invokeSanityCli({args, config: cliPackageConfig, token: 'user-token'}) + const result = await invokeSanityCli({args, config, source: 'mcp', token: 'user-token'}) expect(result.exitCode).toBe(0) expect(result.output).toContain('List CORS origins for the project') @@ -265,8 +348,7 @@ describe('invokeSanityCli', () => { // Regression: oclif's help formatters rewrite command ids in place // (`cors:list` → `cors list`); without defensive copies this corrupts the // shared config and later help calls lose commands (or execute them) - const cliPackageConfig = await loadCliPackageConfig() - const opts = {config: cliPackageConfig, token: 'user-token'} + const opts = {config, source: 'mcp', token: 'user-token'} as const const first = await invokeSanityCli({args: 'cors --help', ...opts}) await invokeSanityCli({args: 'cors list --help', ...opts}) @@ -277,15 +359,16 @@ describe('invokeSanityCli', () => { }) test.each([ - 'datasets --help', // real topic, not invokable - 'login --help', // real root command, not invokable + 'schemas --help', // real topic, fully denied + 'login --help', // real root command, denied + 'datasets export --help', // real command under a visible topic, denied 'bogus --help', // does not exist at all ])('`%s` is rejected identically to an unknown command', async (args) => { - const cliPackageConfig = await loadCliPackageConfig() - const result = await invokeSanityCli({args, config: cliPackageConfig, token: 'user-token'}) + const result = await invokeSanityCli({args, config, source: 'mcp', token: 'user-token'}) expect(result.exitCode).toBe(2) expect(result.output).toContain('Unknown or unsupported command') - expect(result.output).toContain('Available commands: cors add, cors delete, cors list') + expect(result.output).toContain('Available commands:') + expect(result.output).toContain('cors add') }) }) diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/InvokableCommands.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/InvokableCommands.ts deleted file mode 100644 index bbc1772dfa..0000000000 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/InvokableCommands.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {type Config} from '@oclif/core' - -import {Add as CorsAdd} from '../../commands/cors/add.js' -import {Delete as CorsDelete} from '../../commands/cors/delete.js' -import {List as CorsList} from '../../commands/cors/list.js' -import {List as ProjectsList} from '../../commands/projects/list.js' - -export interface InvokableCommand { - run(argv: string[], config: Config): Promise -} - -export const invokableCommands: ReadonlyMap = new Map< - string, - InvokableCommand ->([ - ['cors add', CorsAdd], - ['cors delete', CorsDelete], - ['cors list', CorsList], - ['projects list', ProjectsList], -]) diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/index.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/index.ts new file mode 100644 index 0000000000..30601e10f4 --- /dev/null +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/index.ts @@ -0,0 +1,6 @@ +import {mcpPolicy} from './mcpPolicy' +import {CommandPolicySet, InvocationSource} from './policy' + +export const commandPolicies: Record = { + mcp: mcpPolicy, +} diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts new file mode 100644 index 0000000000..349d751817 --- /dev/null +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts @@ -0,0 +1,186 @@ +import {allow, CommandPolicySet, conditional, deny} from './policy' + +/** + * MCP programmatic mode disables local project/config discovery (see the CLI + * execution context). Missing project or dataset values may therefore produce + * a usage error, but cannot cause local filesystem access. Destructive remote + * operations are allowed and do not by themselves make a command unsafe. + * + * Every manifest command must have exactly one policy here: + * - allow: every valid invocation is safe + * - conditional: safety depends on parsed arguments or flags + * - deny: no invocation is safe + */ +export const mcpPolicy: CommandPolicySet = { + // Special exception, this can be very dangerous but is also super useful to expose + api: allow, + + 'backups:disable': allow, + // Writes a downloaded backup to the local filesystem. + 'backups:download': deny, + 'backups:enable': allow, + 'backups:list': allow, + + // Requires a local Studio project and writes build output to disk. + build: deny, + + // Reads and rewrites local source code. + codemod: deny, + + 'cors:add': allow, + 'cors:delete': allow, + 'cors:list': allow, + + 'datasets:alias:create': allow, + 'datasets:alias:delete': allow, + 'datasets:alias:link': allow, + 'datasets:alias:unlink': allow, + 'datasets:copy': allow, + 'datasets:create': allow, + 'datasets:delete': allow, + 'datasets:embeddings:disable': allow, + 'datasets:embeddings:enable': allow, + 'datasets:embeddings:status': allow, + // Writes dataset contents and assets to the local filesystem. + 'datasets:export': deny, + // Reads import data from the local filesystem and may replace documents. + 'datasets:import': deny, + 'datasets:list': allow, + 'datasets:visibility:get': allow, + 'datasets:visibility:set': allow, + + // Inspects local project files and can print authentication secrets. + debug: deny, + + // Reads, builds, and deploys a local Studio project. + deploy: deny, + + // Loads a local Studio project and starts a development server. + dev: deny, + + // Opens a browser on the machine running the MCP server. + 'docs:browse': deny, + // --web opens a browser on the machine running the MCP server. + 'docs:read': conditional(({flags}) => flags.web !== true), + 'docs:search': allow, + + // Reads and executes local project configuration for diagnostics. + doctor: deny, + + // Reads document input from disk or launches a local editor. + 'documents:create': deny, + 'documents:delete': allow, + 'documents:get': allow, + 'documents:query': allow, + // Loads a local Studio schema to validate documents. + 'documents:validate': deny, + + // Executes arbitrary code in the local Studio context. + exec: deny, + + // Loads a local schema and deploys a GraphQL API. + 'graphql:deploy': deny, + 'graphql:list': allow, + // --api loads GraphQL definitions from the local project; explicit project/dataset flags are safe. + 'graphql:undeploy': conditional(({flags}) => flags.api === undefined), + + 'hooks:attempt': allow, + 'hooks:create': allow, + 'hooks:delete': allow, + 'hooks:list': allow, + 'hooks:logs': allow, + + // Creates or modifies a local project and may install dependencies. + init: deny, + + // Installs packages into the local project. + install: deny, + + // Opens a browser on the machine running the MCP server. + learn: deny, + + // Performs an authentication flow. + login: deny, + + // Performs an authentication operation and clears local credentials. + logout: deny, + + // Reads local project configuration and opens a browser. + manage: deny, + + // Loads local Studio configuration and writes manifest files. + 'manifest:extract': deny, + + // Reads and writes local MCP client configuration. + 'mcp:configure': deny, + + // Writes an aspect definition to the local filesystem. + 'media:create-aspect': deny, + 'media:delete-aspect': allow, + // Reads a local aspect definition before deploying it. + 'media:deploy-aspect': deny, + // Writes media assets to the local filesystem. + 'media:export': deny, + // Reads media assets from the local filesystem. + 'media:import': deny, + + // Creates migration source files in the local project. + 'migrations:create': deny, + // Reads and loads migration definitions from the local project. + 'migrations:list': deny, + // Executes local migration code that may perform arbitrary document mutations. + 'migrations:run': deny, + + // --web opens a browser on the machine running the MCP server. + 'openapi:get': conditional(({flags}) => flags.web !== true), + 'openapi:list': conditional(({flags}) => flags.web !== true), + + 'organizations:create': allow, + 'organizations:delete': allow, + 'organizations:get': allow, + 'organizations:list': allow, + 'organizations:update': allow, + + // Serves a local production build. + preview: deny, + + 'projects:create': allow, + 'projects:list': allow, + + // Loads the local Studio configuration to resolve the datasets containing each schema. + 'schemas:delete': deny, + // Loads local schema files before deploying them. + 'schemas:deploy': deny, + // Loads local Studio configuration and writes an extracted schema to disk. + 'schemas:extract': deny, + // Requires a local project and loads its schema configuration. + 'schemas:list': deny, + // Loads and executes a local Studio schema. + 'schemas:validate': deny, + + // Installs skills into local editor configuration directories. + 'skills:install': deny, + + // Changes account telemetry preferences and mutates local cached configuration. + 'telemetry:disable': deny, + // Changes account telemetry preferences and mutates local cached configuration. + 'telemetry:enable': deny, + 'telemetry:status': allow, + + // Creates authentication credentials. + 'tokens:create': deny, + // Deletes authentication credentials. + 'tokens:delete': deny, + // Exposes authentication credential metadata. + 'tokens:list': deny, + + // Loads local CLI and workbench configuration to identify the deployed Studio or application. + undeploy: deny, + + // Grants a user access to a project. + 'users:invite': deny, + 'users:list': allow, + + // Reads the local project and installed package tree. + versions: deny, +} diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts new file mode 100644 index 0000000000..0bb8eb0f1e --- /dev/null +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts @@ -0,0 +1,41 @@ +/** + * Per-source command policies for programmatic CLI invocation. + * + * A policy decides, for one invocation source (e.g. the remote MCP server), + * which commands may be invoked and — for conditional entries — which parsed + * invocations of those commands are acceptable. Policies are exhaustive: + * every command in this package's oclif manifest has exactly one entry, so a + * newly added CLI command cannot slip into (or ambiguously stay out of) the + * invokable surface. A unit test enforces this; at runtime an uncategorized + * command id fails closed (treated as deny). + */ + +/** The parsed command invocation a conditional policy is evaluated against. */ +export interface Invocation { + args: Readonly> + flags: Readonly> +} + +export type InvocationPolicy = (invocation: Invocation) => boolean + +export interface CommandPolicy { + kind: 'allow' | 'conditional' | 'deny' + validate: InvocationPolicy +} + +/** Every valid invocation of the command is safe. */ +export const allow: CommandPolicy = {kind: 'allow', validate: () => true} + +/** No invocation of the command is safe. Behaves like an unknown command. */ +export const deny: CommandPolicy = {kind: 'deny', validate: () => false} + +/** Safety depends on the parsed arguments or flags. */ +export function conditional(validate: InvocationPolicy): CommandPolicy { + return {kind: 'conditional', validate} +} + +/** A complete policy table, keyed by oclif command id. */ +export type CommandPolicySet = Readonly> + +/** Where an invocation originates; selects the policy to enforce. */ +export type InvocationSource = 'mcp' diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/help.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/help.ts index a42aeed532..9c47108799 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/help.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/help.ts @@ -1,24 +1,37 @@ import {type Command, type Config, Help, type Interfaces} from '@oclif/core' -import {invokableCommands} from './InvokableCommands.js' +import {type CommandPolicySet} from './commandPolicies/policy.js' -const invokableCommandIds = new Set( - [...invokableCommands.keys()].map((id) => id.replaceAll(' ', ':')), -) +/** Command ids a policy exposes: entries that are not denied. */ +function visibleCommandIds(policySet: CommandPolicySet): Set { + return new Set( + Object.entries(policySet) + .filter(([, policy]) => policy.kind !== 'deny') + .map(([id]) => id), + ) +} -const invokableTopicNames = new Set( - [...invokableCommandIds].filter((id) => id.includes(':')).map((id) => id.split(':')[0]), -) +/** Topic names covering the visible commands: every prefix of every id. */ +function visibleTopicNames(commandIds: Set): Set { + const names = new Set() + for (const id of commandIds) { + const parts = id.split(':') + for (let length = 1; length < parts.length; length++) { + names.add(parts.slice(0, length).join(':')) + } + } + return names +} -/** Thrown when help is requested for a subject outside the invokable surface. */ +/** Thrown when help is requested for a subject outside the policy surface. */ class NotInvokableError extends Error {} /** - * oclif help renderer scoped to the invokable command surface. Subject - * resolution (root vs topic vs command help) is oclif's own; the allowlist is + * oclif help renderer scoped to a policy's command surface. Subject + * resolution (root vs topic vs command help) is oclif's own; the policy is * enforced at the rendering entry points so the rest of the CLI stays - * invisible to embedding hosts, and listings are filtered to allowlisted - * topics/commands only. + * invisible to embedding hosts, and listings are filtered to non-denied + * commands and their topics only. * * Output is collected in {@link InvokableHelp.lines} instead of being written * to the process streams. @@ -26,21 +39,30 @@ class NotInvokableError extends Error {} class InvokableHelp extends Help { public readonly lines: string[] = [] + private readonly commandIds: Set + private readonly topicNames: Set + + constructor(config: Config, policySet: CommandPolicySet) { + super(config, {stripAnsi: true}) + this.commandIds = visibleCommandIds(policySet) + this.topicNames = visibleTopicNames(this.commandIds) + } + // The copies below are load-bearing: oclif's formatters rewrite ids/names // in place (`cors:list` → `cors list`). Without copies those writes corrupt - // the shared (cached) config, breaking the allowlist checks on subsequent + // the shared (cached) config, breaking the policy checks on subsequent // invocations. protected override get sortedCommands(): Command.Loadable[] { return super.sortedCommands - .filter((command) => invokableCommandIds.has(command.id)) + .filter((command) => this.commandIds.has(command.id)) .map((command) => ({...command})) } protected override get sortedTopics(): Interfaces.Topic[] { // Topic names and descriptions come from the oclif config (oclif.config.js) return super.sortedTopics - .filter((topic) => invokableTopicNames.has(topic.name)) + .filter((topic) => this.topicNames.has(topic.name)) .map((topic) => ({...topic})) } @@ -49,39 +71,40 @@ class InvokableHelp extends Help { } public override async showCommandHelp(command: Command.Loadable): Promise { - if (!invokableCommandIds.has(command.id)) throw new NotInvokableError(command.id) + if (!this.commandIds.has(command.id)) throw new NotInvokableError(command.id) return super.showCommandHelp({...command}) } protected override async showTopicHelp(topic: Interfaces.Topic): Promise { - if (!invokableTopicNames.has(topic.name)) throw new NotInvokableError(topic.name) + if (!this.topicNames.has(topic.name)) throw new NotInvokableError(topic.name) return super.showTopicHelp({...topic}) } } /** - * Render help text for the invokable command surface: root help for a bare - * help request, or topic/command help when `argv` names a subject (e.g. + * Render help text for a policy's command surface: root help for a bare help + * request, or topic/command help when `argv` names a subject (e.g. * `['cors', '--help']`), exactly as oclif would resolve it. ANSI styling is * stripped so programmatic callers get plain text. * - * Returns `undefined` when the subject is unknown or not invokable — callers - * should respond the same way as for an unknown command, so hosts cannot - * probe the full CLI surface through help. + * Returns `undefined` when the subject is unknown or denied — callers should + * respond the same way as for an unknown command, so hosts cannot probe the + * full CLI surface through help. * * @internal */ export async function renderInvokableHelp( config: Config, argv: string[], + policySet: CommandPolicySet, ): Promise { - const help = new InvokableHelp(config, {stripAnsi: true}) + const help = new InvokableHelp(config, policySet) try { await help.showHelp(argv) } catch (err) { - // Subjects outside the allowlist (NotInvokableError) and subjects oclif - // itself cannot resolve (CLIError, marked with an `oclif` property) both - // yield no help output. + // Subjects outside the policy surface (NotInvokableError) and subjects + // oclif itself cannot resolve (CLIError, marked with an `oclif` property) + // both yield no help output. if (err instanceof NotInvokableError || err.oclif) return undefined throw err } diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts index 574a33a4e5..6b14efb2d9 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts @@ -1,41 +1,41 @@ /** * Programmatic (in-process) invocation of CLI commands, e.g. from an MCP - * server. This is a curated allowlist: only commands that are pure API - * operations — no filesystem access, no interactive-only flows — are - * invokable here. + * server. The invokable surface is governed by a per-source command policy + * (see ./policy.ts): every CLI command is explicitly allowed, denied, or + * allowed conditionally on the parsed invocation. * - * {@link invokeSanityCli} handles arg parsing, - * command dispatch, per-invocation auth, and output capture. + * {@link invokeSanityCli} handles arg parsing, policy enforcement, command + * dispatch, per-invocation auth, and output capture. * ```ts * import {invokeSanityCli} from '@sanity/cli/invokeSanityCli' * * const {exitCode, output} = await invokeSanityCli({ * args: 'cors list --project-id abc123', + * source: 'mcp', * token: extra.authInfo.token, * }) * ``` * - * The command classes themselves are deliberately not exported: going through - * {@link invokeSanityCli} is the only supported way to invoke them in-process. - * - * Help works like the regular CLI, scoped to the invokable surface: `--help` + * Help works like the regular CLI, scoped to the source's policy: `--help` * (or `help` / `-h`) renders root help listing the invokable topics, and a * subject (`cors --help`, `cors list --help`) renders topic or command help. */ import {fileURLToPath} from 'node:url' -import {Config} from '@oclif/core' +import {Config, Parser} from '@oclif/core' +import {normalizeArgv} from '@oclif/core/help' import {CLI_TELEMETRY_SYMBOL, exitCodes, noopLogger, setCliTelemetry} from '@sanity/cli-core' import {runWithCliExecutionContext} from '@sanity/cli-core/executionContext' import {tokenizeCliArgs} from '../../util/tokenizeCliArgs.js' +import {commandPolicies} from './commandPolicies/index.js' +import {type CommandPolicySet, deny, type InvocationSource} from './commandPolicies/policy.js' import {isHelpRequest, renderInvokableHelp} from './help.js' -import {type InvokableCommand, invokableCommands} from './InvokableCommands.js' /** - * Load the oclif `Config` for this package, needed as the second argument to - * `Command.run(argv, config)`. Loading it once and reusing it across - * invocations avoids re-reading the command manifest per call. + * Load the oclif `Config` for this package, needed to resolve, load, and run + * commands. Loading it once and reusing it across invocations avoids + * re-reading the command manifest per call. * * @internal */ @@ -44,28 +44,20 @@ function loadCliCommandConfig(): Promise { return Config.load(fileURLToPath(new URL('../..', import.meta.url))) } -function unknownCommandResult(argv: string[]): InvokeSanityCliResult { +function unknownCommandResult(argv: string[], policySet: CommandPolicySet): InvokeSanityCliResult { + const available = Object.entries(policySet) + .filter(([, policy]) => policy.kind !== 'deny') + .map(([id]) => id.replaceAll(':', ' ')) + .toSorted() return { exitCode: exitCodes.USAGE_ERROR, output: [ `Unknown or unsupported command: ${argv.slice(0, 2).join(' ') || '(none)'}`, - `Available commands: ${[...invokableCommands.keys()].join(', ')}`, + `Available commands: ${available.join(', ')}`, ].join('\n'), } } -function resolveCommand(argv: string[]): {argv: string[]; command: InvokableCommand} | undefined { - // Accept both separator styles: `cors list` and `cors:list` - const tokens = argv[0]?.includes(':') ? [...argv[0].split(':'), ...argv.slice(1)] : argv - - // Longest command id first, so future single-token commands can coexist - for (const idLength of [2, 1]) { - const command = invokableCommands.get(tokens.slice(0, idLength).join(' ')) - if (command) return {argv: tokens.slice(idLength), command} - } - return undefined -} - /** * @internal */ @@ -77,6 +69,12 @@ export interface InvokeSanityCliOptions { */ args: string | string[] + /** + * Where this invocation originates. Selects the command policy to enforce: + * which commands are invokable and which invocations of them are permitted. + */ + source: InvocationSource + /** * Auth token for this invocation. Scoped to this call via the CLI execution * context: it never touches process env or the process-wide token cache, so @@ -105,7 +103,7 @@ export interface InvokeSanityCliResult { let cachedConfig: Promise | undefined /** - * Run an allowlisted CLI command in-process and capture its result. + * Run a policy-permitted CLI command in-process and capture its result. * * Command-level failures (unknown command, bad flags, API errors) are * reported through `exitCode`/`output` rather than thrown, so callers can @@ -116,9 +114,11 @@ let cachedConfig: Promise | undefined export async function invokeSanityCli({ args, config, + source, token, }: InvokeSanityCliOptions): Promise { const resolvedConfig = config ?? (await (cachedConfig ??= loadCliCommandConfig())) + const policySet = commandPolicies[source] // Commands log through the global telemetry store; default it to a noop // store so embedding hosts need no telemetry wiring (and see no warnings), @@ -139,11 +139,11 @@ export async function invokeSanityCli({ if (argv[0] === 'sanity') argv = argv.slice(1) // Help requests are routed through oclif's help system, scoped to the - // invokable surface: root help for a bare request, topic/command help when - // a subject is given. Non-invokable subjects get the standard - // unknown-command response (identical to a truly unknown command, so hosts - // can't probe the full CLI surface through help), and a help request never - // executes a command. + // source's policy: root help for a bare request, topic/command help when a + // subject is given. Denied subjects get the standard unknown-command + // response (identical to a truly unknown command, so hosts can't probe the + // full CLI surface through help), and a help request never executes a + // command. if (isHelpRequest(argv)) { try { // Drop a leading `help` so the rest is the subject, and present `-h` as @@ -151,9 +151,12 @@ export async function invokeSanityCli({ const helpArgv = (argv[0] === 'help' ? argv.slice(1) : argv).map((token) => token === '-h' ? '--help' : token, ) - const output = await renderInvokableHelp(resolvedConfig, helpArgv) + const output = await renderInvokableHelp(resolvedConfig, helpArgv, policySet) if (output !== undefined) return {exitCode: exitCodes.SUCCESS, output} - return unknownCommandResult(helpArgv.filter((token) => token !== '--help')) + return unknownCommandResult( + helpArgv.filter((token) => token !== '--help'), + policySet, + ) } catch (err) { return { exitCode: exitCodes.RUNTIME_ERROR, @@ -162,8 +165,46 @@ export async function invokeSanityCli({ } } - const resolved = resolveCommand(argv) - if (!resolved) return unknownCommandResult(argv) + // Resolve the command id the same way oclif's dispatch would (collating + // space-separated topics, accepting colon-separated ids as-is), then apply + // the policy. Denied and uncategorized ids fail closed, indistinguishable + // from commands that don't exist. + const [commandId = '', ...commandArgv] = normalizeArgv(resolvedConfig, argv) + const policy = policySet[commandId] ?? deny + const commandDefinition = + policy.kind === 'deny' ? undefined : resolvedConfig.findCommand(commandId) + if (!commandDefinition) return unknownCommandResult(argv, policySet) + + const CommandClass = await commandDefinition.load() + + // Parse with the command's real definitions (without executing anything) so + // conditional policies are evaluated against typed args/flags, not tokens. + let invocation: {args: Record; flags: Record} + try { + const parsed = await Parser.parse(commandArgv, { + args: CommandClass.args, + baseFlags: CommandClass.baseFlags, + enableJsonFlag: CommandClass.enableJsonFlag, + flags: CommandClass.flags, + strict: CommandClass.strict, + }) + invocation = { + args: parsed.args as Record, + flags: parsed.flags as Record, + } + } catch (err) { + return { + exitCode: exitCodes.USAGE_ERROR, + output: err instanceof Error ? err.message : String(err), + } + } + + if (!policy.validate(invocation)) { + return { + exitCode: exitCodes.USAGE_ERROR, + output: `This invocation of \`${commandId.replaceAll(':', ' ')}\` is not supported here`, + } + } const output: string[] = [] const sink = (line: string) => output.push(line) @@ -173,7 +214,7 @@ export async function invokeSanityCli({ const previousExitCode = process.exitCode try { await runWithCliExecutionContext({stderr: sink, stdout: sink, token}, () => - resolved.command.run(resolved.argv, resolvedConfig), + CommandClass.run(commandArgv, resolvedConfig), ) return {exitCode: exitCodes.SUCCESS, output: output.join('\n')} } catch (err) { From 6ffc6ed8896905746dc1ebfac8c8aa8ba5479bd6 Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Mon, 27 Jul 2026 10:13:24 -0400 Subject: [PATCH 07/14] fix: test --- packages/@sanity/cli/src/exports/__tests__/commands.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts index 34d41c8bdf..89810015ea 100644 --- a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts +++ b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts @@ -7,7 +7,7 @@ import {cleanAll, pendingMocks} from 'nock' import {afterAll, afterEach, beforeAll, describe, expect, test, vi} from 'vitest' import {CORS_API_VERSION} from '../../services/cors.js' -import {commandPolicies} from '../invokeSanityCli/commandPolicies/policy.js' +import {commandPolicies} from '../invokeSanityCli/commandPolicies/index.js' import {invokeSanityCli} from '../invokeSanityCli/index.js' const projectId = 'test-project' From 93e780afd94bb057323fb41c5bc66d744cfdbfa4 Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Mon, 27 Jul 2026 10:24:47 -0400 Subject: [PATCH 08/14] fix: clean up --- packages/@sanity/cli/src/commands/cors/add.ts | 3 --- packages/@sanity/cli/src/commands/cors/delete.ts | 3 --- packages/@sanity/cli/src/commands/cors/list.ts | 3 --- packages/@sanity/cli/src/commands/projects/list.ts | 3 --- .../src/exports/invokeSanityCli/commandPolicies/index.ts | 4 ++-- .../exports/invokeSanityCli/commandPolicies/mcpPolicy.ts | 2 +- .../src/exports/invokeSanityCli/commandPolicies/policy.ts | 2 +- packages/@sanity/cli/src/exports/invokeSanityCli/index.ts | 6 ++---- 8 files changed, 6 insertions(+), 20 deletions(-) diff --git a/packages/@sanity/cli/src/commands/cors/add.ts b/packages/@sanity/cli/src/commands/cors/add.ts index 8aa269de7d..6b3c26ebc7 100644 --- a/packages/@sanity/cli/src/commands/cors/add.ts +++ b/packages/@sanity/cli/src/commands/cors/add.ts @@ -15,9 +15,6 @@ import {getProjectIdFlag} from '../../util/sharedFlags.js' const addCorsDebug = subdebug('cors:add') -/** - * @internal - */ export class Add extends SanityCommand { static override args = { origin: Args.string({ diff --git a/packages/@sanity/cli/src/commands/cors/delete.ts b/packages/@sanity/cli/src/commands/cors/delete.ts index faceddce09..0ad3a446a4 100644 --- a/packages/@sanity/cli/src/commands/cors/delete.ts +++ b/packages/@sanity/cli/src/commands/cors/delete.ts @@ -8,9 +8,6 @@ import {getProjectIdFlag} from '../../util/sharedFlags.js' const deleteCorsDebug = subdebug('cors:delete') -/** - * @internal - */ export class Delete extends SanityCommand { static override args = { origin: Args.string({ diff --git a/packages/@sanity/cli/src/commands/cors/list.ts b/packages/@sanity/cli/src/commands/cors/list.ts index 83bc93f156..e53a222ad5 100644 --- a/packages/@sanity/cli/src/commands/cors/list.ts +++ b/packages/@sanity/cli/src/commands/cors/list.ts @@ -6,9 +6,6 @@ import {getProjectIdFlag} from '../../util/sharedFlags.js' const listCorsDebug = subdebug('cors:list') -/** - * @internal - */ export class List extends SanityCommand { static override description = 'List CORS origins for the project' static override examples = [ diff --git a/packages/@sanity/cli/src/commands/projects/list.ts b/packages/@sanity/cli/src/commands/projects/list.ts index e8ad25c6d1..6b77e2a961 100644 --- a/packages/@sanity/cli/src/commands/projects/list.ts +++ b/packages/@sanity/cli/src/commands/projects/list.ts @@ -12,9 +12,6 @@ const sortFields = ['id', 'members', 'name', 'url', 'created'] const projectsDebug = subdebug('projects') -/** - * @internal - */ export class List extends SanityCommand { static override description = 'List your projects' static override examples = [ diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/index.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/index.ts index 30601e10f4..f683159b86 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/index.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/index.ts @@ -1,5 +1,5 @@ -import {mcpPolicy} from './mcpPolicy' -import {CommandPolicySet, InvocationSource} from './policy' +import {mcpPolicy} from './mcpPolicy.js' +import {type CommandPolicySet, type InvocationSource} from './policy.js' export const commandPolicies: Record = { mcp: mcpPolicy, diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts index 349d751817..2d79323b3c 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts @@ -1,4 +1,4 @@ -import {allow, CommandPolicySet, conditional, deny} from './policy' +import {allow, type CommandPolicySet, conditional, deny} from './policy.js' /** * MCP programmatic mode disables local project/config discovery (see the CLI diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts index 0bb8eb0f1e..62869d2282 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts @@ -11,7 +11,7 @@ */ /** The parsed command invocation a conditional policy is evaluated against. */ -export interface Invocation { +interface Invocation { args: Readonly> flags: Readonly> } diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts index 6b14efb2d9..5e7451afdf 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts @@ -1,8 +1,8 @@ /** * Programmatic (in-process) invocation of CLI commands, e.g. from an MCP * server. The invokable surface is governed by a per-source command policy - * (see ./policy.ts): every CLI command is explicitly allowed, denied, or - * allowed conditionally on the parsed invocation. + * (see ./commandPolicies): every CLI command is explicitly allowed, denied, + * or allowed conditionally on the parsed invocation. * * {@link invokeSanityCli} handles arg parsing, policy enforcement, command * dispatch, per-invocation auth, and output capture. @@ -36,8 +36,6 @@ import {isHelpRequest, renderInvokableHelp} from './help.js' * Load the oclif `Config` for this package, needed to resolve, load, and run * commands. Loading it once and reusing it across invocations avoids * re-reading the command manifest per call. - * - * @internal */ function loadCliCommandConfig(): Promise { // Resolves to the package root from both src/exports (dev) and dist/exports (built) From 23ee83255e15fd583e5b9dbde101021795500eea Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Mon, 27 Jul 2026 10:53:17 -0400 Subject: [PATCH 09/14] fix: test --- .../src/exports/__tests__/commands.test.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts index 89810015ea..90cd5b1d7b 100644 --- a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts +++ b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts @@ -1,4 +1,3 @@ -import {readFileSync} from 'node:fs' import {fileURLToPath} from 'node:url' import {Config} from '@oclif/core' @@ -51,18 +50,21 @@ describe('invokeSanityCli', () => { expect(pending, 'pending mocks').toEqual([]) }) - test('the mcp policy covers exactly the commands in the oclif manifest', () => { + test('the mcp policy covers exactly this package’s visible commands', () => { // Exhaustiveness keeps the policy honest: a new CLI command fails here // until it is deliberately categorized (allow/conditional/deny), and a - // policy entry for a removed command fails too. - const manifest = JSON.parse( - readFileSync(fileURLToPath(new URL('../../../oclif.manifest.json', import.meta.url)), 'utf8'), - ) as {commands: Record} - - const manifestIds = Object.keys(manifest.commands).toSorted() + // policy entry for a removed command fails too. Command ids come from the + // loaded oclif config — the same source invokeSanityCli resolves against — + // scoped to this package's own visible commands: hidden entries are alias + // redirects, and commands contributed by other plugins (blueprints, + // typegen, help) are uncategorized by design, so they fail closed. + const commandIds = config.commands + .filter((command) => command.pluginName === config.pjson.name && !command.hidden) + .map((command) => command.id) + .toSorted() const policyIds = Object.keys(commandPolicies.mcp).toSorted() - expect(policyIds).toEqual(manifestIds) + expect(policyIds).toEqual(commandIds) }) test('runs a command from string args, using the provided token', async () => { From 0253f033ffd411021f184954c684095303a3a8ba Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Mon, 27 Jul 2026 15:08:52 -0400 Subject: [PATCH 10/14] fix: use Config.load to get config file safeguard host process --- .../@sanity/cli-core/src/SanityCommand.ts | 11 ++++ .../src/__tests__/SanityCommand.test.ts | 51 +++++++++++++++++++ .../src/exports/__tests__/commands.test.ts | 12 +++++ .../cli/src/exports/invokeSanityCli/index.ts | 10 +--- 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/packages/@sanity/cli-core/src/SanityCommand.ts b/packages/@sanity/cli-core/src/SanityCommand.ts index 6e944c1890..f7751da537 100644 --- a/packages/@sanity/cli-core/src/SanityCommand.ts +++ b/packages/@sanity/cli-core/src/SanityCommand.ts @@ -113,6 +113,17 @@ export abstract class SanityCommand // In other cases, we _do_ want to report the error reportCliTraceError(err) + + // oclif's base `catch` sets `process.exitCode` as a side effect + // we do not want to write to the host's own exit status. + if (getCliExecutionContext()) { + if (this.jsonEnabled()) { + this.logJson(this.toErrorJson(err)) + return + } + throw err + } + return super.catch(err) } diff --git a/packages/@sanity/cli-core/src/__tests__/SanityCommand.test.ts b/packages/@sanity/cli-core/src/__tests__/SanityCommand.test.ts index ebe451c3b8..afc59063c8 100644 --- a/packages/@sanity/cli-core/src/__tests__/SanityCommand.test.ts +++ b/packages/@sanity/cli-core/src/__tests__/SanityCommand.test.ts @@ -238,4 +238,55 @@ describe('SanityCommand', () => { } }) }) + + describe('catch (error handling)', () => { + test('under an execution context, a command failure does not touch process.exitCode', async () => { + const previousExitCode = process.exitCode + const cmdClass = createMockedRunCommand({ + run: async () => { + throw new Error('kaboom') + }, + }) + + await expect( + runWithCliExecutionContext({stderr: () => {}, stdout: () => {}}, () => cmdClass.run([])), + ).rejects.toThrow('kaboom') + + expect(process.exitCode).toBe(previousExitCode) + }) + + test('without an execution context, a command failure still sets process.exitCode as before', async () => { + const previousExitCode = process.exitCode + const cmdClass = createMockedRunCommand({ + run: async () => { + throw new Error('kaboom') + }, + }) + + try { + await expect(cmdClass.run([])).rejects.toThrow('kaboom') + expect(process.exitCode).toBe(1) + } finally { + process.exitCode = previousExitCode + } + }) + + test('under an execution context with --json, the error is logged as JSON instead of thrown', async () => { + const out: string[] = [] + const previousExitCode = process.exitCode + const cmdClass = createMockedRunCommand({ + run: async () => { + throw new Error('kaboom') + }, + }) + cmdClass.enableJsonFlag = true + + await runWithCliExecutionContext({stderr: () => {}, stdout: (line) => out.push(line)}, () => + cmdClass.run(['--json']), + ) + + expect(out.join('\n')).toContain('kaboom') + expect(process.exitCode).toBe(previousExitCode) + }) + }) }) diff --git a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts index 90cd5b1d7b..82895be077 100644 --- a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts +++ b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts @@ -67,6 +67,18 @@ describe('invokeSanityCli', () => { expect(policyIds).toEqual(commandIds) }) + test('resolves its own oclif config by default, without a config override', async () => { + // Regression test: `loadCliCommandConfig` must resolve this package's own + // root (where package.json and the oclif manifest live), not some other + // ancestor directory, when a caller doesn't supply `config` (as every + // other test in this file does). + const result = await invokeSanityCli({args: '--help', source: 'mcp', token: 'user-token'}) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('USAGE') + expect(result.output).toContain('Manage CORS origins for your project') + }) + test('runs a command from string args, using the provided token', async () => { mockApi({apiVersion: CORS_API_VERSION, uri: `/projects/${projectId}/cors`}) .matchHeader('authorization', 'Bearer user-token') diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts index 5e7451afdf..f24e31ddec 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts @@ -20,8 +20,6 @@ * (or `help` / `-h`) renders root help listing the invokable topics, and a * subject (`cors --help`, `cors list --help`) renders topic or command help. */ -import {fileURLToPath} from 'node:url' - import {Config, Parser} from '@oclif/core' import {normalizeArgv} from '@oclif/core/help' import {CLI_TELEMETRY_SYMBOL, exitCodes, noopLogger, setCliTelemetry} from '@sanity/cli-core' @@ -38,8 +36,7 @@ import {isHelpRequest, renderInvokableHelp} from './help.js' * re-reading the command manifest per call. */ function loadCliCommandConfig(): Promise { - // Resolves to the package root from both src/exports (dev) and dist/exports (built) - return Config.load(fileURLToPath(new URL('../..', import.meta.url))) + return Config.load(import.meta.url) } function unknownCommandResult(argv: string[], policySet: CommandPolicySet): InvokeSanityCliResult { @@ -207,9 +204,6 @@ export async function invokeSanityCli({ const output: string[] = [] const sink = (line: string) => output.push(line) - // oclif's error handling sets `process.exitCode` as a side effect; restore - // it so a failed invocation can't change the host process's exit status. - const previousExitCode = process.exitCode try { await runWithCliExecutionContext({stderr: sink, stdout: sink, token}, () => CommandClass.run(commandArgv, resolvedConfig), @@ -229,7 +223,5 @@ export async function invokeSanityCli({ exitCode: typeof exit === 'number' ? exit : exitCodes.RUNTIME_ERROR, output: output.join('\n'), } - } finally { - process.exitCode = previousExitCode } } From da74758e953969253aa86a8bc1241839ee935587 Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Mon, 27 Jul 2026 15:48:34 -0400 Subject: [PATCH 11/14] feat: remove denied flags from help text --- .../src/exports/__tests__/commands.test.ts | 20 ++++++++-- .../commandPolicies/mcpPolicy.ts | 10 ++--- .../invokeSanityCli/commandPolicies/policy.ts | 20 ++++++++-- .../cli/src/exports/invokeSanityCli/help.ts | 40 ++++++++++++++++++- .../cli/src/exports/invokeSanityCli/index.ts | 9 ++++- 5 files changed, 85 insertions(+), 14 deletions(-) diff --git a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts index 82895be077..8235f49b5f 100644 --- a/packages/@sanity/cli/src/exports/__tests__/commands.test.ts +++ b/packages/@sanity/cli/src/exports/__tests__/commands.test.ts @@ -275,13 +275,14 @@ describe('invokeSanityCli', () => { }) test.each([ - 'docs read /docs/studio/installation --web', // --web opens a browser on the host - 'graphql undeploy --api ios --force', // --api loads local GraphQL definitions - ])('`%s` is refused by a conditional policy', async (args) => { + ['docs read /docs/studio/installation --web', '--web'], // --web opens a browser on the host + ['graphql undeploy --api ios --force', '--api'], // --api loads local GraphQL definitions + ])('`%s` is refused by a conditional policy naming the flag', async (args, deniedFlag) => { const result = await invokeSanityCli({args, config, source: 'mcp', token: 'user-token'}) expect(result.exitCode).toBe(2) expect(result.output).toContain('is not supported here') + expect(result.output).toContain(deniedFlag) }) test('conditional policies see parsed flags, not raw tokens', async () => { @@ -358,6 +359,19 @@ describe('invokeSanityCli', () => { }, ) + test.each([ + ['docs read --help', '--web'], + ['graphql undeploy --help', '--api'], + ])('`%s` omits the policy-denied %s flag', async (args, deniedFlag) => { + // Help must not advertise surface the policy refuses: the flag disappears + // from FLAGS/USAGE and examples demonstrating it are dropped. + const result = await invokeSanityCli({args, config, source: 'mcp', token: 'user-token'}) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('USAGE') + expect(result.output).not.toContain(deniedFlag) + }) + test('help output is stable across invocations sharing a config', async () => { // Regression: oclif's help formatters rewrite command ids in place // (`cors:list` → `cors list`); without defensive copies this corrupts the diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts index 2d79323b3c..ed08497cf1 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts @@ -1,4 +1,4 @@ -import {allow, type CommandPolicySet, conditional, deny} from './policy.js' +import {allow, type CommandPolicySet, deny, denyFlags} from './policy.js' /** * MCP programmatic mode disables local project/config discovery (see the CLI @@ -61,7 +61,7 @@ export const mcpPolicy: CommandPolicySet = { // Opens a browser on the machine running the MCP server. 'docs:browse': deny, // --web opens a browser on the machine running the MCP server. - 'docs:read': conditional(({flags}) => flags.web !== true), + 'docs:read': denyFlags('web'), 'docs:search': allow, // Reads and executes local project configuration for diagnostics. @@ -82,7 +82,7 @@ export const mcpPolicy: CommandPolicySet = { 'graphql:deploy': deny, 'graphql:list': allow, // --api loads GraphQL definitions from the local project; explicit project/dataset flags are safe. - 'graphql:undeploy': conditional(({flags}) => flags.api === undefined), + 'graphql:undeploy': denyFlags('api'), 'hooks:attempt': allow, 'hooks:create': allow, @@ -132,8 +132,8 @@ export const mcpPolicy: CommandPolicySet = { 'migrations:run': deny, // --web opens a browser on the machine running the MCP server. - 'openapi:get': conditional(({flags}) => flags.web !== true), - 'openapi:list': conditional(({flags}) => flags.web !== true), + 'openapi:get': denyFlags('web'), + 'openapi:list': denyFlags('web'), 'organizations:create': allow, 'organizations:delete': allow, diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts index 62869d2282..cdd387dc42 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts @@ -21,6 +21,12 @@ export type InvocationPolicy = (invocation: Invocation) => boolean export interface CommandPolicy { kind: 'allow' | 'conditional' | 'deny' validate: InvocationPolicy + + /** + * Flag names this policy refuses. Declarative so the help renderer can + * omit them (and examples using them) from the advertised command surface. + */ + deniedFlags?: readonly string[] } /** Every valid invocation of the command is safe. */ @@ -29,9 +35,17 @@ export const allow: CommandPolicy = {kind: 'allow', validate: () => true} /** No invocation of the command is safe. Behaves like an unknown command. */ export const deny: CommandPolicy = {kind: 'deny', validate: () => false} -/** Safety depends on the parsed arguments or flags. */ -export function conditional(validate: InvocationPolicy): CommandPolicy { - return {kind: 'conditional', validate} +/** + * Safe except when any of the named flags is used. The flags are also hidden + * from rendered help, so hosts are never told about surface they cannot use. + */ +export function denyFlags(...names: string[]): CommandPolicy { + return { + deniedFlags: names, + kind: 'conditional', + validate: ({flags}) => + names.every((name) => flags[name] === undefined || flags[name] === false), + } } /** A complete policy table, keyed by oclif command id. */ diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/help.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/help.ts index 9c47108799..6277195a54 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/help.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/help.ts @@ -1,6 +1,6 @@ import {type Command, type Config, Help, type Interfaces} from '@oclif/core' -import {type CommandPolicySet} from './commandPolicies/policy.js' +import {type CommandPolicy, type CommandPolicySet} from './commandPolicies/policy.js' /** Command ids a policy exposes: entries that are not denied. */ function visibleCommandIds(policySet: CommandPolicySet): Set { @@ -26,6 +26,40 @@ function visibleTopicNames(commandIds: Set): Set { /** Thrown when help is requested for a subject outside the policy surface. */ class NotInvokableError extends Error {} +/** + * A copy of `command` without the policy's denied flags, and without examples + * that use them (under any spelling: `--web`, `--w`, `-w`), so rendered help + * only advertises invocations the policy accepts. + */ +function withoutDeniedFlags(command: Command.Loadable, policy?: CommandPolicy): Command.Loadable { + const denied = policy?.deniedFlags + if (!denied?.length) return {...command} + + const flags = {...command.flags} + const spellings: string[] = [] + for (const name of denied) { + const definition = flags[name] + if (!definition) continue + spellings.push(`--${name}`) + if (definition.char) spellings.push(`-${definition.char}`) + for (const alias of definition.aliases ?? []) spellings.push(`--${alias}`, `-${alias}`) + delete flags[name] + } + + const usesDeniedFlag = (example: Command.Example) => { + const text = typeof example === 'string' ? example : example.command + return spellings.some((spelling) => + new RegExp(String.raw`(^|\s)${spelling}(=|\s|$)`).test(text ?? ''), + ) + } + + return { + ...command, + examples: command.examples?.filter((example) => !usesDeniedFlag(example)), + flags, + } +} + /** * oclif help renderer scoped to a policy's command surface. Subject * resolution (root vs topic vs command help) is oclif's own; the policy is @@ -40,10 +74,12 @@ class InvokableHelp extends Help { public readonly lines: string[] = [] private readonly commandIds: Set + private readonly policySet: CommandPolicySet private readonly topicNames: Set constructor(config: Config, policySet: CommandPolicySet) { super(config, {stripAnsi: true}) + this.policySet = policySet this.commandIds = visibleCommandIds(policySet) this.topicNames = visibleTopicNames(this.commandIds) } @@ -72,7 +108,7 @@ class InvokableHelp extends Help { public override async showCommandHelp(command: Command.Loadable): Promise { if (!this.commandIds.has(command.id)) throw new NotInvokableError(command.id) - return super.showCommandHelp({...command}) + return super.showCommandHelp(withoutDeniedFlags(command, this.policySet[command.id])) } protected override async showTopicHelp(topic: Interfaces.Topic): Promise { diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts index f24e31ddec..6709c84629 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/index.ts @@ -195,9 +195,16 @@ export async function invokeSanityCli({ } if (!policy.validate(invocation)) { + const displayId = commandId.replaceAll(':', ' ') + const usedDeniedFlags = (policy.deniedFlags ?? []).filter( + (name) => invocation.flags[name] !== undefined && invocation.flags[name] !== false, + ) return { exitCode: exitCodes.USAGE_ERROR, - output: `This invocation of \`${commandId.replaceAll(':', ' ')}\` is not supported here`, + output: + usedDeniedFlags.length > 0 + ? `The ${usedDeniedFlags.map((name) => `--${name}`).join(', ')} flag is not supported here for \`${displayId}\`` + : `This invocation of \`${displayId}\` is not supported here`, } } From 70213978e6744fb26c013f18b35cfbccc45ac749 Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Mon, 27 Jul 2026 16:08:52 -0400 Subject: [PATCH 12/14] fix: refactor conditional --- .../invokeSanityCli/commandPolicies/mcpPolicy.ts | 10 +++++----- .../invokeSanityCli/commandPolicies/policy.ts | 16 +++++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts index ed08497cf1..3c8b29ec67 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/mcpPolicy.ts @@ -1,4 +1,4 @@ -import {allow, type CommandPolicySet, deny, denyFlags} from './policy.js' +import {allow, type CommandPolicySet, conditionalDenyFlags, deny} from './policy.js' /** * MCP programmatic mode disables local project/config discovery (see the CLI @@ -61,7 +61,7 @@ export const mcpPolicy: CommandPolicySet = { // Opens a browser on the machine running the MCP server. 'docs:browse': deny, // --web opens a browser on the machine running the MCP server. - 'docs:read': denyFlags('web'), + 'docs:read': conditionalDenyFlags('web'), 'docs:search': allow, // Reads and executes local project configuration for diagnostics. @@ -82,7 +82,7 @@ export const mcpPolicy: CommandPolicySet = { 'graphql:deploy': deny, 'graphql:list': allow, // --api loads GraphQL definitions from the local project; explicit project/dataset flags are safe. - 'graphql:undeploy': denyFlags('api'), + 'graphql:undeploy': conditionalDenyFlags('api'), 'hooks:attempt': allow, 'hooks:create': allow, @@ -132,8 +132,8 @@ export const mcpPolicy: CommandPolicySet = { 'migrations:run': deny, // --web opens a browser on the machine running the MCP server. - 'openapi:get': denyFlags('web'), - 'openapi:list': denyFlags('web'), + 'openapi:get': conditionalDenyFlags('web'), + 'openapi:list': conditionalDenyFlags('web'), 'organizations:create': allow, 'organizations:delete': allow, diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts index cdd387dc42..8bd3c2399d 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts @@ -35,16 +35,22 @@ export const allow: CommandPolicy = {kind: 'allow', validate: () => true} /** No invocation of the command is safe. Behaves like an unknown command. */ export const deny: CommandPolicy = {kind: 'deny', validate: () => false} +/** A conditional policy that allows or denies based on the invocation. */ +export function conditional(validate: InvocationPolicy): CommandPolicy { + return {kind: 'conditional', validate} +} + /** - * Safe except when any of the named flags is used. The flags are also hidden + * Helper for conditional policies to deny flags. The flags are also hidden * from rendered help, so hosts are never told about surface they cannot use. */ -export function denyFlags(...names: string[]): CommandPolicy { +export function conditionalDenyFlags(...names: string[]): CommandPolicy { + const condition = conditional(({flags}) => + names.every((name) => flags[name] === undefined || flags[name] === false), + ) return { + ...condition, deniedFlags: names, - kind: 'conditional', - validate: ({flags}) => - names.every((name) => flags[name] === undefined || flags[name] === false), } } From fed65d6a4312b97309cc2928a556ef43e7fb60b9 Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Mon, 27 Jul 2026 16:13:46 -0400 Subject: [PATCH 13/14] fix: unsued exports --- .../invokeSanityCli/commandPolicies/policy.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts index 8bd3c2399d..e785b7c476 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts @@ -35,22 +35,16 @@ export const allow: CommandPolicy = {kind: 'allow', validate: () => true} /** No invocation of the command is safe. Behaves like an unknown command. */ export const deny: CommandPolicy = {kind: 'deny', validate: () => false} -/** A conditional policy that allows or denies based on the invocation. */ -export function conditional(validate: InvocationPolicy): CommandPolicy { - return {kind: 'conditional', validate} -} - /** - * Helper for conditional policies to deny flags. The flags are also hidden + * Conditional policies to deny flags. The flags are also hidden * from rendered help, so hosts are never told about surface they cannot use. */ export function conditionalDenyFlags(...names: string[]): CommandPolicy { - const condition = conditional(({flags}) => - names.every((name) => flags[name] === undefined || flags[name] === false), - ) return { - ...condition, deniedFlags: names, + kind: 'conditional', + validate: ({flags}) => + names.every((name) => flags[name] === undefined || flags[name] === false), } } From ed0e4453399a262089c6c2eed45f2ee94a327e34 Mon Sep 17 00:00:00 2001 From: Matthew Ritter Date: Mon, 27 Jul 2026 16:14:41 -0400 Subject: [PATCH 14/14] fix: unused export --- .../cli/src/exports/invokeSanityCli/commandPolicies/policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts index e785b7c476..0f89278808 100644 --- a/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts +++ b/packages/@sanity/cli/src/exports/invokeSanityCli/commandPolicies/policy.ts @@ -16,7 +16,7 @@ interface Invocation { flags: Readonly> } -export type InvocationPolicy = (invocation: Invocation) => boolean +type InvocationPolicy = (invocation: Invocation) => boolean export interface CommandPolicy { kind: 'allow' | 'conditional' | 'deny'