Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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,11 +1,12 @@
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: {
cliConfig?: Mock
run: (this: (typeof SanityCommand<T>)['prototype'], ...args: any[]) => Promise<void>

Check warning on line 9 in packages/@sanity/cli-core/src/__tests__/SanityCommand.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
}) {
return class TestCommand extends SanityCommand<typeof TestCommand> {
static override flags = {
Expand All @@ -29,7 +30,7 @@
public async getCliConfig() {
return (mocks.cliConfig || vi.fn(() => ({api: {}})))()
}
public async run(...args: any[]) {

Check warning on line 33 in packages/@sanity/cli-core/src/__tests__/SanityCommand.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
return mocks.run.call(this, ...args)
}
}
Expand Down Expand Up @@ -171,4 +172,70 @@
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