Skip to content

Commit b3281c0

Browse files
authored
fix: allow commands to run outside project context with --project-id/--dataset flags (#558)
1 parent 85d3642 commit b3281c0

File tree

23 files changed

+512
-41
lines changed

23 files changed

+512
-41
lines changed

Claude.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ test('with mockApi', async () => {
195195

196196
const {error, stdout} = await testCommand(MyCommand, [])
197197

198-
expect(error).toBeUndefined()
198+
if (error) throw error
199199
expect(stdout).toContain('project-123')
200200
})
201201
```
@@ -244,6 +244,8 @@ When writing tests for CLI commands, follow these rules strictly:
244244
3. **Use hoisted mocks** - Use `vi.hoisted(() => vi.fn())` for client method mocks
245245
4. **Clear mocks in afterEach** - Always include `vi.clearAllMocks()` in `afterEach()`
246246
5. **Test error cases** - Include both success and error scenarios
247+
6. **Use `if (error) throw error`** in success tests - NOT `expect(error).toBeUndefined()`. This gives better stack traces on failure.
248+
7. **Assert `expect(error).toBeInstanceOf(Error)`** in error tests - along with exit code and message assertions
247249

248250
#### NEVER:
249251

packages/@sanity/cli-core/src/SanityCommand.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,21 @@ export abstract class SanityCommand<T extends typeof Command> extends Command {
212212
protected resolveIsInteractive(): boolean {
213213
return isInteractive()
214214
}
215+
216+
/**
217+
* Get the CLI config, returning an empty config if no project root is found.
218+
*
219+
* Use this instead of `getCliConfig()` in commands that can operate without a
220+
* project directory (e.g. when `--project-id` and `--dataset` flags are provided).
221+
*
222+
* @returns The CLI config, or an empty config object if no project root is found.
223+
*/
224+
protected async tryGetCliConfig(): Promise<CliConfig> {
225+
try {
226+
return await this.getCliConfig()
227+
} catch (err) {
228+
if (!(err instanceof ProjectRootNotFoundError)) throw err
229+
return {}
230+
}
231+
}
215232
}

packages/@sanity/cli-test/src/test/mockSanityCommand.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ export function mockSanityCommand<T extends typeof SanityCommand<typeof Command>
9191
}
9292

9393
protected getProjectRoot(): Promise<ProjectRootResult> {
94+
if (
95+
options.cliConfigError &&
96+
'name' in options.cliConfigError &&
97+
options.cliConfigError.name === 'ProjectRootNotFoundError'
98+
) {
99+
return Promise.reject(options.cliConfigError)
100+
}
94101
if (options.projectRoot) {
95102
return Promise.resolve(options.projectRoot)
96103
}

packages/@sanity/cli/src/commands/dataset/__tests__/export.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,50 @@ describe('#dataset:export', () => {
293293
expect(mockExportDataset).toHaveBeenCalledWith(expect.objectContaining({dataset: 'staging'}))
294294
})
295295

296+
test('exports with --project-id flag and dataset arg when outside project directory', async () => {
297+
mockGetProjectCliClient.mockResolvedValue({
298+
datasets: {
299+
list: vi.fn().mockResolvedValue([{name: 'production'}]),
300+
},
301+
} as never)
302+
mockFs.stat.mockRejectedValue(new Error('File not found'))
303+
304+
const {error, stdout} = await testCommand(
305+
DatasetExportCommand,
306+
['production', 'output.tar.gz', '--project-id', 'flag-project'],
307+
{
308+
mocks: {
309+
cliConfigError: new ProjectRootNotFoundError('No project root found'),
310+
token: defaultMocks.token,
311+
},
312+
},
313+
)
314+
315+
if (error) throw error
316+
expect(stdout).toContain('projectId: flag-project')
317+
expect(stdout).toContain('dataset: production')
318+
expect(mockExportDataset).toHaveBeenCalledWith(
319+
expect.objectContaining({dataset: 'production'}),
320+
)
321+
})
322+
323+
test('errors when outside project directory and no --project-id', async () => {
324+
const {error} = await testCommand(
325+
DatasetExportCommand,
326+
['production', 'output.tar.gz'],
327+
{
328+
mocks: {
329+
cliConfigError: new ProjectRootNotFoundError('No project root found'),
330+
token: defaultMocks.token,
331+
},
332+
},
333+
)
334+
335+
expect(error).toBeInstanceOf(Error)
336+
expect(error?.message).toContain('Unable to determine project ID')
337+
expect(error?.oclif?.exit).toBe(1)
338+
})
339+
296340
test('validates dataset exists in project', async () => {
297341
const {mocks} = createTestContext({datasets: [{name: 'production'}]})
298342

packages/@sanity/cli/src/commands/dataset/export.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,7 @@ import path from 'node:path'
33
import {type Writable} from 'node:stream'
44

55
import {Args, Flags} from '@oclif/core'
6-
import {
7-
getProjectCliClient,
8-
ProjectRootNotFoundError,
9-
SanityCommand,
10-
subdebug,
11-
} from '@sanity/cli-core'
6+
import {getProjectCliClient, SanityCommand, subdebug} from '@sanity/cli-core'
127
import {boxen, input, spinner} from '@sanity/cli-core/ux'
138
import {type DatasetsResponse} from '@sanity/client'
149
import {exportDataset, type ExportOptions, type ExportProgress} from '@sanity/export'
@@ -131,14 +126,8 @@ export class DatasetExportCommand extends SanityCommand<typeof DatasetExportComm
131126
if (!dataset) {
132127
try {
133128
// Get default dataset from config (only available when running from a project directory)
134-
let defaultDataset: string | undefined
135-
try {
136-
const cliConfig = await this.getCliConfig()
137-
defaultDataset = cliConfig.api?.dataset
138-
} catch (err) {
139-
if (!(err instanceof ProjectRootNotFoundError)) throw err
140-
// Not inside a project directory — no default dataset available
141-
}
129+
const cliConfig = await this.tryGetCliConfig()
130+
const defaultDataset = cliConfig.api?.dataset
142131

143132
if (defaultDataset) {
144133
dataset = defaultDataset

packages/@sanity/cli/src/commands/documents/__tests__/create.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises'
33
import os from 'node:os'
44
import path from 'node:path'
55

6-
import {getProjectCliClient} from '@sanity/cli-core'
6+
import {getProjectCliClient, ProjectRootNotFoundError} from '@sanity/cli-core'
77
import {testCommand} from '@sanity/cli-test'
88
import {watch as chokidarWatch} from 'chokidar'
99
import {execa, execaSync} from 'execa'
@@ -921,4 +921,59 @@ describe('#documents:create', () => {
921921
}),
922922
)
923923
})
924+
925+
describe('outside project context', () => {
926+
const noProjectRootMocks = {
927+
cliConfigError: new ProjectRootNotFoundError('No project root found'),
928+
token: 'test-token',
929+
}
930+
931+
test('works with --project-id and --dataset flags when no project root', async () => {
932+
const mockDoc = {_id: 'test-doc', _type: 'post', title: 'Test Post'}
933+
const mockTransaction = vi.fn().mockReturnValue({
934+
commit: vi.fn().mockResolvedValue({results: [{id: 'test-doc', operation: 'create'}]}),
935+
})
936+
mockGetProjectCliClient.mockResolvedValue({transaction: mockTransaction} as never)
937+
mockFs.readFile.mockResolvedValue(JSON.stringify(mockDoc))
938+
mockJson5.parse.mockReturnValue(mockDoc)
939+
940+
const {error, stdout} = await testCommand(
941+
CreateDocumentCommand,
942+
['test-doc.json', '--project-id', 'my-project', '--dataset', 'production'],
943+
{mocks: noProjectRootMocks},
944+
)
945+
946+
if (error) throw error
947+
expect(stdout).toContain('Created:')
948+
expect(stdout).toContain('test-doc')
949+
expect(mockGetProjectCliClient).toHaveBeenCalledWith(
950+
expect.objectContaining({
951+
dataset: 'production',
952+
projectId: 'my-project',
953+
}),
954+
)
955+
})
956+
957+
test('errors when no project root and no --project-id', async () => {
958+
const {error} = await testCommand(CreateDocumentCommand, ['test-doc.json'], {
959+
mocks: noProjectRootMocks,
960+
})
961+
962+
expect(error).toBeInstanceOf(Error)
963+
expect(error?.message).toContain('Unable to determine project ID')
964+
expect(error?.oclif?.exit).toBe(1)
965+
})
966+
967+
test('errors when no project root with --project-id but no --dataset', async () => {
968+
const {error} = await testCommand(
969+
CreateDocumentCommand,
970+
['test-doc.json', '--project-id', 'my-project'],
971+
{mocks: noProjectRootMocks},
972+
)
973+
974+
expect(error).toBeInstanceOf(Error)
975+
expect(error?.message).toContain('No dataset specified')
976+
expect(error?.oclif?.exit).toBe(1)
977+
})
978+
})
924979
})

packages/@sanity/cli/src/commands/documents/__tests__/delete.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {ProjectRootNotFoundError} from '@sanity/cli-core'
12
import {testCommand} from '@sanity/cli-test'
23
import {afterEach, describe, expect, test, vi} from 'vitest'
34

@@ -215,4 +216,63 @@ describe('#documents:delete', () => {
215216

216217
expect(stdout).toContain('Deleted 2 documents')
217218
})
219+
220+
describe('outside project context', () => {
221+
const noProjectRootMocks = {
222+
cliConfigError: new ProjectRootNotFoundError('No project root found'),
223+
token: 'test-token',
224+
}
225+
226+
test('works with --project-id and --dataset flags when no project root', async () => {
227+
const mockDelete = vi.fn()
228+
const mockCommit = vi.fn().mockResolvedValue({
229+
results: [{id: 'test-doc', operation: 'delete'}],
230+
})
231+
mockTransaction.mockReturnValue({
232+
commit: mockCommit,
233+
delete: mockDelete,
234+
})
235+
236+
const {error, stdout} = await testCommand(
237+
DeleteDocumentCommand,
238+
['test-doc', '--project-id', 'ext-project', '--dataset', 'ext-dataset'],
239+
{mocks: noProjectRootMocks},
240+
)
241+
242+
if (error) throw error
243+
expect(stdout).toContain('Deleted 1 document')
244+
expect(mockDelete).toHaveBeenCalledWith('test-doc')
245+
expect(mockCommit).toHaveBeenCalled()
246+
expect(mockGetProjectCliClient).toHaveBeenCalledWith(
247+
expect.objectContaining({
248+
dataset: 'ext-dataset',
249+
projectId: 'ext-project',
250+
}),
251+
)
252+
})
253+
254+
test('errors when no project root and no --project-id', async () => {
255+
const {error} = await testCommand(
256+
DeleteDocumentCommand,
257+
['test-doc', '--dataset', 'ext-dataset'],
258+
{mocks: noProjectRootMocks},
259+
)
260+
261+
expect(error).toBeInstanceOf(Error)
262+
expect(error?.message).toContain('Unable to determine project ID')
263+
expect(error?.oclif?.exit).toBe(1)
264+
})
265+
266+
test('errors when no project root with --project-id but no --dataset', async () => {
267+
const {error} = await testCommand(
268+
DeleteDocumentCommand,
269+
['test-doc', '--project-id', 'ext-project'],
270+
{mocks: noProjectRootMocks},
271+
)
272+
273+
expect(error).toBeInstanceOf(Error)
274+
expect(error?.message).toContain('No dataset specified')
275+
expect(error?.oclif?.exit).toBe(1)
276+
})
277+
})
218278
})

packages/@sanity/cli/src/commands/documents/__tests__/get.test.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {ProjectRootNotFoundError} from '@sanity/cli-core'
12
import {testCommand} from '@sanity/cli-test'
23
import {afterEach, describe, expect, test, vi} from 'vitest'
34

@@ -17,14 +18,17 @@ const defaultMocks = {
1718
}
1819

1920
const mockGetDocument = vi.hoisted(() => vi.fn())
21+
const mockGetProjectCliClient = vi.hoisted(() =>
22+
vi.fn().mockResolvedValue({
23+
getDocument: mockGetDocument,
24+
}),
25+
)
2026

2127
vi.mock('@sanity/cli-core', async () => {
2228
const actual = await vi.importActual('@sanity/cli-core')
2329
return {
2430
...actual,
25-
getProjectCliClient: vi.fn().mockResolvedValue({
26-
getDocument: mockGetDocument,
27-
}),
31+
getProjectCliClient: mockGetProjectCliClient,
2832
}
2933
})
3034

@@ -163,4 +167,76 @@ describe('#documents:get', () => {
163167
expect(error?.message).toContain('Missing 1 required arg')
164168
expect(error?.oclif?.exit).toBe(2)
165169
})
170+
171+
describe('outside project context', () => {
172+
const noProjectRootMocks = {
173+
cliConfigError: new ProjectRootNotFoundError('No project root found'),
174+
token: 'test-token',
175+
}
176+
177+
test('works with --project-id and --dataset flags when there is no project root', async () => {
178+
const mockDoc = {
179+
_id: 'test-doc',
180+
_type: 'post',
181+
title: 'Test Post',
182+
}
183+
184+
mockGetDocument.mockResolvedValue(mockDoc)
185+
186+
const {error, stdout} = await testCommand(
187+
GetDocumentCommand,
188+
['test-doc', '--project-id', 'flag-project', '--dataset', 'flag-dataset'],
189+
{mocks: noProjectRootMocks},
190+
)
191+
192+
if (error) throw error
193+
expect(stdout).toContain('"_id": "test-doc"')
194+
expect(stdout).toContain('"title": "Test Post"')
195+
expect(mockGetDocument).toHaveBeenCalledWith('test-doc')
196+
})
197+
198+
test('errors when outside project context and no --project-id provided', async () => {
199+
const {error} = await testCommand(GetDocumentCommand, ['test-doc'], {
200+
mocks: noProjectRootMocks,
201+
})
202+
203+
expect(error).toBeInstanceOf(Error)
204+
expect(error?.message).toContain('Unable to determine project ID')
205+
expect(error?.oclif?.exit).toBe(1)
206+
})
207+
208+
test('errors when outside project context with --project-id but no --dataset', async () => {
209+
const {error} = await testCommand(
210+
GetDocumentCommand,
211+
['test-doc', '--project-id', 'flag-project'],
212+
{mocks: noProjectRootMocks},
213+
)
214+
215+
expect(error).toBeInstanceOf(Error)
216+
expect(error?.message).toContain('No dataset specified')
217+
expect(error?.oclif?.exit).toBe(1)
218+
})
219+
220+
test('--project-id flag overrides CLI config projectId', async () => {
221+
const mockDoc = {
222+
_id: 'test-doc',
223+
_type: 'post',
224+
title: 'Test Post',
225+
}
226+
227+
mockGetDocument.mockResolvedValue(mockDoc)
228+
229+
const {error, stdout} = await testCommand(
230+
GetDocumentCommand,
231+
['test-doc', '--project-id', 'override-project'],
232+
{mocks: defaultMocks},
233+
)
234+
235+
if (error) throw error
236+
expect(stdout).toContain('"_id": "test-doc"')
237+
expect(mockGetProjectCliClient).toHaveBeenCalledWith(
238+
expect.objectContaining({projectId: 'override-project'}),
239+
)
240+
})
241+
})
166242
})

0 commit comments

Comments
 (0)