Skip to content

Commit e0b3427

Browse files
authored
improvement(cli): files batch-delete takes file ids as arguments (#9518)
* improvement(cli): files batch-delete takes file ids as arguments * chore(docs): record the batch-delete usage line in the compatibility baseline
1 parent e985d51 commit e0b3427

10 files changed

Lines changed: 125 additions & 15 deletions

File tree

‎apps/docs/content/docs/cli/files.mdx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -634,16 +634,26 @@ API request and response schemas: [deleteFile](/api-reference/files/deleteFile).
634634
This archives the listed files; restore them with `files restore`.
635635

636636
```bash
637-
sim files batch-delete [options]
637+
sim files batch-delete [file-ids...] [options]
638638
```
639639

640+
**Arguments**
641+
642+
<CommandTable>
643+
644+
| Argument | Required | Description |
645+
| --- | --- | --- |
646+
| `file-ids...` | No | File identifiers to delete. Same as --file-ids. |
647+
648+
</CommandTable>
649+
640650
**Options**
641651

642652
<CommandTable>
643653

644654
| Option | Required | Description |
645655
| --- | --- | --- |
646-
| `--file-ids <value...>` | Yes | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
656+
| `--file-ids <value...>` | No | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @) (required, or pass the values as arguments). |
647657
| `-y, --yes` | Yes | Confirm this operation. |
648658

649659
</CommandTable>

‎apps/docs/content/docs/cli/reference.mdx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -972,16 +972,26 @@ Delete several files at once
972972
This archives the listed files; restore them with `files restore`.
973973

974974
```bash
975-
sim files batch-delete [options]
975+
sim files batch-delete [file-ids...] [options]
976976
```
977977

978+
**Arguments**
979+
980+
<CommandTable>
981+
982+
| Argument | Required | Description |
983+
| --- | --- | --- |
984+
| `file-ids...` | No | File identifiers to delete. Same as --file-ids. |
985+
986+
</CommandTable>
987+
978988
**Options**
979989

980990
<CommandTable>
981991

982992
| Option | Required | Description |
983993
| --- | --- | --- |
984-
| `--file-ids <value...>` | Yes | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
994+
| `--file-ids <value...>` | No | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @) (required, or pass the values as arguments). |
985995
| `-y, --yes` | Yes | Confirm this operation. |
986996

987997
</CommandTable>

