Skip to content

Commit 3e423ba

Browse files
feat(cli): runtime that builds every command from the contract
Turns the CLI contract into working commands. 43 leaves across 7 groups, up from the 6 hand-written ones — every v2 operation the contract does not hide is now reachable, including `sim tables upsert`, `sim workflows run`, and the whole tables surface. ## What the generator now emits `V2_OPERATIONS` carries a field→slot map per operation: each query/body field's kind, whether it is required, its enum values, and its server-side default. Types alone could not drive this — the runtime has to *iterate* fields to build flags, and everything from argv arrives as a string, so it needs the kind to turn "50" into 50 and '{"a":1}' into an object. It also lifts each operation's one-line `summary` from the OpenAPI specs. The contracts carry validation, not prose, so `--help` had been showing raw URLs; the specs already hold a written summary per operation and `check:openapi` guarantees one exists, so this reuses documentation rather than inventing a second place to describe the same endpoint. ## The runtime `derive.ts` names a command `<resource> [sub-resource] <verb>` from the route, covering 41 of 47. `request.ts` assembles the call: path params from positional args, `workspaceId` injected from the profile into whichever slot declares it, everything else coerced and validated locally — so a bad enum, malformed JSON, missing required flag, or absent workspace fails before any network call. `build.ts` constructs the commander tree, auto-pages cursor lists up to `--limit` (0 for everything), and renders through the contract's columns or, for runtime-shaped rows, keys unioned across the page. Fixed while wiring: `new Command('upsert <tableId>')` makes the *whole string* the command name, so `sim tables upsert` never matched and fell through to the group's help. Arguments have to be declared with `.argument()`. ## What stays hand-written Two leaves, each for a reason generation cannot satisfy in principle: `files download` streams binary rather than the JSON envelope, and `tables rows list` discovers columns from user-defined row data nested under `data`. They attach onto the generated groups, so `sim files --help` lists them alongside the rest. The five previous command files are deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj
1 parent e34372b commit 3e423ba

13 files changed

Lines changed: 1264 additions & 818 deletions

File tree

packages/sim-cli/src/commands/files.ts

