Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 12 additions & 2 deletions apps/docs/content/docs/cli/files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -634,16 +634,26 @@ API request and response schemas: [deleteFile](/api-reference/files/deleteFile).
This archives the listed files; restore them with `files restore`.

```bash
sim files batch-delete [options]
sim files batch-delete [file-ids...] [options]
```

**Arguments**

<CommandTable>

| Argument | Required | Description |
| --- | --- | --- |
| `file-ids...` | No | File identifiers to delete. Same as --file-ids. |

</CommandTable>

**Options**

<CommandTable>

| Option | Required | Description |
| --- | --- | --- |
| `--file-ids <value...>` | Yes | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
| `--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). |
| `-y, --yes` | Yes | Confirm this operation. |

</CommandTable>
Expand Down
14 changes: 12 additions & 2 deletions apps/docs/content/docs/cli/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -972,16 +972,26 @@ Delete several files at once
This archives the listed files; restore them with `files restore`.

```bash
sim files batch-delete [options]
sim files batch-delete [file-ids...] [options]
```

**Arguments**

<CommandTable>

| Argument | Required | Description |
| --- | --- | --- |
| `file-ids...` | No | File identifiers to delete. Same as --file-ids. |

</CommandTable>

**Options**

<CommandTable>

| Option | Required | Description |
| --- | --- | --- |
| `--file-ids <value...>` | Yes | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
| `--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). |
| `-y, --yes` | Yes | Confirm this operation. |

