Skip to content

Commit 15c6b7d

Browse files
authored
fix: getCliClient() token not passed through (#688)
1 parent aee2e6e commit 15c6b7d

File tree

7 files changed

+200
-22
lines changed

7 files changed

+200
-22
lines changed

packages/@sanity/cli/src/actions/exec/configClient.worker.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.

packages/@sanity/cli/src/actions/exec/execScript.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import {spawn} from 'node:child_process'
22
import path from 'node:path'
33

4+
import {CLIError} from '@oclif/core/errors'
5+
import {getCliToken} from '@sanity/cli-core'
6+
47
interface ExecScriptOptions {
58
extraArguments: string[]
69
flags: {
@@ -19,7 +22,6 @@ export async function execScript(options: ExecScriptOptions): Promise<void> {
1922
const resolvedScriptPath = path.resolve(scriptPath)
2023

2124
const browserEnvPath = new URL('registerBrowserEnv.worker.js', import.meta.url).href
22-
const configClientPath = new URL('configClient.worker.js', import.meta.url).href
2325

2426
// Use tsx loader for TypeScript support in the spawned child process
2527
// We need to resolve the tsx loader path from the CLI's node_modules since the child
@@ -30,17 +32,33 @@ export async function execScript(options: ExecScriptOptions): Promise<void> {
3032
throw new Error('@sanity/cli not able to resolve tsx loader')
3133
}
3234

35+
// When --with-user-token is specified, resolve the token in the parent process
36+
// and pass it via environment variable. This avoids a module-instance mismatch where
37+
// the worker's `getCliClient` import resolves to a different module than the script's
38+
// `import {getCliClient} from 'sanity/cli'`, causing the __internal__getToken mutation
39+
// to not propagate.
40+
let tokenEnv: Record<string, string> = {}
41+
if (withUserToken) {
42+
const token = await getCliToken()
43+
if (!token) {
44+
throw new CLIError(
45+
'--with-user-token specified, but no auth token could be found. Run `sanity login`',
46+
)
47+
}
48+
tokenEnv = {SANITY_AUTH_TOKEN: token}
49+
}
50+
3351
const baseArgs = mockBrowserEnv
3452
? ['--import', tsxLoaderPath, '--import', browserEnvPath]
3553
: ['--import', tsxLoaderPath]
36-
const tokenArgs = withUserToken ? ['--import', configClientPath] : []
3754

38-
const nodeArgs = [...baseArgs, ...tokenArgs, resolvedScriptPath, ...extraArguments]
55+
const nodeArgs = [...baseArgs, resolvedScriptPath, ...extraArguments]
3956

4057
const proc = spawn(process.argv[0], nodeArgs, {
4158
env: {
4259
...process.env,
4360
SANITY_BASE_PATH: workDir,
61+
...tokenEnv,
4462
},
4563
stdio: 'inherit',
4664
})
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Separate file from exec.test.ts because getCliToken() in @sanity/cli-core caches the resolved
2+
// token at module level. A separate file gives us a fresh module scope, so the "no token" error
3+
// test isn't order-dependent on tests that successfully resolve a token.
4+
import {type SpawnOptions} from 'node:child_process'
5+
import {copyFile, mkdir, rm} from 'node:fs/promises'
6+
import {tmpdir} from 'node:os'
7+
import {join, resolve} from 'node:path'
8+
9+
import {CLIError} from '@oclif/core/errors'
10+
import {setCliUserConfig} from '@sanity/cli-core'
11+
import {testCommand, testFixture} from '@sanity/cli-test'
12+
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
13+
14+
import {ExecCommand} from '../exec.js'
15+
16+
const TEST_CONFIG_DIR = join(tmpdir(), 'sanity-cli-test-exec-token')
17+
const TEST_CONFIG_PATH = join(TEST_CONFIG_DIR, 'config.json')
18+
19+
const fixtureDir = resolve(import.meta.dirname, '../../../test/__fixtures__')
20+
21+
// Mock spawn to capture output instead of inheriting
22+
vi.mock('node:child_process', async (importOriginal) => {
23+
const actual = await importOriginal<typeof import('node:child_process')>()
24+
return {
25+
...actual,
26+
spawn: (command: string, args: string[], options: SpawnOptions) => {
27+
const proc = actual.spawn(command, args, {
28+
...options,
29+
stdio: ['inherit', 'pipe', 'pipe'],
30+
})
31+
32+
proc.stdout?.pipe(process.stdout)
33+
proc.stderr?.pipe(process.stderr)
34+
35+
return proc
36+
},
37+
}
38+
})
39+
40+
async function setupTestAuth(token: string): Promise<{cleanup: () => Promise<void>}> {
41+
await mkdir(TEST_CONFIG_DIR, {recursive: true})
42+
vi.stubEnv('SANITY_CLI_CONFIG_PATH', TEST_CONFIG_PATH)
43+
// Clear SANITY_AUTH_TOKEN so getCliToken() reads from the config file
44+
// instead of picking up an env var (e.g. set in CI)
45+
vi.stubEnv('SANITY_AUTH_TOKEN', '')
46+
await setCliUserConfig('authToken', token)
47+
48+
return {cleanup: () => rm(TEST_CONFIG_DIR, {force: true, recursive: true})}
49+
}
50+
51+
describe('exec --with-user-token', {timeout: 15 * 1000}, () => {
52+
let exampleDir: string
53+
let scriptPath: string
54+
55+
beforeEach(async () => {
56+
exampleDir = await testFixture('basic-studio')
57+
process.chdir(exampleDir)
58+
59+
scriptPath = join(exampleDir, 'test-script.ts')
60+
await copyFile(join(fixtureDir, 'exec-script.ts'), scriptPath)
61+
})
62+
63+
afterEach(() => {
64+
vi.unstubAllEnvs()
65+
})
66+
67+
test('errors when no auth token is found', async () => {
68+
// Ensure no token is available from any source
69+
vi.stubEnv('SANITY_AUTH_TOKEN', '')
70+
vi.stubEnv('SANITY_CLI_CONFIG_PATH', join(tmpdir(), 'sanity-cli-nonexistent', 'config.json'))
71+
72+
const {error} = await testCommand(ExecCommand, [scriptPath, '--with-user-token'])
73+
74+
expect(error).toBeInstanceOf(CLIError)
75+
expect(error?.message).toContain('--with-user-token specified')
76+
expect(error?.message).toContain('sanity login')
77+
})
78+
79+
test('passes token to getCliClient()', async () => {
80+
const tokenScriptPath = join(exampleDir, 'test-token-script.ts')
81+
await copyFile(join(fixtureDir, 'exec-get-user-token.ts'), tokenScriptPath)
82+
83+
const {cleanup} = await setupTestAuth('test-fake-token-abc123')
84+
85+
try {
86+
const {error, stdout} = await testCommand(ExecCommand, [tokenScriptPath, '--with-user-token'])
87+
88+
if (error) throw error
89+
90+
const data = JSON.parse(stdout.trim())
91+
expect(data.success).toBe(true)
92+
expect(data.token).toBe('test-fake-token-abc123')
93+
} finally {
94+
await cleanup()
95+
}
96+
})
97+
})

packages/@sanity/cli/src/commands/__tests__/exec.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,6 @@ describe('#exec', {timeout: 15 * 1000}, () => {
101101
const data = JSON.parse(stdout.trim())
102102
expect(data.success).toBe(true)
103103
expect(data.env.SANITY_BASE_PATH).toBe(exampleDir)
104-
// Without token, API returns empty object rather than throwing error
105-
expect(data.user).toEqual({})
106104
})
107105

108106
test.skipIf(!TEST_TOKEN)('executes script with --with-user-token flag', async () => {

packages/@sanity/cli/src/util/__tests__/cliClient.test.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,23 @@ vi.mock('@sanity/client')
99

1010
describe('getCliClient', () => {
1111
const mockClient = {withConfig: vi.fn()} as unknown as SanityClient
12-
let originalProcessEnv: NodeJS.ProcessEnv
12+
const originalAuthToken = process.env.SANITY_AUTH_TOKEN
1313

1414
beforeEach(() => {
1515
vi.clearAllMocks()
1616
vi.mocked(createClient).mockReturnValue(mockClient)
17-
originalProcessEnv = process.env
17+
// Ensure SANITY_AUTH_TOKEN doesn't leak into tests that expect no token
18+
delete process.env.SANITY_AUTH_TOKEN
1819
})
1920

2021
afterEach(() => {
21-
process.env = originalProcessEnv
22+
// Restore original SANITY_AUTH_TOKEN value (may have been set in CI)
23+
if (originalAuthToken === undefined) {
24+
delete process.env.SANITY_AUTH_TOKEN
25+
} else {
26+
process.env.SANITY_AUTH_TOKEN = originalAuthToken
27+
}
28+
vi.unstubAllEnvs()
2229
})
2330

2431
test('should throw error if not called from node.js', () => {
@@ -85,7 +92,7 @@ describe('getCliClient', () => {
8592
test('should use SANITY_BASE_PATH env var as cwd if set', async () => {
8693
const {findProjectRootSync, getCliConfigSync} = await import('@sanity/cli-core')
8794

88-
process.env.SANITY_BASE_PATH = '/custom/path'
95+
vi.stubEnv('SANITY_BASE_PATH', '/custom/path')
8996

9097
const mockProjectRoot = {
9198
directory: '/custom/path',
@@ -238,6 +245,48 @@ describe('getCliClient', () => {
238245
getCliClient.__internal__getToken = () => undefined
239246
})
240247

248+
test('should use SANITY_AUTH_TOKEN env var as fallback', () => {
249+
vi.stubEnv('SANITY_AUTH_TOKEN', 'env-token')
250+
251+
const options = {
252+
dataset: 'test-dataset',
253+
projectId: 'test-project',
254+
}
255+
256+
getCliClient(options)
257+
258+
expect(createClient).toHaveBeenCalledWith({
259+
apiVersion: '2022-06-06',
260+
dataset: 'test-dataset',
261+
projectId: 'test-project',
262+
token: 'env-token',
263+
useCdn: false,
264+
})
265+
})
266+
267+
test('should prioritize __internal__getToken over SANITY_AUTH_TOKEN env var', () => {
268+
vi.stubEnv('SANITY_AUTH_TOKEN', 'env-token')
269+
getCliClient.__internal__getToken = () => 'internal-token'
270+
271+
const options = {
272+
dataset: 'test-dataset',
273+
projectId: 'test-project',
274+
}
275+
276+
getCliClient(options)
277+
278+
expect(createClient).toHaveBeenCalledWith({
279+
apiVersion: '2022-06-06',
280+
dataset: 'test-dataset',
281+
projectId: 'test-project',
282+
token: 'internal-token',
283+
useCdn: false,
284+
})
285+
286+
// Reset
287+
getCliClient.__internal__getToken = () => undefined
288+
})
289+
241290
test('should pass through additional ClientConfig options', () => {
242291
const options = {
243292
dataset: 'test-dataset',

packages/@sanity/cli/src/util/cliClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const getCliClient: CliClientGetter = (options: CliClientOptions = {}): S
2929
cwd = process.env.SANITY_BASE_PATH || process.cwd(),
3030
dataset,
3131
projectId,
32-
token = getCliClient.__internal__getToken(),
32+
token = getCliClient.__internal__getToken() ?? (process.env.SANITY_AUTH_TOKEN || undefined),
3333
useCdn = false,
3434
...restOfOptions
3535
} = options
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Test script for `sanity exec --with-user-token`
2+
// Verifies that the token is available in the client config
3+
4+
import {getCliClient} from 'sanity/cli'
5+
6+
try {
7+
const client = getCliClient()
8+
const config = client.config()
9+
10+
// Output whether the token was received
11+
// eslint-disable-next-line no-console
12+
console.log(
13+
JSON.stringify({
14+
hasToken: typeof config.token === 'string' && config.token.length > 0,
15+
token: config.token,
16+
success: true,
17+
}),
18+
)
19+
} catch (error) {
20+
// eslint-disable-next-line no-console
21+
console.error(
22+
JSON.stringify({
23+
error: error instanceof Error ? error.message : String(error),
24+
success: false,
25+
}),
26+
)
27+
process.exit(1)
28+
}

0 commit comments

Comments
 (0)