Skip to content

Commit 676bd83

Browse files
fix(cli): stop dropping nested fields, and emit exports as documents
`sim workflows export <id>` printed `version` and `exportedAt` and nothing else. The record builder kept only scalar fields, so `workflow` and `state` — the entire export — were discarded with nothing to say they had been. Same for `workflows get`, which silently dropped `variables` and `inputs`. Record views now render every field. Nested values serialize to one line and are cut at 160 chars: visibly partial beats silently absent, and json/yaml output still prints them whole. Export is a document, not a record — it exists to be redirected to a file and fed back to `import`, and table/text flatten and truncate, so neither can round-trip it. `document: true` in the contract makes those formats fall back to JSON; yaml is honoured because it round-trips. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU
1 parent 0a77c75 commit 676bd83

5 files changed

Lines changed: 107 additions & 5 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,14 @@ export const CLI_CONTRACT: CliContract = {
200200
],
201201
},
202202

203+
// ─── Documents, not records ───────────────────────────────────────────────
204+
// The payload is the artifact: `sim workflows export <id> > wf.json` has to
205+
// produce something `sim workflows import` accepts back.
206+
exportWorkflow: {
207+
describe: 'Print a workflow as a portable JSON document',
208+
document: true,
209+
},
210+
203211
// ─── Execution ────────────────────────────────────────────────────────────
204212
// The derived names land badly here: `/execute` and `/cancel` are verbs in
205213
// the path, but neither is in the action list, so POST would derive

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ export interface CommandSpec {
8484
* the point is that the caller can tell whether they meant it.
8585
*/
8686
confirm?: string
87+
/**
88+
* The response IS a document, not a record to look at.
89+
*
90+
* `workflows export` exists to be redirected into a file and fed back to
91+
* `import`, so a key/value view of it is wrong at any fidelity — the useful
92+
* artifact is the payload itself. Document commands emit raw JSON (or YAML
93+
* when the profile says so) whatever the profile's display format is.
94+
*/
95+
document?: boolean
8796
/** Keep the operation out of the CLI surface entirely. */
8897
hidden?: boolean
8998
}

packages/sim-cli/src/output/render.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,21 @@ export function printList<T>(format: OutputFormat, rows: T[], columns: Column<T>
201201
console.log(renderTable(rows, columns))
202202
}
203203