‎apps/docs/scripts/fixtures/content-baseline.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4422,8 +4422,8 @@
44224422
{
44234423
"lang": "bash",
44244424
"meta": null,
4425-
"sha256": "41e9881b8582523af56643e45a5a1ba351eee353683c169a0dc8fc56b4d3ad66",
4426-
"bytes": 32,
4425+
"sha256": "c569c5e38502a1c1e8f6078954bc90b559dd80adb577bb4cf7b63ec7bbdb3aa7",
4426+
"bytes": 46,
44274427
"line": 632
44284428
},
44294429
{
@@ -11786,8 +11786,8 @@
1178611786
{
1178711787
"lang": "bash",
1178811788
"meta": null,
11789-
"sha256": "41e9881b8582523af56643e45a5a1ba351eee353683c169a0dc8fc56b4d3ad66",
11790-
"bytes": 32,
11789+
"sha256": "c569c5e38502a1c1e8f6078954bc90b559dd80adb577bb4cf7b63ec7bbdb3aa7",
11790+
"bytes": 46,
1179111791
"line": 970
1179211792
},
1179311793
{

‎packages/sim-cli/src/contract/commands.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1556,7 +1556,7 @@ export const CLI_CONTRACT: CliContract = {
15561556
command: 'files batch-delete',
15571557
describe: 'Delete several files at once',
15581558
flags: {
1559-
fileIds: { list: true },
1559+
fileIds: { list: true, trailing: true },
15601560
},
15611561
confirm: 'This archives the listed files; restore them with `files restore`.',
15621562
},

‎packages/sim-cli/src/contract/types.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ export interface FlagSpec {
6969
* error, and a literal `#` value can still be passed.
7070
*/
7171
manifest?: true
72+
/**
73+
* The list's values may also follow the command as arguments, so `files batch-delete <id>...`
74+
* reads like `rm`. Passing both forms is refused. At most one field per command.
75+
*/
76+
trailing?: true
7277
/** Take a JSON string. Implied for object/array/unknown fields. */
7378
json?: boolean
7479
/** Read a scalar string verbatim from `@path` / `@-`; `@@` escapes a literal leading `@`. */

‎packages/sim-cli/src/runtime/build.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
isProfileWorkspacePath,
1515
PROFILE_INJECTED_FIELD,
1616
RESERVED_PROGRAM_FLAGS,
17+
trailingListField,
1718
} from './request'
1819
import type { OperationSpec } from './types'
1920

@@ -312,6 +313,22 @@ function configureOperation(
312313
)
313314
}
314315

316+
const trailing = trailingListField(spec)
317+
if (trailing) {
318+
const trailingFlags = Object.values(spec.flags ?? {}).filter((flag) => flag.trailing)
319+
const descriptor = operationSpec.query?.[trailing] ?? operationSpec.body?.[trailing]
320+
if (trailingFlags.length > 1)
321+
throw new Error(`${operation} declares more than one trailing list`)
322+
if (!descriptor || !flagSpecFor(operation, trailing).list) {
323+
throw new Error(`${operation}.${trailing} is trailing but not a list request field`)
324+
}
325+
const name = flagNameFor(operation, trailing)
326+
command.argument(
327+
`[${name}...]`,
328+
`${flagSpecFor(operation, trailing).describe ?? descriptor.describe} Same as --${name}.`
329+
)
330+
}
331+
315332
if (spec.requestFields) {
316333
for (const field of spec.requestFields) {
317334
if (

‎packages/sim-cli/src/runtime/execute.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
flagNameFor,
2323
isProfileWorkspacePath,
2424
PROFILE_INJECTED_FIELD,
25+
trailingListField,
2526
} from './request'
2627
import { foldPageEnvelope, renderPage, renderResult } from './result'
2728
import type { OperationSpec } from './types'
@@ -416,6 +417,17 @@ export async function executeOperation(
416417
for (const [index, field] of (commandSpec.positionals ?? []).entries()) {
417418
requestFlags[camel(flagNameFor(operation, field))] = invocation[pathPositionalCount + index]
418419
}
420+
const trailing = trailingListField(commandSpec)
421+
const trailingValues = trailing
422+
? invocation[pathPositionalCount + (commandSpec.positionals?.length ?? 0)]
423+
: undefined
424+
if (trailing && Array.isArray(trailingValues) && trailingValues.length > 0) {
425+
const name = flagNameFor(operation, trailing)
426+
if (requestFlags[camel(name)] !== undefined) {
427+
throw new SimApiError(`Pass the ${name} as arguments or with --${name}, not both`, 0)
428+
}
429+
requestFlags[camel(name)] = trailingValues
430+
}
419431

420432
foldRenamedFlags(operation, commandSpec, requestFlags)
421433
assertCapIsUsable(operation, requestFlags)

‎packages/sim-cli/src/runtime/options.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,13 @@ function addFieldOption(
233233
: flag.textSource
234234
? ' (@path / @- reads a file or stdin verbatim, including trailing newlines; @@value for a literal leading @)'
235235
: ''
236-
}${descriptor.required ? ' (required)' : ''}${literalNull ? literalNullHint(documented, name) : ''}`
236+
}${
237+
descriptor.required
238+
? flag.trailing
239+
? ' (required, or pass the values as arguments)'
240+
: ' (required)'
241+
: ''
242+
}${literalNull ? literalNullHint(documented, name) : ''}`
237243

238244
const renamedFrom = flag.renamedFrom ?? []
239245
const option = new Option(`${short}--${name} ${placeholder}`, describe)
@@ -243,11 +249,12 @@ function addFieldOption(
243249
option.default(undefined, String(descriptor.default))
244250
}
245251
// Commander's mandatory check runs before `executeOperation` can fold a
246-
// renamed spelling onto the current one, so a required field that has been
247-
// renamed would reject the very argv this exists to keep working. The
248-
// requirement is not lost: `buildRequest` raises it against the current
249-
// spelling once both have had their chance to supply the value.
250-
if (descriptor.required && renamedFrom.length === 0) option.makeOptionMandatory()
252+
// renamed spelling or trailing arguments onto the flag, so a required field
253+
// supplied either way would be rejected. The requirement is not lost:
254+
// `buildRequest` raises it once every spelling has had its chance.
255+
if (descriptor.required && renamedFrom.length === 0 && !flag.trailing) {
256+
option.makeOptionMandatory()
257+
}
251258
command.addOption(option)
252259

253260
for (const previous of renamedFrom) {

‎packages/sim-cli/src/runtime/request.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ export function flagSpecFor(operation: V2OperationName, field: string): FlagSpec
6161
return CLI_CONTRACT[operation]?.flags?.[field] ?? {}
6262
}
6363

64+
/** The list field whose values may also follow the command as arguments, if any. */
65+
export function trailingListField(commandSpec: CommandSpec): string | undefined {
66+
return Object.entries(commandSpec.flags ?? {}).find(([, flag]) => flag.trailing)?.[0]
67+
}
68+
6469
/**
6570
* Long and short flags the root program has already claimed.
6671
*
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { runEmbeddedCli } from '../embed'
3+
4+
const identity = { endpoint: 'https://sim.test', apiKey: 'fixture', workspaceId: 'workspace' }
5+
6+
/** Runs one embedded invocation, recording each request body it sends. */
7+
async function run(argv: string[]) {
8+
const bodies: unknown[] = []
9+
const result = await runEmbeddedCli(argv, {
10+
...identity,
11+
transport: async (_url, init) => {
12+
bodies.push(JSON.parse(String(init?.body)))
13+
return Response.json({ data: { deletedItems: { files: 2 } } })
14+
},
15+
})
16+
return { ...result, bodies }
17+
}
18+
19+
describe('files batch-delete ids as trailing arguments', () => {
20+
it('sends ids given as arguments exactly as it sends --file-ids', async () => {
21+
const trailing = await run(['files', 'batch-delete', 'wf_a', 'wf_b', '--yes'])
22+
const flagged = await run(['files', 'batch-delete', '--file-ids', 'wf_a', 'wf_b', '--yes'])
23+
expect(trailing.exitCode).toBe(0)
24+
expect(trailing.bodies).toEqual([{ workspaceId: 'workspace', fileIds: ['wf_a', 'wf_b'] }])
25+
expect(flagged.bodies).toEqual(trailing.bodies)
26+
})
27+
28+
it('refuses both forms at once, and neither, before any request', async () => {
29+
const both = await run(['files', 'batch-delete', 'wf_a', '--file-ids', 'wf_b', '--yes'])
30+
expect(both.exitCode).toBe(1)
31+
expect(both.stderr).toContain('as arguments or with --file-ids, not both')
32+
const neither = await run(['files', 'batch-delete', '--yes'])
33+
expect(neither.exitCode).toBe(1)
34+
expect(neither.stderr).toContain('--file-ids is required')
35+
expect([...both.bodies, ...neither.bodies]).toEqual([])
36+
})
37+
38+
it('keeps the destructive confirmation for ids given as arguments', async () => {
39+
const unconfirmed = await run(['files', 'batch-delete', 'wf_a'])
40+
expect(unconfirmed.exitCode).toBe(1)
41+
expect(unconfirmed.stderr).toContain('Re-run with --yes to confirm')
42+
expect(unconfirmed.bodies).toEqual([])
43+
})
44+
})

0 commit comments

Comments
 (0)