Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


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

feat: AIGRO-4919 - Invoking allowed commands from outside the cli
4 changes: 4 additions & 0 deletions packages/@sanity/cli-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
57 changes: 56 additions & 1 deletion packages/@sanity/cli-core/src/SanityCommand.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -247,6 +248,46 @@ export abstract class SanityCommand<T extends typeof Command>
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.
*
Expand All @@ -272,4 +313,18 @@ export abstract class SanityCommand<T extends typeof Command>
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
}
}
67 changes: 67 additions & 0 deletions packages/@sanity/cli-core/src/__tests__/SanityCommand.test.ts
Original file line number Diff line number Diff line change
@@ -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<T extends typeof Command>(mocks: {
Expand Down Expand Up @@ -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()
}
})
})
})
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {}
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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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')

Expand Down
10 changes: 10 additions & 0 deletions packages/@sanity/cli-core/src/config/cli/cliUserConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,6 +26,15 @@ export const _internals = {
* @internal
*/
export async function getCliToken(): Promise<string | undefined> {
// 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
Expand Down
12 changes: 12 additions & 0 deletions packages/@sanity/cli-core/src/config/findProjectRoot.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -19,6 +20,17 @@ import {
* @internal
*/
export async function findProjectRoot(cwd: string): Promise<ProjectRootResult> {
// 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)
Expand Down
Loading
Loading