204+
/**
205+
* Prints a payload whose value IS the deliverable — `workflows export`, which
206+
* is meant to be redirected to a file and fed back to `import`.
207+
*
208+
* `table` and `text` are display formats: they flatten, truncate and colour, so
209+
* neither can round-trip a document. Rather than emit something that looks like
210+
* an export but cannot be re-imported, those two fall back to JSON. Only `yaml`
211+
* is honoured, because it round-trips.
212+
*/
213+
export function printDocument(format: OutputFormat, raw: unknown): void {
214+
console.log(
215+
format === 'yaml' ? (renderMachine('yaml', raw) as string) : JSON.stringify(raw, null, 2)
216+
)
217+
}
218+
204219
/** Prints a single record: machine formats from the raw value, otherwise aligned lines. */
205220
export function printRecord(format: OutputFormat, fields: Array<[string, string]>, raw: unknown) {
206221
const machine = renderMachine(format, raw)

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,49 @@ describe('single-resource rendering', () => {
153153
expect(printed.join('\n')).toMatch(/Deepwiki/)
154154
})
155155

156+
it('renders nested fields instead of dropping them', async () => {
157+
// `workflows export` printed `version` and `exportedAt` and nothing else:
158+
// the record builder kept only scalars, so `workflow` and `state` — the
159+
// entire export — vanished with no indication anything was missing.
160+
const printed = await lines(
161+
['workflows', 'get', 'wf_1'],
162+
{ id: 'wf_1', name: 'Onboarding', inputs: [{ name: 'email', type: 'string' }] },
163+
'text'
164+
)
165+
166+
expect(printed.join('\n')).toMatch(/inputs/)
167+
expect(printed.join('\n')).toMatch(/email/)
168+
})
169+
170+
it('truncates a nested value rather than flooding the terminal', async () => {
171+
const printed = await lines(
172+
['workflows', 'get', 'wf_1'],
173+
{ id: 'wf_1', state: { blocks: 'x'.repeat(5000) } },
174+
'text'
175+
)
176+
177+
const stateLine = printed.find((line) => line.startsWith('state')) ?? ''
178+
expect(stateLine.length).toBeLessThan(300)
179+
expect(stateLine).toMatch(/$/)
180+
})
181+
182+
it('emits a document command as JSON whatever the display format is', async () => {
183+
// Redirecting this to a file has to yield something `import` accepts, so
184+
// `table`/`text` — which flatten and truncate — must not be honoured here.
185+
const printed = await lines(
186+
['workflows', 'export', 'wf_1'],
187+
{ version: '1.0', exportedAt: 'now', workflow: { id: 'wf_1' }, state: { blocks: {} } },
188+
'text'
189+
)
190+
191+
expect(JSON.parse(printed.join('\n'))).toEqual({
192+
version: '1.0',
193+
exportedAt: 'now',
194+
workflow: { id: 'wf_1' },
195+
state: { blocks: {} },
196+
})
197+
})
198+
156199
it('leaves a payload with sibling keys intact', async () => {
157200
// `upsertTableRow` returns `{ row, operation }` — two real fields, not an
158201
// envelope. Unwrapping there would drop whether it inserted or updated.

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
bytes,
99
type Column,
1010
duration,
11+
printDocument,
1112
printList,
1213
printRecord,
1314
sanitize,
@@ -56,6 +57,25 @@ function renderCell(value: unknown, format: ColumnSpec['format']): string {
5657
}
5758
}
5859

60+
/**
61+
* How wide a nested value may get before a record line stops being readable.
62+
* A workflow's `state` serializes to tens of kilobytes on one line.
63+
*/
64+
const NESTED_CELL_WIDTH = 160
65+
66+
/**
67+
* A field in a record view.
68+
*
69+
* Nested values are rendered, not skipped: a record that quietly omits half of
70+
* what the server sent is worse than a long line, because nothing tells the
71+
* caller anything is missing. Long ones are cut with an ellipsis — visibly
72+
* partial, and `sim configure --set-output json` prints them whole.
73+
*/
74+
function recordCell(value: unknown): string {
75+
const rendered = renderCell(value, 'auto')
76+
return rendered.length > NESTED_CELL_WIDTH ? `${rendered.slice(0, NESTED_CELL_WIDTH)}…` : rendered
77+
}
78+
5979
function columnsFrom(specs: ColumnSpec[]): Column<unknown>[] {
6080
return specs.map((spec) => ({
6181
header: spec.header,
@@ -282,7 +302,14 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri
282302
query: request.query,
283303
body: request.body,
284304
})
285-
const data = unwrapResource(result?.data ?? result)
305+
const raw = result?.data ?? result
306+
307+
if (spec.document) {
308+
printDocument(profile.output, raw)
309+
return
310+
}
311+
312+
const data = unwrapResource(raw)
286313

287314
if (Array.isArray(data)) {
288315
// Reached when a non-paginated operation answers with a collection.
@@ -291,11 +318,11 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri
291318
return
292319
}
293320

321+
// Every field, nested ones included. Filtering to scalars here is what made
322+
// `workflows export` print its two timestamps and drop the actual workflow.
294323
const fields: Array<[string, string]> =
295-
data && typeof data === 'object' && !Array.isArray(data)
296-
? Object.entries(data)
297-
.filter(([, value]) => value === null || typeof value !== 'object')
298-
.map(([key, value]) => [key, renderCell(value, 'auto')])
324+
data && typeof data === 'object'
325+
? Object.entries(data).map(([key, value]) => [key, recordCell(value)])
299326
: []
300327

301328
printRecord(profile.output, fields, data)

0 commit comments

Comments
 (0)