Lines changed: 0 additions & 122 deletions
This file was deleted.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { once } from 'node:events'
2+
import { createWriteStream, type WriteStream } from 'node:fs'
3+
import { basename } from 'node:path'
4+
import chalk from 'chalk'
5+
import type { Command } from 'commander'
6+
import { clientFrom } from '../context.js'
7+
import type { QueryRowsResponse } from '../generated/v2-api.js'
8+
import { SimApiError } from '../http/client.js'
9+
import { type Column, printList, text } from '../output/render.js'
10+
11+
/**
12+
* Commands the generated runtime cannot produce.
13+
*
14+
* Kept deliberately small — each entry needs a reason that generation could not
15+
* satisfy even in principle, not merely "not migrated yet". They attach onto the
16+
* groups the runtime already built, so `sim files --help` lists them alongside
17+
* the generated leaves rather than in a second group.
18+
*/
19+
20+
type Row = QueryRowsResponse['data'][number]
21+
22+
/**
23+
* Streams a fetch body to disk, honouring backpressure.
24+
*
25+
* An explicit reader loop rather than `Readable.fromWeb`: the DOM
26+
* `ReadableStream` that `fetch` returns and the one `node:stream/web` declares
27+
* are structurally incompatible under this TS config, and bridging them needs a
28+
* cast that would erase exactly the typing this keeps honest.
29+
*/
30+
async function streamToFile(body: ReadableStream<Uint8Array>, file: WriteStream): Promise<void> {
31+
const reader = body.getReader()
32+
try {
33+
while (true) {
34+
const { done, value } = await reader.read()
35+
if (done) break
36+
// `write` returning false means the buffer is full; waiting for `drain` is
37+
// what stops a large file being buffered entirely in memory.
38+
if (!file.write(value)) await once(file, 'drain')
39+
}
40+
} finally {
41+
reader.releaseLock()
42+
}
43+
44+
await new Promise<void>((resolve, reject) => {
45+
file.once('error', reject)
46+
file.end(resolve)
47+
})
48+
}
49+
50+
/**
51+
* Row `data` is name-keyed and user-defined, so columns exist only at runtime.
52+
* Keys are unioned across the page rather than read off the first row — a
53+
* sparse row would otherwise hide every column it happens to omit.
54+
*/
55+
function rowColumns(rows: Row[]): Column<Row>[] {
56+
const keys: string[] = []
57+
const seen = new Set<string>()
58+
for (const row of rows) {
59+
for (const key of Object.keys(row.data)) {
60+
if (seen.has(key)) continue
61+
seen.add(key)
62+
keys.push(key)
63+
}
64+
}
65+
66+
return [
67+
{ header: 'id', value: (row) => row.id },
68+
...keys.map((key) => ({
69+
header: key,
70+
value: (row: Row) => {
71+
const value = row.data[key]
72+
if (value === null || value === undefined) return text(null)
73+
return typeof value === 'object' ? JSON.stringify(value) : String(value)
74+
},
75+
})),
76+
]
77+
}
78+
79+
function group(program: Command, name: string): Command {
80+
const existing = program.commands.find((command) => command.name() === name)
81+
if (existing) return existing
82+
const created = program.command(name)
83+
return created
84+
}
85+
86+
export function attachHandWritten(program: Command): void {
87+
// ── files download ── the response is binary, not the JSON envelope ────────
88+
group(program, 'files')
89+
.command('download <fileId>')
90+
.description('Download a file')
91+
.option('-o, --output-file <path>', 'Where to write it (defaults to the file name)')
92+
.action(async (fileId: string, options: { outputFile?: string }, command: Command) => {
93+
const { client, profile } = clientFrom(command)
94+
const workspaceId = client.requireWorkspace()
95+
96+
if (!profile.apiKey) {
97+
throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0)
98+
}
99+
100+
const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`)
101+
url.searchParams.set('workspaceId', workspaceId)
102+
103+
const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } })
104+
if (!response.ok || !response.body) {
105+
const raw = await response.text().catch(() => '')
106+
throw new SimApiError(
107+
raw || `Download failed with status ${response.status}`,
108+
response.status
109+
)
110+
}
111+
112+
const target =
113+
options.outputFile ??
114+
basename(
115+
/filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ??
116+
fileId
117+
)
118+
119+
await streamToFile(response.body, createWriteStream(target))
120+
console.log(chalk.green(`✓ Saved ${target}`))
121+
})
122+
123+
// ── tables rows list ── columns come from user-defined row data ───────────
124+
const tables = group(program, 'tables')
125+
const rows =
126+
tables.commands.find((command) => command.name() === 'rows') ?? tables.command('rows')
127+
rows
128+
.command('list <tableId>')
129+
.description('List rows, with columns discovered from the data')
130+
.option('--limit <n>', 'Maximum rows to return (0 for everything)', '100')
131+
.action(async (tableId: string, options: { limit: string }, command: Command) => {
132+
const { client, profile } = clientFrom(command)
133+
const parsed = Number.parseInt(options.limit, 10)
134+
if (Number.isNaN(parsed) || parsed < 0) {
135+
throw new SimApiError('--limit must be a non-negative number', 0)
136+
}
137+
const limit = parsed === 0 ? Number.POSITIVE_INFINITY : parsed
138+
139+
const collected: Row[] = []
140+
let cursor: string | null = null
141+
do {
142+
const page = (await client.request(`/api/v2/tables/${encodeURIComponent(tableId)}/rows`, {
143+
query: { workspaceId: client.requireWorkspace(), cursor },
144+
})) as QueryRowsResponse
145+
collected.push(...page.data)
146+
cursor = page.nextCursor
147+
} while (cursor && collected.length < limit)
148+
149+
const page = Number.isFinite(limit) ? collected.slice(0, limit) : collected
150+
printList(profile.output, page, rowColumns(page))
151+
})
152+
}

0 commit comments

Comments
 (0)