Files
sim/scripts/generate-v2-cli-api.ts
T
Waleed c930830310 fix(v2,cli): second audit pass over the v2 API and CLI (#7126)
* fix(chat): resolve a caller-supplied conversation id through its owner

The v2 chat route used the caller-supplied conversationId verbatim, with no
existence, owner, or workspace check, against a store keyed by bare text with
no owner column. A caller who knew another user's conversation id reached that
conversation. Ids now resolve through the same owner-scoped loader the web chat
path uses, and anything unresolvable answers one uniform 404 before any
lifecycle work runs. Omitting the id mints a server-issued conversation.

The contract also accepted any 1-128 character string for a column typed uuid,
so a malformed id raised a driver error and rendered 500 while an unknown but
well-formed id rendered 404 - a shape oracle, and a 500 on ordinary input.

The ownership predicate had no coverage anywhere: the route test mocked the
module and the lifecycle test drove a chain mock that ignores its where clause,
so deleting the owner condition left both suites green. It is now asserted by
composition and by condition count, which is what catches a dropped condition.

Also renames the reply's model identifier away from a term the project's own
copy rules forbid on a user-facing surface.

* fix(v2): conceal workspace absence, and stop archived tables faulting their page

Two reads answered a caller more than they were entitled to know.

A workspace a caller cannot reach at all returned FORBIDDEN while one that does
not exist returned NOT_FOUND, so a workspace-key holder could enumerate which
workspace ids exist by diffing the two. Both now answer the same absence, using
the concealment policy the billing routes already use. A refusal from inside the
workspace - a member whose role is too low - still answers FORBIDDEN, because
that caller already knows the workspace exists.

Separately, archiving a folder cascades onto its tables but leaves each table
pointing at the archived folder row. The archived listing resolved those paths
strictly, so one such row faulted the whole page and no cursor could step past
it - which also made the ids undiscoverable and left restore unreachable for
exactly the tables that need it. The archived scope now resolves leniently to
the root, where a restore would place them, matching the shipped workflows
behavior. Active listings still fault loudly on a dangling folder.

* fix(knowledge): validate upload processing options without stranding live sessions

recipe and lang were accepted as free strings up to their length caps, silently
discarded, and echoed back nowhere, so a typo was unobservable: uploading with a
misspelled recipe returned 200 and quietly used the default. Both are now
validated at the boundary and a bad value answers 400 naming what is accepted.

The accepted recipe set deliberately includes the sentinel every first-party
caller sends today alongside the three real chunker recipes, and the three are
derived from the chunker's own union so removing one there is a compile error
here rather than a silent 400 in production.

The same schema also parses metadata read back off a persisted upload session,
so tightening it would have thrown out of resume and complete for any session
created before this - a 500 on work that could then never finish. The read-back
path now drops a value it no longer recognises instead of rejecting it; the
request boundary stays strict.

Neither field reaches chunking, so nothing here moves chunk boundaries,
embeddings, or search results.

* fix(v2): honour a requested stats window, and answer a claimed graph id with a conflict

Log statistics accepted a start and an end, filtered the totals by them, and
then built the series against wall-clock now. Bucket width was computed over a
span the caller never asked for, and every bucket past the requested end was
structurally empty - so a bounded historical query returned a wrong-width series
with fabricated trailing buckets, under a window label that disagreed with the
request. Each edge now honours the bound it was given and keeps its previous
derivation when omitted, so an unbounded request is unchanged.

Separately, block, edge and subflow ids are global primary keys while the
delete that precedes a state replace is scoped to one workflow. An id owned by
another workflow survived that delete, the insert violated the key, and because
callers pass their own transaction the driver error escaped unclassified as a
server fault. The write now refuses such an id up front with a conflict naming
it, and re-classifies the same violation if one races past the check, since the
lock covers only the workflow being written. The dry run checks the ids a commit
would insert and reports the warnings a commit would report, which is what its
own contract already promised.

* fix(secrets): let a workspace secret change its metadata without resending the value

Restoring redaction cost more than removing it. The only way to flip a secret
back to redacted was to re-send the plaintext, because the write required a
value and omitting it fell into an interactive prompt that cannot run in CI.
A workspace secret can now change its description or visibility on its own; the
stored value is never re-encrypted or rewritten, a write that names no existing
secret answers not-found rather than creating one, and a personal secret still
requires a value because it has no other writable field.

The path parameter was also one shared schema across the write and the delete,
so a single description had to cover both and the delete documented an argument
that could create and replace. Split, mirroring the credentials pair.

The metadata write is a new update against the credentials table, so its scope
is asserted by composition and by condition count: an unscoped update would let
one workspace flip another workspace's identically-named secret out of
redaction, and the cache invalidation would then carry that flag into the other
workspace's runtime catalog.

* fix(v2): say what an error means in terms the caller can act on

A size-limit refusal collapsed every value under a kilobyte to "0 Bytes", so a
28-byte file over a 27-byte ceiling read "is 0 Bytes, above the 0 Bytes limit" -
self-contradictory, and useless for choosing a value that would work.

Errors and field descriptions also told callers to invoke raw HTTP endpoints.
These strings serve the REST reference and the CLI's own help equally, so they
now name the operation and its object rather than a method and a path. A sweep
test walks every v2 schema description and holds the line, with the remaining
offenders in files this change does not own recorded explicitly rather than
left to be rediscovered.

Listing the editors of a built-in skill claimed the skill did not exist, while
reading the same id succeeded - a well-formed request for a real resource is
not malformed, so the list answers an empty roster and only the mutations
refuse.

Bulk folder deletion recorded only the leaf name in its audit trail while the
single delete recorded the full path, leaving two same-named folders under
different parents indistinguishable after the fact.

Bulk chunk enable, disable and delete each treated an unmatched id differently
behind one sentence of documentation. They now follow one rule.

A workspace-scoped list refused with the name of a resource the caller never
addressed, which reads as an empty workspace rather than an unreachable one.

* fix(cli): stop a config value forging a section it was never meant to write

The config file is written by joining names and values into INI lines, and
nothing checked what was in them. A profile name carrying a newline and a
section header wrote a section that merged into a different profile and took
over its endpoint - and the next command sent that profile's stored API key
there. A workspace value could do the same from the other side, since only the
endpoint flag validated its input.

The refusal now lives at the writer, the single place untrusted text enters the
document, with the flag-level checks kept for the better message. Either alone
blocks the forgery; the pair is deliberate.

Rejecting rather than escaping, because the format has no escape syntax and
these files are hand-edited and read by other tools that would not decode one
we invented. The forbidden set covers control characters and the two Unicode
line separators, which the previous guard missed - those parse as an unreadable
line, so the key silently vanished on read and the next write appended a
duplicate while the command reported success.

Login also wrote the key before the settings, so a malformed response from the
deployment could leave a key on disk with no endpoint beside it, and the next
command would send it to the default host. Settings are written first, and the
response is checked before anything touches disk.

Name validation applies only when creating a profile, so a hand-written one
that predates the rule keeps working.

* fix(docs): tell the reader which key a command needs, and stop the ids contradicting the CLI

Around sixty v2 operations refuse a workspace API key, and the CLI's help said
nothing about it - the caller found out from a 403 after the request went out.
The restriction is already stated in the API spec, so the generator now reads it
from there and the command description carries it. The sentinel sentences are
imported from the spec's own constants rather than copied, so a reword cannot
silently unmark every command, and the test pins the count as well as named
operations because a reword confined to one family would otherwise slip past.

The generated reference also rendered an empty default as a sentence pointing at
nothing - "Defaults to ." - for every repeatable filter. Omitted now, while
false and zero still render, which is the trap that shape of check usually
walks into.

The hand-written guides used a workflow-shaped id for workflows that the CLI's
own help says never names one, and five other families were equally wrong. All
of them now match the scheme the CLI declares, consistently per entity across
pages, with the shared ones taken from that help text so the two read as one
voice.

The page documenting every flag was linked from nowhere; both landing links
pointed at the overview instead. And the generator's test file was absent from
the hand-maintained list CI runs, so its guards never executed.

* fix(cli): stop a page-size default capping a destructive filter

Every request field named limit inherited the pager's default of 100, but only
a cursor-paginated command interprets that flag. The two filter-based row
mutations declare no cursor, so the default went onto the wire as a row cap: a
filter matching 250 rows deleted 100, exited 0, and said nothing - while the
confirmation the user had just answered promised every matching row. The flag's
own help offered 0 for everything, which those endpoints reject; the unbounded
form is the field being absent. The pager's default now applies only where the
pager runs, and the tests pin the omission on the request body rather than in
help text.

A cap typed alongside an explicit row list was silently ignored; it is now
refused on the client, where refusing costs nothing to already-installed
versions.

Lists also truncated at a hundred with no signal in any format, and the two
inventory endpoints that do report truncation had that field dropped on the way
out - so a caller reconciling against a clipped list could not tell. One note
now goes to stderr while stdout stays a bare array, and a flag raised on a later
page survives the fold.

Also: a folder whose name contains the separator no longer prints a path that
resolves to a different folder; validation errors name the flag the user typed
instead of the wire field; an unknown subcommand with --help exits non-zero
instead of printing the parent's help; a fractional or negative page size is
refused rather than floored; an empty query filter is refused rather than
silently returning everything; and the two spellings of the missing-workspace
message became one.

* fix(cli): gate destructive table imports and fix follow-mode rendering

A `tables import --mode replace` empties the table before its first batch,
so the only warning was in the describe. It now confirms, and the wording
tells the truth per mode: cancelling a replace leaves a prefix of the new
file with the originals already gone, while an append re-adds its rows if
the file is imported twice. `--yes` skips the gate, and the gate runs
before the file is opened.

Import and export cancellation carried no describe at all; the import one
now confirms, the export one records why it deliberately does not.

Follow-mode output truncated cells to whatever the first row happened to
measure, so a longer status or workflow name arrived clipped with no
signal. Cells now clamp at a shared ceiling and pad to the lock, and the
log columns carry width floors so a short first page cannot pin a column
narrower than its own values.

Interrupting a staged download left the staging directory behind; it is
now removed on SIGINT and SIGTERM before the signal is re-raised.

`--select-output` without `--follow` selected from a response that does
not carry outputs, and said nothing. It is refused client-side, with a
separate message for `--async`. Its describe now names what the path
addresses.

`secrets set` always read a value, even when only metadata flags were
passed. Off a TTY that was an immediate refusal, so a metadata-only edit
exited 1 in CI for a value it was never asked for; on a TTY it stopped to
prompt, and the prompt rejects an empty entry, so there was no way to say
"leave the stored value alone" short of re-typing the secret. The read is
now skipped and the field omitted, which is what lets a metadata-only edit
run unattended. On a TTY, setting only a description no longer prompts.
Passing both spellings of the reveal flag is refused rather than silently
resolved.

Four mandatory hand-authored flags now say so, `billing logs` names its
key-type scope, and the dispatch list declares its columns.

* fix: close the gaps an adversarial review of this branch found

A conflict handler added earlier in this branch was dead code. It read the
Postgres error code off the thrown object, but the driver error arrives
wrapped with the real one on `cause`, so the check returned false on its
first line and the 409 never fired. Its test passed only because it threw a
flat shape production never produces. It now reads through the cause chain
with the shared helpers, compares the constraint name exactly instead of
matching a substring of the SQL, and its test throws the real wrapped error.

Resuming a conversation checked its workflow and its workspace but not its
type, so a conversation created by the web surface could be continued as a
CLI turn. It now refuses through the same uniform 404 as every other
mismatch, which closes the same omission on the web posting path. Minting
one no longer leaves a blank untitled row at the top of the Chat list.

The pre-write check on a minted API key refused fewer characters than the
writer does, so a key the check accepted could still fail at the write —
after the endpoint beside it was already stored, pairing a new endpoint with
the previous key. The two had drifted because the set was spelled three
times; there is now one.

A description claimed a processed count reported only the chunks that
changed. The update returns every row it matched, so re-enabling chunks that
were already enabled counts them all. Two OpenAPI sentences promised no
conflict detection and no persistence warnings in a dry run, both of which
the same branch had just made false. A described window was wrong whenever a
start was supplied without an end.

Listing the editors of a built-in skill answered a read with a modification
refusal on the internal surface. Archived table listings could reach the
strict folder projector again through a third scope value the input type
still allowed. A metadata-only secret write skipped the guard its
personal-scope twin has. The internal document boundary still took the two
processing fields as unbounded strings. Truncation was reported only from
the response envelope, so a clipped file body, row search and workflow-stats
list said nothing.

A staged download stopped watching for signals before it finished removing
its directory, and cleared every listener for the signal rather than its own.
Three tests asserted a contract constant against itself; they now drive
rendered help, real argv, or real render output.

* chore: regenerate the API reference, CLI surface, and CLI docs

The published reference still marked a secret value required and described
the delete parameter as one that also creates, the CLI surface still lacked
the marker that says which operations refuse a workspace key, and the
reference rendered an empty sentence for every repeatable filter whose
default is an empty list.

* test(cli): use the package's own delay helper in the staging poll

The audit bans a hand-rolled setTimeout promise. `sim-cli` does not depend
on the shared utils package, and its own idiom is `node:timers/promises`.

* fix: act on a second review round, and correct two earlier claims

The conflict pre-check read block ids from the wrong side. The writer
inserts each block's own `id` field while the check read the record key,
and the two can diverge because preparation copies a value under its key
without reconciling them. Edges already read the value and subflows are
genuinely keyed by the record key, so only blocks were wrong — collecting
every family from the values, as first suggested, would have broken
subflows instead.

A minted API key carrying leading or trailing whitespace passed the
pre-write check but failed the writer, leaving the new endpoint on disk
beside the previous key. It is refused up front now rather than trimmed: a
key is opaque, so trimming would store a value the server never issued and
turn a loud failure into an unexplained 401 later. The endpoint normalizer
does trim, which is what made a padded `--endpoint` fail only after the
browser flow had already minted a key.

A metadata-only secret write raced with deletion returned 500, because the
follow-up read that only assembles the response body threw an unclassified
error; it now reports the same not-found the non-racing miss already gave.
An unusable output format in the environment silently printed a table
instead of refusing. Two validation messages printed control characters
verbatim. A dry run now reports the preparation warnings its own commit
path returns.

The chat route created a titled conversation and never wrote a message, so
it appeared in the Chat list promising content it did not have. Both sides
of a successful turn are now persisted; a failed turn still writes nothing,
so a question is never stored without its answer.

Two claims of mine were wrong. The earlier commit message said `secrets
set` sent an empty value that overwrote the stored secret — it did not; the
prompt refuses off a TTY and rejects empty on one, so the old behaviour was
a clean refusal. And the delay helper commit said this package's idiom is
`node:timers/promises`; the package carries its own `sleep`, which is the
audit's sanctioned home and has five callers. It uses that now.

Also: a test asserting a deadlock stays unclassified could not fail, since
every candidate rejects it; it now pins a unique violation carrying no
constraint name. Workflow ids spelled with the file prefix are corrected in
the remaining fixtures, leaving the genuine file ids alone.

* fix: close a credential-misdirection path this branch had opened

Making the endpoint normalizer trim handled whitespace around a value but
not a control character inside one, and the URL parser removes those from
anywhere in its input — so a value that reads as one host could resolve to
another, and the profile's key went with it. The flag and environment paths
never touch the config writer, so its guard did not cover this. The
normalizer now refuses the same character set the writer does, which also
keeps the invariant that nothing it blesses can be refused by the write
that stores it. Comparing the parsed URL back against its input was the
alternative and is wrong: the parser rewrites percent-encoding, case,
internationalized hosts and default ports, so legitimate endpoints would be
refused.

The blank-query guard tested for exactly empty, so a whitespace-only value
still reached the wire — as a real zero on a numeric filter, an explicit
false on a boolean one, and as an encoded space the server then rejected.
It now refuses any value that is blank once trimmed, while a body string
keeps its meaning, an explicit zero still sends, and a value with content
around its whitespace is passed through untouched rather than trimmed.

A graph-id conflict reported 409 on the v2 route and fell through the older
persistence wrapper as an unclassified 500. That wrapper now classifies
orchestration failures through the cause chain, which also fixes a
pre-existing case where a workflow archived between authorization and the
locked read reported 500 rather than 404.

Persisting a chat turn claimed its row by id alone, so a conversation
soft-deleted mid-turn still received the messages and was bumped back up
the list. It now requires a live row. A turn whose caller hung up after the
model had already answered persisted nothing, though the work was done and
billed; it now persists and still reports the connection as closed.

An empty workspace id from the login response was read as no workspace at
all. A published description still promised a language-tag standard the
schema does not enforce.

The test asserting that a turn is stored before the final event drained the
whole response first, so it held whichever order the code used. It now
reads the stream incrementally and fails if the write moves after the
event.
2026-08-26 15:05:00 -07:00

689 lines
27 KiB
TypeScript

#!/usr/bin/env bun
/**
* Generates the Sim CLI's view of the public v2 API from the Zod route
* contracts, so the terminal and the server cannot describe the same endpoint
* differently.
*
* The contracts under `apps/sim/lib/api/contracts/v2/**` are the single source
* of truth: the routes validate against them, so a shape that disagrees with a
* contract is a shape the server would reject. Everything downstream is derived
* rather than restated.
*
* The CLI cannot import the contracts directly — `packages/*` must never depend
* on `apps/*` (scripts/check-monorepo-boundaries.ts). This script bridges that
* at build time instead: it reads the contracts here and emits a file of plain
* type declarations with no imports at all, so nothing about the package
* boundary changes.
*
* Deliberately NOT generated: the OpenAPI documents under `apps/docs`. They
* carry hand-written descriptions, examples, and error responses that Zod
* schemas do not encode. `scripts/check-openapi-specs.ts` reconciles those
* against the same contracts instead, field by field, so the prose survives
* while drift still fails CI.
*
* Usage:
* bun run scripts/generate-v2-cli-api.ts # write the generated file
* bun run scripts/generate-v2-cli-api.ts --check # fail if it is stale
*/
import { spawnSync } from 'node:child_process'
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { z } from 'zod'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2')
const OUTPUT = path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts')
const DOCS_DIR = path.join(ROOT, 'apps/docs')
/**
* OpenAPI documents to read operation summaries from, discovered rather than
* listed — same reason as {@link contractModules}.
*
* A new spec file (`openapi-v2-resources.json` arrived with the MCP/skills/
* folders/credentials endpoints) would otherwise go unread, and the only symptom
* would be `--help` quietly falling back to `METHOD /path` for a whole domain.
*
* `openapi.json` is the retired single-document spec, superseded by the split
* files; it is excluded by name because it still exists on disk and would
* contribute stale duplicates.
*/
function specFiles(): string[] {
return readdirSync(DOCS_DIR, { withFileTypes: true })
.filter(
(entry) =>
entry.isFile() &&
entry.name.startsWith('openapi') &&
entry.name.endsWith('.json') &&
entry.name !== 'openapi.json'
)
.map((entry) => entry.name)
.sort()
}
/** What the OpenAPI specs say about one operation, beyond its request shape. */
export interface OperationDoc {
/** The spec's one-line summary, used as the command's `--help` description. */
summary?: string
/**
* The operation refuses a workspace API key, per its `description`.
*
* Carried so `--help` can say so before the request goes out; without it the
* caller learns the restriction from a `403` after the fact.
*/
personalKeyOnly?: true
}
/**
* The description sentences that mark an operation as personal-key-only.
*
* Read out of `apps/sim/lib/api/contracts/v2/openapi/shared.ts` at generation
* time rather than restated here, so rewording the sentence there cannot leave
* the marker silently unemitted. The import is lazy because that module
* resolves through the `@/` alias, which exists under `bun` but not under the
* root `vitest` that imports this file's pure helpers.
*/
export async function loadPersonalKeyMarkers(): Promise<readonly string[]> {
const shared: Record<string, unknown> = await import(
path.join(ROOT, 'apps/sim/lib/api/contracts/v2/openapi/shared.ts')
)
const markers = [shared.WORKSPACE_API_KEY_DENIED, shared.WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND]
for (const marker of markers) {
if (typeof marker !== 'string' || !marker.trim()) {
throw new Error('openapi/shared.ts no longer exports the workspace-key denial sentences')
}
}
return markers as string[]
}
/**
* `METHOD /api/v2/{id}/…` → what the specs document about that operation.
*
* The contracts carry validation, not prose, so `--help` text has to come from
* somewhere else. The specs already hold a hand-written summary per operation
* and `check:openapi` guarantees every contract has one, so reading them here
* reuses documentation that is already written and already verified rather than
* inventing a second place to describe the same endpoint. The longer
* `description` is read for the same reason — it is where the workspace-key
* denial is already stated.
*/
export function loadSummaries(personalKeyMarkers: readonly string[]): Map<string, OperationDoc> {
const docs = new Map<string, OperationDoc>()
for (const file of specFiles()) {
let spec: Record<string, any>
try {
spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8'))
} catch {
// A missing spec is not fatal: the CLI falls back to `METHOD path`, and
// `check:openapi` is what actually enforces the specs' presence.
continue
}
for (const [specPath, methods] of Object.entries(spec.paths ?? {})) {
for (const [method, operation] of Object.entries(methods as Record<string, any>)) {
const doc: OperationDoc = {}
if (typeof operation?.summary === 'string') doc.summary = operation.summary
const description = operation?.description
if (
typeof description === 'string' &&
personalKeyMarkers.some((marker) => description.includes(marker))
) {
doc.personalKeyOnly = true
}
if (doc.summary || doc.personalKeyOnly) {
docs.set(`${method.toUpperCase()} ${specPath}`, doc)
}
}
}
}
return docs
}
/**
* Every contract module under `contracts/v2`, discovered rather than listed.
*
* A hardcoded list is the wrong shape for this: adding a v2 domain would leave
* its operations silently absent from the CLI, with no error and nothing in
* `--check` to notice, because the generated file would still match a generator
* that never looked. Discovery makes a new domain appear on the next
* regeneration, which is the property the whole pipeline is built on.
*
* `shared.ts` holds the response-envelope helpers, not contracts; it is skipped
* because it exports no route contract, not because it is named here.
*/
function contractModules(): string[] {
return readdirSync(CONTRACTS_DIR, { withFileTypes: true })
.filter(
(entry) =>
entry.isFile() &&
entry.name.endsWith('.ts') &&
!entry.name.endsWith('.test.ts') &&
entry.name !== 'index.ts'
)
.map((entry) => entry.name.replace(/\.ts$/, ''))
.sort()
}
interface RouteContract {
method: string
path: string
params?: z.ZodType
query?: z.ZodType
body?: z.ZodType
headers?: z.ZodType
response: { mode: string; schema?: z.ZodType }
}
interface Operation {
/** `listTables` — derived from the export name. */
name: string
domain: string
contract: RouteContract
}
function isRouteContract(value: unknown): value is RouteContract {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<RouteContract>
return (
typeof candidate.method === 'string' &&
typeof candidate.path === 'string' &&
typeof candidate.response === 'object'
)
}
/** `v2ListTablesContract` → `listTables`. */
function operationName(exportName: string): string {
const stripped = exportName.replace(/^v2/, '').replace(/Contract$/, '')
return stripped.charAt(0).toLowerCase() + stripped.slice(1)
}
function pascal(name: string): string {
return name.charAt(0).toUpperCase() + name.slice(1)
}
async function collectOperations(): Promise<Operation[]> {
const operations: Operation[] = []
for (const domain of contractModules()) {
const mod: Record<string, unknown> = await import(path.join(CONTRACTS_DIR, `${domain}.ts`))
for (const [exportName, value] of Object.entries(mod)) {
if (!exportName.endsWith('Contract') || !isRouteContract(value)) continue
operations.push({ name: operationName(exportName), domain, contract: value })
}
}
// Import order is stable, but sort anyway so a reordered export list does not
// show up as a spurious diff in the generated file.
return operations.sort((a, b) => a.name.localeCompare(b.name))
}
type JsonSchema = Record<string, any>
/**
* Emits a TypeScript type for the subset of JSON Schema that `z.toJSONSchema`
* produces from these contracts.
*
* Hand-rolled rather than pulled from `json-schema-to-typescript`: the input is
* a known, narrow subset (no `patternProperties`, no draft-04 quirks), and the
* output is committed and read by humans, so controlling the formatting is
* worth more here than covering spec corners that never appear. An unhandled
* construct throws rather than degrading to `any` — silence is how a generated
* client drifts from its server.
*
* `refs` maps a `$defs` key to the TypeScript alias hoisted for it. Zod factors
* a schema out into `$defs` when it is recursive, which the table view's filter
* grammar is — a predicate holds predicates — so it cannot be inlined.
*/
function toTypeScript(schema: JsonSchema, indent = 0, refs?: Map<string, string>): string {
if (typeof schema.$ref === 'string') {
const key = schema.$ref.replace('#/$defs/', '')
const name = refs?.get(key)
if (!name) throw new Error(`Unresolved $ref: ${schema.$ref}`)
return name
}
const pad = ' '.repeat(indent + 1)
const closePad = ' '.repeat(indent)
if (schema.const !== undefined) return JSON.stringify(schema.const)
if (schema.enum) return schema.enum.map((v: unknown) => JSON.stringify(v)).join(' | ')
const variants = schema.anyOf ?? schema.oneOf
if (variants) {
return variants.map((v: JsonSchema) => toTypeScript(v, indent, refs)).join(' | ')
}
if (schema.allOf) {
return schema.allOf.map((v: JsonSchema) => toTypeScript(v, indent, refs)).join(' & ')
}
switch (schema.type) {
case 'string':
return 'string'
case 'number':
case 'integer':
return 'number'
case 'boolean':
return 'boolean'
case 'null':
return 'null'
case 'array':
return schema.items ? `Array<${toTypeScript(schema.items, indent, refs)}>` : 'unknown[]'
case 'object': {
const properties: Record<string, JsonSchema> = schema.properties ?? {}
const required: string[] = schema.required ?? []
const keys = Object.keys(properties)
if (keys.length === 0) {
// A bare object with only `additionalProperties` is a record.
const value =
schema.additionalProperties && typeof schema.additionalProperties === 'object'
? toTypeScript(schema.additionalProperties, indent, refs)
: 'unknown'
return `Record<string, ${value}>`
}
const lines = keys.map((key) => {
const optional = required.includes(key) ? '' : '?'
const safeKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
return `${pad}${safeKey}${optional}: ${toTypeScript(properties[key], indent + 1, refs)}`
})
return `{\n${lines.join('\n')}\n${closePad}}`
}
}
// `z.unknown()` / `z.any()` render as a schema carrying no constraints. A
// `.describe()` on one adds annotation keys without narrowing the type, so
// those are not constraints either.
const ANNOTATION_KEYS = new Set(['$schema', 'description', 'title', 'default', 'examples'])
if (Object.keys(schema).every((k) => ANNOTATION_KEYS.has(k))) return 'unknown'
throw new Error(`Unhandled JSON Schema construct: ${JSON.stringify(schema).slice(0, 200)}`)
}
/**
* A type plus any aliases that must be declared before it.
*
* A recursive schema cannot be written inline, so Zod lifts it into `$defs` and
* points at it; those become real named types, which TypeScript resolves
* recursively without complaint.
*/
interface GeneratedType {
type: string
declarations: string[]
}
function schemaToType(schema: z.ZodType, io: 'input' | 'output', name: string): GeneratedType {
const json = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema
const defs = json.$defs as Record<string, JsonSchema> | undefined
if (!defs) return { type: toTypeScript(json), declarations: [] }
// Named after the type that owns them, so two operations lifting their own
// `__schema0` cannot collide in the single generated module.
const refs = new Map(Object.keys(defs).map((key, index) => [key, `${name}Ref${index}`]))
const declarations = Object.entries(defs).map(
([key, def]) => `type ${refs.get(key)} = ${toTypeScript(def, 0, refs)}\n`
)
const { $defs, ...root } = json
return { type: toTypeScript(root, 0, refs), declarations }
}
/** Path params the CLI must substitute, e.g. `/api/v2/workflows/[id]` → `['id']`. */
function pathParams(routePath: string): string[] {
return [...routePath.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1])
}
/**
* `.describe()` for each path parameter, so a positional argument can explain
* itself the way a flag does.
*
* The params schema is otherwise read only for its field names, which the route
* path already supplies — the prose attached to them was being discarded, and
* `sim tables rows get <tableId> <rowId>` had nothing to say about either.
*/
function pathParamDocs(schema: z.ZodType | undefined): Record<string, string> {
if (!schema) return {}
const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema
const docs: Record<string, string> = {}
for (const [key, property] of Object.entries(json.properties ?? {})) {
const description = (property as JsonSchema).description
if (typeof description === 'string' && description.trim()) docs[key] = description.trim()
}
return docs
}
/**
* The kind a request field reduces to for the CLI's purposes.
*
* Everything from argv arrives as a string, so this is what tells the runtime
* how to turn `"50"` into `50`, a bare `--flag` into `true`, and `'{"a":1}'`
* into an object. `unknown` covers `z.unknown()`/`z.any()`, which the CLI can
* only accept as JSON.
*/
type FieldKind =
| 'string'
| 'number'
| 'integer'
| 'boolean'
| 'enum'
| 'array'
| 'object'
| 'unknown'
function fieldKind(schema: JsonSchema): FieldKind {
if (schema.enum) return 'enum'
const variants = schema.anyOf ?? schema.oneOf
if (variants) {
// Nullable is spelled as a union with `null`; a single non-null branch is
// the field's real kind. A genuine multi-branch union has no single flag
// shape, so it falls through to `unknown` and is taken as JSON.
const concrete = variants.filter((v: JsonSchema) => v.type !== 'null')
return concrete.length === 1 ? fieldKind(concrete[0]) : 'unknown'
}
const type = Array.isArray(schema.type)
? schema.type.find((t: string) => t !== 'null')
: schema.type
switch (type) {
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'array':
case 'object':
return type
default:
return 'unknown'
}
}
/**
* Describes one request slot's fields for the runtime that builds flags.
*
* Emitted as data rather than baked into types because the CLI has to *iterate*
* these at startup to construct commands — a type alone cannot be walked.
*/
/**
* Whether the slot is a union, whose branches the CLI cannot turn into flags.
*
* Distinct from "the map came out empty": the shared fields of a union are
* emitted as a map, so emptiness alone no longer identifies one, and the
* runtime still has to know the rest of the body must come in as JSON.
*/
function isUnionSlot(schema: z.ZodType): boolean {
const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema
return Object.keys(json.properties ?? {}).length === 0 && Boolean(json.anyOf ?? json.oneOf)
}
/**
* Headers the CLI sets itself, which must never become flags.
*
* `options.headers` is spread last over the client's own header block, so a
* flag spelled `--x-api-key` would let argv replace the profile's credential —
* a footgun on every command that carries it, and an authentication decision
* argv has no business making. No contract declares one of these today; the
* exclusion exists so that adding one does not quietly grow a flag for it.
*/
export const CLI_MANAGED_HEADERS: ReadonlySet<string> = new Set([
'x-api-key',
'authorization',
'accept',
'content-type',
'user-agent',
])
export function renderSlotMap(
schema: z.ZodType | undefined,
indent: string,
exclude?: ReadonlySet<string>
): string | null {
if (!schema) return null
const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema
let properties: Record<string, JsonSchema> = json.properties ?? {}
let required = new Set<string>(json.required ?? [])
// A union has no properties of its own, but the fields every branch agrees on
// are still known and still have to be sent — `workspaceId` is required by
// both branches of the row-insert body and comes from the profile, so
// dropping it left `tables rows create` rejected as invalid input.
if (Object.keys(properties).length === 0) {
const branches = (json.anyOf ?? json.oneOf) as JsonSchema[] | undefined
if (branches?.length) {
const shared = branches.reduce<string[]>(
(keys, branch) => keys.filter((key) => branch.properties?.[key] !== undefined),
Object.keys(branches[0].properties ?? {})
)
properties = Object.fromEntries(shared.map((key) => [key, branches[0].properties[key]]))
required = new Set(shared.filter((key) => branches.every((b) => b.required?.includes(key))))
}
}
const keys = Object.keys(properties).filter((key) => !exclude?.has(key))
// A union body (e.g. single-row vs batch insert) has no flat field list. The
// caller marks it `opaqueBody` so the runtime can offer the whole body as one
// JSON flag instead.
if (keys.length === 0) return null
// A schema carrying `.meta({ id })` is lifted into `$defs` and referenced, so
// the property here is a bare `$ref` with no type to classify. Left
// unresolved every such field reads as `unknown` and the CLI demands JSON for
// what is really a plain string flag.
const defs = (json.$defs ?? {}) as Record<string, JsonSchema>
const deref = (schema: JsonSchema): JsonSchema => {
let current = schema
for (let depth = 0; typeof current.$ref === 'string' && depth < 10; depth++) {
const resolved = defs[current.$ref.replace('#/$defs/', '')]
if (!resolved) break
current = resolved
}
return current
}
const lines = keys.map((key) => {
const property = deref(properties[key])
const parts = [`kind: '${fieldKind(property)}'`]
if (required.has(key)) parts.push('required: true')
if (property.enum) {
parts.push(
`values: [${property.enum.map((v: unknown) => JSON.stringify(v)).join(', ')}] as const`
)
}
if (property.default !== undefined) parts.push(`default: ${JSON.stringify(property.default)}`)
// The contract's own `.describe()` is the field's documentation, and it is
// already what the OpenAPI specs publish. Carrying it here is what lets
// `--help` say what a flag means instead of restating its name back at the
// reader as "Set sort by". Read from the reference site first: a field that
// narrows a shared `$defs` schema describes its own use of it.
const description = properties[key].description ?? property.description
if (typeof description === 'string' && description.trim()) {
parts.push(`describe: ${JSON.stringify(description.trim())}`)
}
return `${indent} ${JSON.stringify(key)}: { ${parts.join(', ')} },`
})
return `{\n${lines.join('\n')}\n${indent}}`
}
function render(operations: Operation[], docs: Map<string, OperationDoc>): string {
const out: string[] = []
out.push('/**')
out.push(' * GENERATED FILE — DO NOT EDIT.')
out.push(' *')
out.push(' * Emitted from the Zod route contracts in')
out.push(' * `apps/sim/lib/api/contracts/v2/**` by `scripts/generate-v2-cli-api.ts`.')
out.push(' * Regenerate with `bun run generate:cli-api`; CI fails when this file is')
out.push(' * stale, so edit the contract rather than this file.')
out.push(' *')
out.push(' * Contains only type declarations and one const table — no imports, so the')
out.push(' * `packages/* must not import apps/*` boundary is preserved.')
out.push(' */')
out.push('')
for (const op of operations) {
const Name = pascal(op.name)
const { contract } = op
out.push(`/** \`${contract.method} ${contract.path}\` */`)
for (const slot of ['params', 'query', 'body', 'headers'] as const) {
const schema = contract[slot]
if (!schema) continue
const slotName = `${Name}${pascal(slot)}`
const generated = schemaToType(schema, 'input', slotName)
out.push(...generated.declarations)
out.push(`export type ${slotName} = ${generated.type}`)
out.push('')
}
if (contract.response.mode === 'json' && contract.response.schema) {
const generated = schemaToType(contract.response.schema, 'output', `${Name}Response`)
out.push(...generated.declarations)
out.push(`export type ${Name}Response = ${generated.type}`)
} else {
out.push(`/** Non-JSON response (\`${contract.response.mode}\`). */`)
out.push(`export type ${Name}Response = never`)
}
out.push('')
}
out.push('/**')
out.push(' * Every v2 operation, keyed by name.')
out.push(' *')
out.push(' * `query`, `body`, and `headers` describe each field well enough for the CLI')
out.push(' * to build a flag for it and coerce the string argv gives back: its kind,')
out.push(' * whether it is required, its enum values, and its server-side default. A slot')
out.push(' * the contract does not declare — or one whose shape is a union with no flat')
out.push(' * field list — is absent, and the runtime falls back to taking it as JSON.')
out.push(' * Headers the CLI sets itself, such as the API key, are never listed.')
out.push(' *')
out.push(" * `summary` is the operation's one-line description, lifted from the OpenAPI")
out.push(' * specs so `--help` reuses prose that is already written and already checked.')
out.push(' *')
out.push(' * `personalKeyOnly` marks an operation whose spec description says a workspace')
out.push(' * API key is rejected, so `--help` can say so before the request is sent.')
out.push(' */')
out.push('export const V2_OPERATIONS = {')
for (const op of operations) {
const params = pathParams(op.contract.path)
out.push(` ${op.name}: {`)
out.push(` method: '${op.contract.method}',`)
out.push(` path: '${op.contract.path}',`)
out.push(` pathParams: [${params.map((p) => `'${p}'`).join(', ')}] as const,`)
const paramDocs = pathParamDocs(op.contract.params)
const documentedParams = params.filter((p) => paramDocs[p])
if (documentedParams.length > 0) {
const entries = documentedParams.map(
(p) => `${JSON.stringify(p)}: ${JSON.stringify(paramDocs[p])}`
)
out.push(` pathParamDocs: { ${entries.join(', ')} },`)
}
out.push(` responseMode: '${op.contract.response.mode}',`)
// OpenAPI writes `{id}` where the contract writes `[id]`.
const doc = docs.get(
`${op.contract.method} ${op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}')}`
)
if (doc?.summary) out.push(` summary: ${JSON.stringify(doc.summary)},`)
if (doc?.personalKeyOnly) out.push(` personalKeyOnly: true,`)
for (const slot of ['query', 'body'] as const) {
const map = renderSlotMap(op.contract[slot], ' ')
if (map) out.push(` ${slot}: ${map},`)
// A declared slot with no flat field list still has to be sendable.
// Absence alone cannot say so: it means both "no body" and "a body the
// generator could not describe", and reading it as the former left
// `tables rows create` unable to send anything at all.
if (slot === 'body' && op.contract.body && isUnionSlot(op.contract.body)) {
out.push(` opaqueBody: true,`)
}
}
// Contract headers are request input like any other slot: `upload-token`
// is what addresses an upload session, and leaving it out of this table
// meant the runtime could not build a flag for it, so `files uploads get`
// was rejected as invalid input on every call it could ever make.
const headers = renderSlotMap(op.contract.headers, ' ', CLI_MANAGED_HEADERS)
if (headers) out.push(` headers: ${headers},`)
out.push(' },')
}
out.push('} as const')
out.push('')
out.push('export type V2OperationName = keyof typeof V2_OPERATIONS')
out.push('')
return out.join('\n')
}
/**
* Runs the emitted source through Biome so the generated file is a fixed point
* of the repo's formatter.
*
* Without this the file is rewritten on the way into a commit: lint-staged runs
* `biome check --write` on explicit paths, which bypasses the `files.includes`
* exclusion in biome.json. The result was a generated file that no longer
* matched its generator, so `--check` failed in CI complaining about contract
* drift that had not happened. Formatting here means the hook has nothing left
* to change.
*/
function format(source: string): string {
const result = spawnSync(
path.join(ROOT, 'node_modules/.bin/biome'),
['format', `--stdin-file-path=${OUTPUT}`],
{ input: source, encoding: 'utf8' }
)
if (result.status !== 0 || !result.stdout) {
// Fail loudly: silently emitting unformatted output would reintroduce the
// exact hook-rewrites-generated-file loop this exists to close.
throw new Error(
`biome failed to format the generated output (status ${result.status}): ${result.stderr ?? ''}`
)
}
return result.stdout
}
async function main() {
const args = new Set(process.argv.slice(2))
const operations = await collectOperations()
const generated = format(render(operations, loadSummaries(await loadPersonalKeyMarkers())))
if (args.has('--check')) {
let current = ''
try {
current = readFileSync(OUTPUT, 'utf8')
} catch {
console.error(`${path.relative(ROOT, OUTPUT)} is missing. Run: bun run generate:cli-api`)
process.exit(1)
}
if (current !== generated) {
console.error(
`${path.relative(ROOT, OUTPUT)} is stale. Run: bun run generate:cli-api\n\n` +
'The v2 contracts changed without the CLI being regenerated.'
)
process.exit(1)
}
console.log(`${path.relative(ROOT, OUTPUT)} is up to date (${operations.length} operations).`)
return
}
writeFileSync(OUTPUT, generated)
console.log(
`Wrote ${path.relative(ROOT, OUTPUT)} — ${operations.length} operations from ${contractModules().length} contract modules.`
)
}
// Guarded so the pure helpers above can be imported by tests without the
// generator writing a file as a side effect of the import.
if (import.meta.main) main()