mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
* chore: run the orphaned migration-safety test, and stop publishing real-looking ids One of the eight script tests was reachable from no entrypoint, so it had never run in CI — it passes, it was simply never invoked. This is the second time that hand-maintained list has drifted from the files beside it; the audit runner's own header records the first. The guard against a third is a `check:*` script rather than a test, because the runner derives its list from that namespace and so picks the guard up by name — a test would have had to be hand-added to the very list it guards. A published spec sat outside the generator's manifest and so outside its drift check, and carried six example ids with the texture of real generated ones rather than the pandigital placeholders the rest of the repo uses. The new check globs the directory instead of reading the manifest, since the manifest is what omitted the file. The one pre-existing borderline id is allowlisted with a reason: loosening the threshold to admit it would have hidden one of the six. * fix(cli): close the gaps black-box testing the shipped CLI found `sim profiles <anything>` still exited 0, so a probe reading the exit code to ask whether a command exists was told yes — the one group the earlier guard missed. The exemption was written for commands that are both a group and a leaf, but only `files restore` takes an operand; `profiles` takes none. Registering its listing as a default subcommand puts it back among the pure dispatchers the existing guard already covers, so the guard itself did not need widening. Three commands refuse a workspace API key and said nothing, while their menu siblings said so — reading as though they accept one. They are hand-written, so they never reached the code that appends the note. That note now comes from a helper taking the operation, so a command names the operation it invokes and the two cannot disagree, and a test fails if a hand-written command ever calls a restricted operation without it. A blank numeric value in a request body still became a real zero, the same coercion already fixed for query strings: the guard keyed off the slot when the distinction is the field's declared type. Twenty-one fields across fifteen operations were affected. An empty body string still clears a description. Blank values for the root endpoint, workspace and profile flags fell back to what was configured instead of being refused, and a whitespace workspace was accepted verbatim. A hand-written profile name carrying padding listed as reachable but resolved to defaults rather than erroring. Two schema descriptions named request fields that no flag spells, and a rejected value was echoed unredacted by four messages while their siblings redacted it. A write now re-emits a section header it was not asked to touch byte for byte. The blank-line normalisation around it is left alone: making the writer position-faithful is a change to its model, not a fix. * fix(scripts): match example uuids case-insensitively in the spec audit The pattern only recognised lowercase hex, so an uppercase id in a published spec was never examined and the audit reported success without having looked at it. Matching case-insensitively is not enough on its own: hex is case-insensitive, so a mixed-case id counts `A` and `a` as two digits and reports twenty distinct ones rather than sixteen. That inflated count clears the threshold the texture test uses to recognise a hand-authored placeholder, so a real id could have passed for one. The allowlist is an exact-string lookup and would likewise have missed an uppercase spelling of an entry. Both checks and the lookup now take a normalised id, while the finding still reports the spelling as it appears in the file. * fix(cli): stop a refusal being swallowed, and gate example ids by name A blank root flag was refused everywhere except `profiles`, where the catch that lets a broken profile still list absorbed it and the command exited 0 after printing the table. The refusal now carries its own error class, which is what the listing rethrows on — the two are distinguished by type rather than by matching message text, and a genuinely broken profile still lists. The unknown-profile message redacted the name the caller typed but not the suggestion or the list of configured names beside it, which come from the same file and are equally attacker-influenced once it has been hand-edited. Those are redacted now, as is every other message in these two files that quotes a name read out of the config, and the profile listing flattens the names it renders the way it already flattened the error column. The example-id audit judged a uuid by its digit texture, on the premise that a real one essentially never looks hand-authored. Measured against ten million generated ids, 0.81% of them do — one in 124, where this change alone replaced six. Requiring each digit exactly twice takes that to zero but rejects all fourteen placeholders now in the specs, so it is no cheaper than the alternative. The audit now holds the eighteen ids the specs actually use, which is one file rather than the twenty-seven a reserved format would touch, and a new id fails until someone lists it — which is the review the check exists to force. * fix(scripts): match the uuid sentinels exactly rather than by shape Accepting any id built from at most two distinct hex digits let something through that was never on the approved list. A generated id essentially never has that shape, so the practical risk was small — but this check had just stopped being a shape test and become a list, and a structural exception is the one thing that undoes that. The two ids it exists for are the nil and max sentinels, and both are matched by value now.
628 lines
24 KiB
TypeScript
628 lines
24 KiB
TypeScript
import { existsSync, readFileSync, readSync } from 'node:fs'
|
|
import { CLI_CONTRACT } from '../contract/commands'
|
|
import type { CommandSpec, FlagSpec } from '../contract/types'
|
|
import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api'
|
|
import { type QueryValue, SimApiError } from '../http/client'
|
|
import { camel, kebab } from './derive'
|
|
import type { OperationSpec } from './types'
|
|
|
|
/** One request field, as the generator describes it. */
|
|
export interface FieldSpec {
|
|
kind: 'string' | 'number' | 'integer' | 'boolean' | 'enum' | 'array' | 'object' | 'unknown'
|
|
required?: boolean
|
|
values?: readonly string[]
|
|
default?: unknown
|
|
/** The field's `.describe()` from the route contract, used as `--help` text. */
|
|
describe?: string
|
|
}
|
|
|
|
/**
|
|
* The workspace never becomes a flag.
|
|
*
|
|
* It is the one field every workspace-scoped operation declares, and it comes
|
|
* from the profile — surfacing it as `--workspace-id` on 30-odd commands would
|
|
* duplicate the global `--workspace` and invite the two to disagree.
|
|
*/
|
|
export const PROFILE_INJECTED_FIELD = 'workspaceId'
|
|
|
|
/** Whether this path segment comes from the active profile's workspace. */
|
|
export function isProfileWorkspacePath(commandSpec: CommandSpec, param: string): boolean {
|
|
return commandSpec.profileWorkspacePath === true && param === PROFILE_INJECTED_FIELD
|
|
}
|
|
|
|
/**
|
|
* The slot a cursor-paginated operation carries its `cursor` field in.
|
|
*
|
|
* Pagination, not the name of a field, is what makes `limit` a page size. It
|
|
* lives here rather than beside its reader in `execute.ts` because `options.ts`
|
|
* has to ask the same question while it builds the flag, and importing
|
|
* `execute.ts` from `options.ts` would close a module cycle — `execute.ts`
|
|
* already reads `DEFAULT_LIMIT` from `options.ts`.
|
|
*/
|
|
export function cursorSlot(
|
|
operationSpec: Pick<OperationSpec, 'query' | 'body'>
|
|
): 'query' | 'body' | null {
|
|
if (operationSpec.query && 'cursor' in operationSpec.query) return 'query'
|
|
if (operationSpec.body && 'cursor' in operationSpec.body) return 'body'
|
|
return null
|
|
}
|
|
|
|
/** Kinds the CLI can only accept as a JSON string. */
|
|
const JSON_KINDS = new Set(['object', 'array', 'unknown'])
|
|
|
|
/** Kinds read through `Number`, where a blank does not survive: `Number('')` is `0`. */
|
|
const NUMERIC_KINDS: ReadonlySet<FieldSpec['kind']> = new Set(['number', 'integer'])
|
|
|
|
export function flagSpecFor(operation: V2OperationName, field: string): FlagSpec {
|
|
return CLI_CONTRACT[operation]?.flags?.[field] ?? {}
|
|
}
|
|
|
|
/**
|
|
* Long and short flags the root program has already claimed.
|
|
*
|
|
* Commander matches the root's own options across the whole of argv, including
|
|
* after a subcommand name, so a leaf that declares one of these never sees what
|
|
* the caller typed. The two failure modes differ only in how loud they are:
|
|
* `--version` and `--help` terminate, so `sim workflows rollback wf_1 --version
|
|
* 1` printed the CLI version and exited `0` without issuing a request; the
|
|
* root's value flags do not terminate, so a colliding leaf simply reads
|
|
* `undefined` and acts as though the flag were never typed.
|
|
*/
|
|
export const RESERVED_PROGRAM_FLAGS: ReadonlySet<string> = new Set([
|
|
'--version',
|
|
'-V',
|
|
'--help',
|
|
'-h',
|
|
'--profile',
|
|
'-P',
|
|
'--endpoint',
|
|
'--workspace',
|
|
'-w',
|
|
'--output',
|
|
])
|
|
|
|
/**
|
|
* Spellings a derived flag name is moved to when it would be shadowed.
|
|
*
|
|
* Only the name the CLI derives is rewritten. A name the contract states
|
|
* outright is left as written and caught by the build-time collision check
|
|
* instead — an explicit spelling is somebody's decision, and quietly serving a
|
|
* different flag than the one they wrote is how the shadowing went unnoticed in
|
|
* the first place.
|
|
*/
|
|
const RESERVED_FLAG_REPLACEMENTS: Readonly<Record<string, string>> = {
|
|
version: 'to-version',
|
|
}
|
|
|
|
/** The flag name a field is exposed under, honouring any contract override. */
|
|
export function flagNameFor(operation: V2OperationName, field: string): string {
|
|
const declared = flagSpecFor(operation, field).name
|
|
if (declared) return declared
|
|
const derived = kebab(field)
|
|
return RESERVED_FLAG_REPLACEMENTS[derived] ?? derived
|
|
}
|
|
|
|
/** The named option used for a path parameter that is contextual rather than primary. */
|
|
export function pathFlagNameFor(commandSpec: CommandSpec, param: string): string {
|
|
return commandSpec.pathFlags?.[param]?.name ?? kebab(param)
|
|
}
|
|
|
|
export function takesJson(field: FieldSpec, flag: FlagSpec): boolean {
|
|
// `rowCap` builds the object itself from a typed number, so the field's
|
|
// object kind must not pull the flag back into the JSON form it replaces.
|
|
if (flag.rowCap) return false
|
|
return flag.json === true || JSON_KINDS.has(field.kind)
|
|
}
|
|
|
|
/** The route's ceiling on `limit.max`; stated here so the refusal can name it. */
|
|
const MAX_ROW_CAP = 1_000_000
|
|
|
|
/** Reads `--max-rows 100` as the `{ type: 'rows', max: 100 }` the route declares. */
|
|
function coerceRowCap(raw: unknown, flagName: string): { type: 'rows'; max: number } {
|
|
const value = Number(raw)
|
|
if (!Number.isInteger(value) || value < 1 || value > MAX_ROW_CAP) {
|
|
throw new SimApiError(
|
|
`--${flagName} must be a whole number between 1 and ${MAX_ROW_CAP.toLocaleString('en-US')}`,
|
|
0
|
|
)
|
|
}
|
|
return { type: 'rows', max: value }
|
|
}
|
|
|
|
/**
|
|
* Drains stdin synchronously.
|
|
*
|
|
* `readFileSync(0)` looks like the obvious way to do this and fails on the one
|
|
* case that matters: a pipe is opened non-blocking, so a single read of an
|
|
* upstream process that has not written yet returns EAGAIN rather than waiting,
|
|
* and `export … | import --workflow @-` died with a raw stack trace. Reading in
|
|
* a loop and treating EAGAIN as "not ready yet" is what makes a pipe work.
|
|
*
|
|
* `Atomics.wait` is the only synchronous sleep available; without it the retry
|
|
* spins a core for as long as the writer takes.
|
|
*/
|
|
function readStdin(): string {
|
|
const idle = new Int32Array(new SharedArrayBuffer(4))
|
|
const buffer = Buffer.alloc(64 * 1024)
|
|
const chunks: Buffer[] = []
|
|
|
|
for (;;) {
|
|
let read: number
|
|
try {
|
|
read = readSync(0, buffer, 0, buffer.length, null)
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code
|
|
if (code === 'EAGAIN') {
|
|
Atomics.wait(idle, 0, 0, 5)
|
|
continue
|
|
}
|
|
// Some platforms report end-of-input on a pipe as EOF rather than 0.
|
|
if (code === 'EOF') break
|
|
throw error
|
|
}
|
|
if (read === 0) break
|
|
chunks.push(Buffer.from(buffer.subarray(0, read)))
|
|
}
|
|
|
|
return Buffer.concat(chunks).toString('utf8')
|
|
}
|
|
|
|
/**
|
|
* Resolves a flag argument that may name a file instead of carrying its value
|
|
* inline.
|
|
*
|
|
* `@path` reads the file and `@-` reads stdin, the curl convention. A workflow
|
|
* export is hundreds of lines, and the shell makes passing that literally
|
|
* unpleasant — unquoted `$(cat f.json)` word-splits into broken JSON, and the
|
|
* quoted form is easy to get wrong. JSON never starts with `@`; primitive list
|
|
* flags reserve it for this explicit file-input form.
|
|
*
|
|
* A value that genuinely starts with `@` is written `@@`, and only the leading
|
|
* `@` is dropped. The escape lives here rather than in any one command so that
|
|
* every `@`-aware flag inherits it: without it `--tag @urgent` has no spelling
|
|
* at all, because it can only be read as a request to open a file named
|
|
* `urgent`.
|
|
*/
|
|
/**
|
|
* Points at `@@` when an `@value` names nothing on disk.
|
|
*
|
|
* `--allowed-emails @example.org` is the natural spelling of a domain pattern
|
|
* and reads here as a request to open a file, and "cannot read example.org"
|
|
* alone gives no clue the value has a literal spelling at all. Gated on ENOENT
|
|
* so a real file that cannot be read (EACCES, EISDIR) is not answered with
|
|
* advice about escaping.
|
|
*/
|
|
function literalAtHint(error: unknown, path: string): string {
|
|
return (error as NodeJS.ErrnoException)?.code === 'ENOENT'
|
|
? `. To pass the literal value @${path}, write @@${path}`
|
|
: ''
|
|
}
|
|
|
|
export function readArgumentSource(raw: string, flagName: string): { text: string; from: string } {
|
|
if (raw.startsWith('@@')) return { text: raw.slice(1), from: '' }
|
|
if (!raw.startsWith('@')) return { text: raw, from: '' }
|
|
|
|
const path = raw.slice(1)
|
|
if (path === '-') {
|
|
if (process.stdin.isTTY) {
|
|
throw new SimApiError(`--${flagName} @- reads stdin, but nothing is piped in`, 0)
|
|
}
|
|
try {
|
|
return { text: readStdin(), from: ' (read from stdin)' }
|
|
} catch (error) {
|
|
throw new SimApiError(`--${flagName} cannot read stdin: ${(error as Error).message}`, 0)
|
|
}
|
|
}
|
|
|
|
try {
|
|
return { text: readFileSync(path, 'utf8'), from: ` (read from ${path})` }
|
|
} catch (error) {
|
|
throw new SimApiError(
|
|
`--${flagName} cannot read ${path}: ${(error as Error).message}${literalAtHint(error, path)}`,
|
|
0
|
|
)
|
|
}
|
|
}
|
|
|
|
/** Reads a primitive list from argv or a newline-delimited file. */
|
|
function readListValues(raw: unknown, flagName: string): string[] {
|
|
const arguments_ = Array.isArray(raw) ? raw : [raw]
|
|
const values = arguments_.flatMap((argument) => {
|
|
if (typeof argument !== 'string') {
|
|
throw new SimApiError(`--${flagName} values must be strings`, 0)
|
|
}
|
|
|
|
if (!argument.startsWith('@')) return [argument]
|
|
|
|
const source = readArgumentSource(argument, flagName)
|
|
const lines = source.text.split(/\r?\n/)
|
|
if (lines.at(-1) === '') lines.pop()
|
|
if (lines.length === 0) {
|
|
throw new SimApiError(`--${flagName}${source.from} contains no values`, 0)
|
|
}
|
|
|
|
return lines.map((line, index) => {
|
|
const value = line.trim()
|
|
if (!value) {
|
|
throw new SimApiError(
|
|
`--${flagName}${source.from} has an empty value on line ${index + 1}`,
|
|
0
|
|
)
|
|
}
|
|
return value
|
|
})
|
|
})
|
|
|
|
return values.map((value) => {
|
|
const trimmed = value.trim()
|
|
if (!trimmed) throw new SimApiError(`--${flagName} values cannot be empty`, 0)
|
|
return trimmed
|
|
})
|
|
}
|
|
|
|
/** A percent-escape the caller has already applied, well-formed enough to decode. */
|
|
const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/
|
|
|
|
/** What `encodeURIComponent` leaves raw and the server's canonical encoder does not. */
|
|
const SUB_DELIMITERS = /[!'()*]/g
|
|
|
|
/**
|
|
* Encodes one segment exactly as `encodeFolderPathSegment` does server-side.
|
|
*
|
|
* The route does not merely decode a path, it re-encodes each segment and
|
|
* demands the result match byte for byte, so "close enough" is rejected outright
|
|
* with `Path must be a canonical folder path`. `encodeURIComponent` alone leaves
|
|
* `!'()*` raw — common in real folder names (`Q1 (draft)`, `Sam's stuff`) — and
|
|
* spells a lone `.` or `..` as itself, which the server refuses to let address a
|
|
* folder actually named that.
|
|
*/
|
|
function encodeFolderPathSegment(name: string): string {
|
|
if (name === '.') return '%2E'
|
|
if (name === '..') return '%2E%2E'
|
|
return encodeURIComponent(name).replace(
|
|
SUB_DELIMITERS,
|
|
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Rewrites one folder path into the API's canonical wire form.
|
|
*
|
|
* The wire form encodes each segment, so `/Folder 1` in the app is
|
|
* `/Folder%201` to the API — and typing the name you can see was rejected with
|
|
* a message that never said the word encoding. Splitting on `/` first is what
|
|
* keeps the separators: `encodeURIComponent` over the whole path would turn
|
|
* every one of them into `%2F` and address a single top-level folder whose name
|
|
* contains slashes.
|
|
*
|
|
* Decoding each segment before encoding it is what makes this idempotent, and
|
|
* it has to be: the encoded spelling is what the CLI prints today, what the
|
|
* README shows, and therefore what people will paste back. `/Folder 1` and
|
|
* `/Folder%201` must reach the same folder, and `%2520` is the failure to
|
|
* avoid. The limit of that rule is a folder whose name really contains a `%`
|
|
* followed by two hex digits — `100%20off` reads as `100 off`. A stray `%` is
|
|
* safe, because it fails to decode and is encoded literally, and the ambiguous
|
|
* name can always be typed in its encoded form (`100%2520off`).
|
|
*/
|
|
export function encodeFolderPath(value: string): string {
|
|
return value
|
|
.split('/')
|
|
.map((segment) => {
|
|
if (!PERCENT_ESCAPE.test(segment)) return encodeFolderPathSegment(segment)
|
|
try {
|
|
return encodeFolderPathSegment(decodeURIComponent(segment))
|
|
} catch {
|
|
return encodeFolderPathSegment(segment)
|
|
}
|
|
})
|
|
.join('/')
|
|
}
|
|
|
|
/**
|
|
* Points at `@` when a value that failed to parse looks like a filename.
|
|
*
|
|
* `--workflow export.json` is the natural first guess, and "must be valid JSON"
|
|
* alone gives no clue that passing a file is even supported.
|
|
*/
|
|
function pathHint(raw: string): string {
|
|
if (raw.startsWith('@') || /^\s*[[{"\-\d]|^\s*(true|false|null)/.test(raw)) return ''
|
|
return existsSync(raw)
|
|
? `. ${raw} is a file — pass it as @${raw}`
|
|
: '. To read a file, pass @path (or @- for stdin)'
|
|
}
|
|
|
|
/**
|
|
* Turns the string argv provides into the value the contract expects.
|
|
*
|
|
* Every failure names the flag rather than the field, because the flag is what
|
|
* the caller typed — and every one of these is caught before any request is
|
|
* made, so a typo costs nothing.
|
|
*/
|
|
export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: string): unknown {
|
|
if (raw === undefined) return undefined
|
|
|
|
/**
|
|
* A repeated flag. `list` says the CLI accepts several values; the *wire*
|
|
* encoding follows the field's own kind, because the two are not the same
|
|
* question:
|
|
*
|
|
* - `string` — the route splits on commas (`workflowIds`, `folderPaths`,
|
|
* `triggers`), so the values are joined.
|
|
* - anything else — the wire genuinely wants an array (`rowIds`,
|
|
* `selectedOutputs`) or a string-or-array union whose array branch is the
|
|
* right one (`knowledgeBaseIds`). Joining those produced a single bogus id
|
|
* or failed validation outright.
|
|
*/
|
|
if (flag.list) {
|
|
const values = readListValues(raw, flagName).map((value) =>
|
|
flag.folderPath ? encodeFolderPath(value) : value
|
|
)
|
|
// Encoding first is also what keeps the comma-joined form unambiguous: a
|
|
// folder name containing a comma leaves here as `%2C`, so the route's split
|
|
// cannot cut one path in half.
|
|
return field.kind === 'string' ? values.join(',') : values
|
|
}
|
|
|
|
if (flag.rowCap) return coerceRowCap(raw, flagName)
|
|
|
|
if (takesJson(field, flag)) {
|
|
if (typeof raw !== 'string') return raw
|
|
const source = readArgumentSource(raw, flagName)
|
|
try {
|
|
return JSON.parse(source.text)
|
|
} catch (error) {
|
|
throw new SimApiError(
|
|
`--${flagName} must be valid JSON${source.from}: ${(error as Error).message}${pathHint(raw)}`,
|
|
0
|
|
)
|
|
}
|
|
}
|
|
|
|
if (NUMERIC_KINDS.has(field.kind)) {
|
|
const value = Number(raw)
|
|
if (Number.isNaN(value)) throw new SimApiError(`--${flagName} must be a number`, 0)
|
|
return value
|
|
}
|
|
|
|
if (field.kind === 'boolean' || flag.boolean) return raw === true || raw === 'true'
|
|
|
|
const choices = flag.choices ?? field.values
|
|
if (choices && !choices.includes(String(raw))) {
|
|
throw new SimApiError(`--${flagName} must be one of: ${choices.join(', ')}`, 0)
|
|
}
|
|
|
|
if (flag.folderPath && typeof raw === 'string') return encodeFolderPath(raw)
|
|
|
|
return raw
|
|
}
|
|
|
|
/**
|
|
* The workspace precondition as stated when the profile is not at hand.
|
|
*
|
|
* `executeOperation` resolves the workspace through `SimClient.requireWorkspace`
|
|
* first, which names the profile and checks the API key, so this is a defensive
|
|
* floor rather than the wording a caller sees.
|
|
*/
|
|
const NO_WORKSPACE_FALLBACK =
|
|
'No workspace set. Pass --workspace, or run: sim configure --set-workspace <id>'
|
|
|
|
export interface BuiltRequest {
|
|
path: string
|
|
query: Record<string, QueryValue>
|
|
body: Record<string, unknown> | undefined
|
|
/** Contract-declared request headers, absent when the operation declares none. */
|
|
headers?: Record<string, string>
|
|
}
|
|
|
|
/**
|
|
* A query string can only carry scalars. Every v2 query field is one today, but
|
|
* a structured field could be added — serializing it here keeps that a working
|
|
* request rather than `[object Object]`.
|
|
*/
|
|
function asQueryValue(value: unknown): QueryValue {
|
|
if (value === null || value === undefined) return undefined
|
|
if (typeof value === 'object') return JSON.stringify(value)
|
|
return value as QueryValue
|
|
}
|
|
|
|
/**
|
|
* Assembles one operation's HTTP request from positional args, parsed flags,
|
|
* and the profile's workspace.
|
|
*
|
|
* Primary path params come from positional arguments in declared order. A
|
|
* contextual path param can instead come from a named option declared by the
|
|
* CLI contract. Every other field is looked up by its flag name in the slot the
|
|
* API contract declares it in, so a field that moved from query to body moves
|
|
* here on the next regeneration.
|
|
*/
|
|
export function buildRequest(
|
|
operation: V2OperationName,
|
|
positional: string[],
|
|
flags: Record<string, unknown>,
|
|
workspaceId: string | null
|
|
): BuiltRequest {
|
|
const commandSpec: CommandSpec = CLI_CONTRACT[operation] ?? {}
|
|
const spec = V2_OPERATIONS[operation] as {
|
|
method: string
|
|
path: string
|
|
pathParams: readonly string[]
|
|
query?: Record<string, FieldSpec>
|
|
body?: Record<string, FieldSpec>
|
|
headers?: Record<string, FieldSpec>
|
|
opaqueBody?: boolean
|
|
}
|
|
|
|
let path = spec.path
|
|
let positionalIndex = 0
|
|
for (const param of spec.pathParams) {
|
|
const pathFlag = commandSpec.pathFlags?.[param]
|
|
const profileWorkspacePath = isProfileWorkspacePath(commandSpec, param)
|
|
const flagName = pathFlagNameFor(commandSpec, param)
|
|
const argumentName = commandSpec.pathArgumentNames?.[param] ?? param
|
|
const value = profileWorkspacePath
|
|
? workspaceId
|
|
: pathFlag
|
|
? flags[camel(flagName)]
|
|
: positional[positionalIndex++]
|
|
if (value === undefined || value === null) {
|
|
if (profileWorkspacePath) {
|
|
throw new SimApiError(NO_WORKSPACE_FALLBACK, 0)
|
|
}
|
|
throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${argumentName}>`, 0)
|
|
}
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
throw new SimApiError(
|
|
pathFlag ? `--${flagName} cannot be empty` : `<${argumentName}> cannot be empty`,
|
|
0
|
|
)
|
|
}
|
|
// Ids are opaque; an unencoded `/` or `?` would silently retarget the request.
|
|
path = path.replace(`[${param}]`, encodeURIComponent(value))
|
|
}
|
|
|
|
const query: Record<string, QueryValue> = {}
|
|
const body: Record<string, unknown> = {}
|
|
const headers: Record<string, string> = {}
|
|
|
|
/**
|
|
* On a paginating operation `limit` is the walk size rather than a filter,
|
|
* and the pager reads it from the flags itself — including refusing a blank
|
|
* one, in wording that says what `0` means there. Left to it, so the caller
|
|
* gets that message instead of the generic refusal below.
|
|
*/
|
|
const paginatedLimit = cursorSlot(spec) !== null
|
|
|
|
for (const slot of ['query', 'body', 'headers'] as const) {
|
|
for (const [field, descriptor] of Object.entries(spec[slot] ?? {})) {
|
|
const flag = flagSpecFor(operation, field)
|
|
if (flag.omit) continue
|
|
|
|
const flagName = flagNameFor(operation, field)
|
|
// Commander stores `--min-duration-ms` as `minDurationMs`; reading by the
|
|
// flag's own name silently finds nothing.
|
|
const omitProfileWorkspace = commandSpec.allWorkspaces && flags.allWorkspaces === true
|
|
const provided =
|
|
field === PROFILE_INJECTED_FIELD
|
|
? omitProfileWorkspace
|
|
? undefined
|
|
: workspaceId
|
|
: flags[camel(flagName)]
|
|
// A contract default only applies to what the caller left unsaid, so
|
|
// typing the flag — including typing the server's own default back — still
|
|
// decides. It is validated like any other value, enum choices included.
|
|
const raw = provided ?? flag.requestDefault
|
|
|
|
/**
|
|
* A blank filter is a mistake, and every v2 JSON route says so
|
|
* (`rejectBlankQueryValues`). The CLI never let one reach the wire: the
|
|
* URL builder skips an empty value, so `logs list --status ""` searched
|
|
* everything and answered `0`, a wider result set presented as an answer.
|
|
* Refused here, before the request, the way an empty list entry and an
|
|
* empty path parameter already are.
|
|
*
|
|
* Read from what the caller typed rather than from the coerced value,
|
|
* because coercion erases the blank on a numeric field: `Number('')` is
|
|
* `0`, so `--max-cost ""` reached the wire as a real "costing at most
|
|
* nothing" filter that a check on the coerced value cannot see. An
|
|
* explicit `--max-cost 0` is a value the caller chose and is still sent.
|
|
*
|
|
* That erasure is what a numeric *body* field suffers too, so the rule is
|
|
* the whole query slot plus every numeric field wherever it sits: `tables
|
|
* rows batch-delete --limit ""` sent `"limit":0`, a cap the caller never
|
|
* typed. The slot alone is not the distinction — a blank is meaningful
|
|
* only where it can survive as itself, which is a body *string*, where it
|
|
* clears a description.
|
|
*
|
|
* Blank is `trim()`-empty rather than exactly empty, matching both the
|
|
* route — `blankQueryValueValidationError` reads `?status=%20` as blank —
|
|
* and the list flag beside it, which trims each entry before refusing it.
|
|
* A quoted space is invisible in a shell and read as every blank the
|
|
* empty string did: `--max-cost " "` as `0`, `--deployed-only " "` as an
|
|
* explicit `false`, `--status " "` as a `%20` the server answers `400`.
|
|
*/
|
|
if (
|
|
(slot === 'query' || NUMERIC_KINDS.has(descriptor.kind)) &&
|
|
typeof raw === 'string' &&
|
|
raw.trim() === '' &&
|
|
!(field === 'limit' && paginatedLimit)
|
|
) {
|
|
throw new SimApiError(`--${flagName} cannot be empty`, 0)
|
|
}
|
|
|
|
const value = coerce(raw ?? undefined, descriptor, flag, flagName)
|
|
|
|
if (value === undefined) {
|
|
if (descriptor.required) {
|
|
throw new SimApiError(
|
|
field === PROFILE_INJECTED_FIELD ? NO_WORKSPACE_FALLBACK : `--${flagName} is required`,
|
|
0
|
|
)
|
|
}
|
|
// Omitted rather than sent as null: the server applies its own default,
|
|
// and sending an explicit undefined would override it with nothing.
|
|
continue
|
|
}
|
|
|
|
if (slot === 'query') query[field] = asQueryValue(value)
|
|
// A header is a wire string: the contracts declare only string headers,
|
|
// and anything else would reach `fetch` as `[object Object]`.
|
|
else if (slot === 'headers') headers[field] = String(value)
|
|
else body[field] = value
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Left off entirely when the operation declared none, so a request without
|
|
* contract headers is byte-for-byte the request it was before.
|
|
*/
|
|
const headerSlot = Object.keys(headers).length > 0 ? { headers } : {}
|
|
|
|
// A union body comes in whole through `--body`, merged over the fields the
|
|
// branches share. Replacing outright dropped the profile's `workspaceId`,
|
|
// which both branches require, so every insert came back as invalid input.
|
|
// The caller's JSON still wins on any key it sets.
|
|
if (spec.opaqueBody) {
|
|
if (commandSpec.bodyVariants) {
|
|
const provided = commandSpec.bodyVariants.filter(
|
|
(variant) => flags[camel(variant.name)] !== undefined
|
|
)
|
|
const names = commandSpec.bodyVariants.map((variant) => `--${variant.name}`).join(' or ')
|
|
if (provided.length !== 1) {
|
|
throw new SimApiError(`Pass exactly one of ${names}`, 0)
|
|
}
|
|
|
|
const variant = provided[0]
|
|
const raw = flags[camel(variant.name)]
|
|
if (typeof raw !== 'string') throw new SimApiError(`--${variant.name} is required`, 0)
|
|
const parsed = coerce(raw, { kind: variant.kind }, { json: true }, variant.name)
|
|
if (
|
|
(variant.kind === 'object' &&
|
|
(!parsed || typeof parsed !== 'object' || Array.isArray(parsed))) ||
|
|
(variant.kind === 'array' && !Array.isArray(parsed))
|
|
) {
|
|
throw new SimApiError(`--${variant.name} must be a JSON ${variant.kind}`, 0)
|
|
}
|
|
return { path, query, body: { ...body, [variant.property]: parsed }, ...headerSlot }
|
|
}
|
|
|
|
const raw = flags.body
|
|
if (typeof raw !== 'string') throw new SimApiError('--body is required', 0)
|
|
const parsed = coerce(raw, { kind: 'object' }, { json: true }, 'body')
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new SimApiError('--body must be a JSON object', 0)
|
|
}
|
|
return { path, query, body: { ...body, ...(parsed as Record<string, unknown>) }, ...headerSlot }
|
|
}
|
|
|
|
return {
|
|
path,
|
|
query,
|
|
/**
|
|
* A declared JSON body is still an object when all of its fields are optional.
|
|
* Sending no bytes makes the server reject before field defaults can apply.
|
|
*/
|
|
body: spec.body ? body : undefined,
|
|
...headerSlot,
|
|
}
|
|
}
|