</CommandTable>
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/scripts/fixtures/content-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -4422,8 +4422,8 @@
{
"lang": "bash",
"meta": null,
"sha256": "41e9881b8582523af56643e45a5a1ba351eee353683c169a0dc8fc56b4d3ad66",
"bytes": 32,
"sha256": "c569c5e38502a1c1e8f6078954bc90b559dd80adb577bb4cf7b63ec7bbdb3aa7",
"bytes": 46,
"line": 632
},
{
Expand Down Expand Up @@ -11786,8 +11786,8 @@
{
"lang": "bash",
"meta": null,
"sha256": "41e9881b8582523af56643e45a5a1ba351eee353683c169a0dc8fc56b4d3ad66",
"bytes": 32,
"sha256": "c569c5e38502a1c1e8f6078954bc90b559dd80adb577bb4cf7b63ec7bbdb3aa7",
"bytes": 46,
"line": 970
},
{
Expand Down
2 changes: 1 addition & 1 deletion packages/sim-cli/src/contract/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1556,7 +1556,7 @@ export const CLI_CONTRACT: CliContract = {
command: 'files batch-delete',
describe: 'Delete several files at once',
flags: {
fileIds: { list: true },
fileIds: { list: true, trailing: true },
},
confirm: 'This archives the listed files; restore them with `files restore`.',
},
Expand Down
5 changes: 5 additions & 0 deletions packages/sim-cli/src/contract/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export interface FlagSpec {
* error, and a literal `#` value can still be passed.
*/
manifest?: true
/**
* The list's values may also follow the command as arguments, so `files batch-delete <id>...`
* reads like `rm`. Passing both forms is refused. At most one field per command.
*/
trailing?: true
/** Take a JSON string. Implied for object/array/unknown fields. */
json?: boolean
/** Read a scalar string verbatim from `@path` / `@-`; `@@` escapes a literal leading `@`. */
Expand Down
17 changes: 17 additions & 0 deletions packages/sim-cli/src/runtime/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
isProfileWorkspacePath,
PROFILE_INJECTED_FIELD,
RESERVED_PROGRAM_FLAGS,
trailingListField,
} from './request'
import type { OperationSpec } from './types'

Expand Down Expand Up @@ -312,6 +313,22 @@ function configureOperation(
)
}

const trailing = trailingListField(spec)
if (trailing) {
const trailingFlags = Object.values(spec.flags ?? {}).filter((flag) => flag.trailing)
const descriptor = operationSpec.query?.[trailing] ?? operationSpec.body?.[trailing]
if (trailingFlags.length > 1)
throw new Error(`${operation} declares more than one trailing list`)
if (!descriptor || !flagSpecFor(operation, trailing).list) {
throw new Error(`${operation}.${trailing} is trailing but not a list request field`)
}
const name = flagNameFor(operation, trailing)
command.argument(
`[${name}...]`,
`${flagSpecFor(operation, trailing).describe ?? descriptor.describe} Same as --${name}.`
)
}

if (spec.requestFields) {
for (const field of spec.requestFields) {
if (
Expand Down
12 changes: 12 additions & 0 deletions packages/sim-cli/src/runtime/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
flagNameFor,
isProfileWorkspacePath,
PROFILE_INJECTED_FIELD,
trailingListField,
} from './request'
import { foldPageEnvelope, renderPage, renderResult } from './result'
import type { OperationSpec } from './types'
Expand Down Expand Up @@ -416,6 +417,17 @@ export async function executeOperation(
for (const [index, field] of (commandSpec.positionals ?? []).entries()) {
requestFlags[camel(flagNameFor(operation, field))] = invocation[pathPositionalCount + index]
}
const trailing = trailingListField(commandSpec)
const trailingValues = trailing
? invocation[pathPositionalCount + (commandSpec.positionals?.length ?? 0)]
: undefined
if (trailing && Array.isArray(trailingValues) && trailingValues.length > 0) {
const name = flagNameFor(operation, trailing)
if (requestFlags[camel(name)] !== undefined) {
throw new SimApiError(`Pass the ${name} as arguments or with --${name}, not both`, 0)
}
requestFlags[camel(name)] = trailingValues
}

foldRenamedFlags(operation, commandSpec, requestFlags)
assertCapIsUsable(operation, requestFlags)
Expand Down
19 changes: 13 additions & 6 deletions packages/sim-cli/src/runtime/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,13 @@ function addFieldOption(
: flag.textSource
? ' (@path / @- reads a file or stdin verbatim, including trailing newlines; @@value for a literal leading @)'
: ''
}${descriptor.required ? ' (required)' : ''}${literalNull ? literalNullHint(documented, name) : ''}`
}${
descriptor.required
? flag.trailing
? ' (required, or pass the values as arguments)'
: ' (required)'
: ''
}${literalNull ? literalNullHint(documented, name) : ''}`

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

for (const previous of renamedFrom) {
Expand Down
5 changes: 5 additions & 0 deletions packages/sim-cli/src/runtime/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export function flagSpecFor(operation: V2OperationName, field: string): FlagSpec
return CLI_CONTRACT[operation]?.flags?.[field] ?? {}
}

/** The list field whose values may also follow the command as arguments, if any. */
export function trailingListField(commandSpec: CommandSpec): string | undefined {
return Object.entries(commandSpec.flags ?? {}).find(([, flag]) => flag.trailing)?.[0]
}

/**
* Long and short flags the root program has already claimed.
*
Expand Down
44 changes: 44 additions & 0 deletions packages/sim-cli/src/runtime/trailing-list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { runEmbeddedCli } from '../embed'

const identity = { endpoint: 'https://sim.test', apiKey: 'fixture', workspaceId: 'workspace' }

/** Runs one embedded invocation, recording each request body it sends. */
async function run(argv: string[]) {
const bodies: unknown[] = []
const result = await runEmbeddedCli(argv, {
...identity,
transport: async (_url, init) => {
bodies.push(JSON.parse(String(init?.body)))
return Response.json({ data: { deletedItems: { files: 2 } } })
},
})
return { ...result, bodies }
}

describe('files batch-delete ids as trailing arguments', () => {
it('sends ids given as arguments exactly as it sends --file-ids', async () => {
const trailing = await run(['files', 'batch-delete', 'wf_a', 'wf_b', '--yes'])
const flagged = await run(['files', 'batch-delete', '--file-ids', 'wf_a', 'wf_b', '--yes'])
expect(trailing.exitCode).toBe(0)
expect(trailing.bodies).toEqual([{ workspaceId: 'workspace', fileIds: ['wf_a', 'wf_b'] }])
expect(flagged.bodies).toEqual(trailing.bodies)
})

it('refuses both forms at once, and neither, before any request', async () => {
const both = await run(['files', 'batch-delete', 'wf_a', '--file-ids', 'wf_b', '--yes'])
expect(both.exitCode).toBe(1)
expect(both.stderr).toContain('as arguments or with --file-ids, not both')
const neither = await run(['files', 'batch-delete', '--yes'])
expect(neither.exitCode).toBe(1)
expect(neither.stderr).toContain('--file-ids is required')
expect([...both.bodies, ...neither.bodies]).toEqual([])
})

it('keeps the destructive confirmation for ids given as arguments', async () => {
const unconfirmed = await run(['files', 'batch-delete', 'wf_a'])
expect(unconfirmed.exitCode).toBe(1)
expect(unconfirmed.stderr).toContain('Re-run with --yes to confirm')
expect(unconfirmed.bodies).toEqual([])
})
})
Loading