Skip to content

Commit cb8b8f5

Browse files
committed
feat(output): add csv and ndjson output modes for list commands
Extends --output to accept csv/ndjson in addition to json/text, for project list, test list, and test result --history. Enables piping CLI output directly into spreadsheets/log pipelines without custom parsing.
1 parent 26821e1 commit cb8b8f5

8 files changed

Lines changed: 423 additions & 13 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/doctor.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ describe('createDoctorCommand wiring', () => {
289289
expect(rejection).toMatchObject({
290290
code: 'VALIDATION_ERROR',
291291
exitCode: 5,
292-
nextAction: 'Flag `--output` is invalid: must be one of: json, text.',
292+
nextAction: 'Flag `--output` is invalid: must be one of: json, text, csv, ndjson.',
293293
});
294294
});
295295

src/commands/project.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,3 +1423,171 @@ describe('dogfood 2026-06-30 — whitespace-only --name is rejected (parity with
14231423
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
14241424
});
14251425
});
1426+
1427+
// ---------------------------------------------------------------------------
1428+
// issue #164 — `project list --output csv|ndjson`
1429+
// ---------------------------------------------------------------------------
1430+
1431+
describe('project list --output csv', () => {
1432+
it('renders an RFC 4180 header + one row per project, quoting fields with commas/quotes/newlines', async () => {
1433+
const { credentialsPath } = makeCreds();
1434+
const quirky: CliProject = {
1435+
...PROJECT_FIXTURE,
1436+
id: 'project_quirky',
1437+
name: 'Say "hi", then\nnewline',
1438+
};
1439+
const fetchImpl = makeFetch(() => ({
1440+
body: { items: [PROJECT_FIXTURE, quirky], nextToken: null },
1441+
}));
1442+
1443+
const out: string[] = [];
1444+
const err: string[] = [];
1445+
await runList(
1446+
{ profile: 'default', output: 'csv', debug: false },
1447+
{ credentialsPath, fetchImpl, stdout: line => out.push(line), stderr: line => err.push(line) },
1448+
);
1449+
1450+
expect(out).toHaveLength(1);
1451+
const lines = out[0]!.split('\r\n');
1452+
expect(lines[0]).toBe('id,name,type,createdFrom,createdAt,updatedAt');
1453+
expect(lines[1]).toBe(
1454+
`${PROJECT_FIXTURE.id},${PROJECT_FIXTURE.name},${PROJECT_FIXTURE.type},${PROJECT_FIXTURE.createdFrom},${PROJECT_FIXTURE.createdAt},${PROJECT_FIXTURE.updatedAt}`,
1455+
);
1456+
// Embedded comma, double quote, and (raw) newline all force RFC 4180
1457+
// quoting; the embedded `"` is doubled per §2 rule 7. The record is one
1458+
// CSV row even though it contains a literal `\n` — only `\r\n` is a row
1459+
// separator, so `.split('\r\n')` does not break it apart.
1460+
expect(lines).toHaveLength(3);
1461+
expect(lines[2]).toBe(
1462+
'project_quirky,"Say ""hi"", then\nnewline",frontend,portal,2026-04-15T10:23:00.000Z,2026-05-05T08:12:00.000Z',
1463+
);
1464+
expect(err).toEqual([]);
1465+
});
1466+
1467+
it('emits the header row only (no data rows) for an empty page', async () => {
1468+
const { credentialsPath } = makeCreds();
1469+
const fetchImpl = makeFetch(() => ({ body: { items: [], nextToken: null } }));
1470+
1471+
const out: string[] = [];
1472+
await runList(
1473+
{ profile: 'default', output: 'csv', debug: false },
1474+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
1475+
);
1476+
1477+
expect(out).toEqual(['id,name,type,createdFrom,createdAt,updatedAt']);
1478+
});
1479+
1480+
it('routes the pagination cursor to stderr, keeping stdout pure CSV', async () => {
1481+
const { credentialsPath } = makeCreds();
1482+
const fetchImpl = makeFetch(() => ({
1483+
body: { items: [PROJECT_FIXTURE], nextToken: 'opaque-cursor-1' },
1484+
}));
1485+
1486+
const out: string[] = [];
1487+
const err: string[] = [];
1488+
await runList(
1489+
{ profile: 'default', output: 'csv', debug: false, pageSize: 1 },
1490+
{ credentialsPath, fetchImpl, stdout: line => out.push(line), stderr: line => err.push(line) },
1491+
);
1492+
1493+
expect(out).toHaveLength(1);
1494+
expect(out[0]).not.toContain('nextToken');
1495+
expect(err).toEqual(['nextToken: opaque-cursor-1']);
1496+
});
1497+
});
1498+
1499+
describe('project list --output ndjson', () => {
1500+
it('emits one compact JSON object per line, no wrapping array', async () => {
1501+
const { credentialsPath } = makeCreds();
1502+
const second = { ...PROJECT_FIXTURE, id: 'project_2' };
1503+
const fetchImpl = makeFetch(() => ({ body: { items: [PROJECT_FIXTURE, second], nextToken: null } }));
1504+
1505+
const out: string[] = [];
1506+
await runList(
1507+
{ profile: 'default', output: 'ndjson', debug: false },
1508+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
1509+
);
1510+
1511+
expect(out).toHaveLength(1);
1512+
const lines = out[0]!.split('\n');
1513+
expect(lines).toHaveLength(2);
1514+
expect(JSON.parse(lines[0]!)).toEqual(PROJECT_FIXTURE);
1515+
expect(JSON.parse(lines[1]!)).toEqual(second);
1516+
// Compact — no pretty-printed indentation.
1517+
expect(lines[0]).not.toContain('\n ');
1518+
});
1519+
1520+
it('writes nothing to stdout for an empty page (no stray blank line)', async () => {
1521+
const { credentialsPath } = makeCreds();
1522+
const fetchImpl = makeFetch(() => ({ body: { items: [], nextToken: null } }));
1523+
1524+
const out: string[] = [];
1525+
await runList(
1526+
{ profile: 'default', output: 'ndjson', debug: false },
1527+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
1528+
);
1529+
1530+
expect(out).toEqual([]);
1531+
});
1532+
1533+
it('routes the pagination cursor to stderr as JSON, keeping stdout pure NDJSON', async () => {
1534+
const { credentialsPath } = makeCreds();
1535+
const fetchImpl = makeFetch(() => ({
1536+
body: { items: [PROJECT_FIXTURE], nextToken: 'opaque-cursor-1' },
1537+
}));
1538+
1539+
const out: string[] = [];
1540+
const err: string[] = [];
1541+
await runList(
1542+
{ profile: 'default', output: 'ndjson', debug: false, pageSize: 1 },
1543+
{ credentialsPath, fetchImpl, stdout: line => out.push(line), stderr: line => err.push(line) },
1544+
);
1545+
1546+
expect(out).toHaveLength(1);
1547+
expect(err).toEqual([JSON.stringify({ nextToken: 'opaque-cursor-1' })]);
1548+
});
1549+
});
1550+
1551+
describe('issue #164 — single-object commands reject --output csv|ndjson', () => {
1552+
it('runGet exits 5 (VALIDATION_ERROR) for --output csv', async () => {
1553+
const { credentialsPath } = makeCreds();
1554+
const fetchImpl = makeFetch(() => ({ body: PROJECT_FIXTURE }));
1555+
1556+
await expect(
1557+
runGet(
1558+
{ profile: 'default', output: 'csv', debug: false, projectId: 'p1' },
1559+
{ credentialsPath, fetchImpl, stdout: () => {} },
1560+
),
1561+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
1562+
});
1563+
1564+
it('runGet exits 5 (VALIDATION_ERROR) for --output ndjson', async () => {
1565+
const { credentialsPath } = makeCreds();
1566+
const fetchImpl = makeFetch(() => ({ body: PROJECT_FIXTURE }));
1567+
1568+
await expect(
1569+
runGet(
1570+
{ profile: 'default', output: 'ndjson', debug: false, projectId: 'p1' },
1571+
{ credentialsPath, fetchImpl, stdout: () => {} },
1572+
),
1573+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
1574+
});
1575+
1576+
it('runCreate exits 5 (VALIDATION_ERROR) for --output csv', async () => {
1577+
const { credentialsPath } = makeCreds();
1578+
const fetchImpl = makeFetch(() => ({ body: PROJECT_FIXTURE }));
1579+
1580+
await expect(
1581+
runCreate(
1582+
{
1583+
profile: 'default',
1584+
output: 'csv',
1585+
debug: false,
1586+
type: 'backend',
1587+
name: 'Checkout',
1588+
},
1589+
{ credentialsPath, fetchImpl, stdout: () => {}, stderr: () => {} },
1590+
),
1591+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
1592+
});
1593+
});

src/commands/project.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import {
1010
import { ApiError } from '../lib/errors.js';
1111
import type { FetchImpl } from '../lib/http.js';
1212
import type { HttpClient } from '../lib/http.js';
13-
import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js';
13+
import {
14+
GLOBAL_OPTS_HINT,
15+
Output,
16+
resolveOutputMode,
17+
type ListColumn,
18+
type OutputMode,
19+
} from '../lib/output.js';
1420
import { assertNotLocal } from '../lib/target-url.js';
1521
import { renderTextTable, resolveTextColumns, type TextTableColumn } from '../lib/text-table.js';
1622
import { assertIdempotencyKey } from '../lib/validate.js';
@@ -88,6 +94,27 @@ export async function runList(
8894
);
8995
}
9096

97+
// csv/ndjson: routed here (before `out.print`, which rejects both modes
98+
// for every non-list command) so the row data goes to stdout and the
99+
// pagination cursor goes to stderr — keeping stdout a pure, parseable
100+
// table/stream per M-hackathon-164.
101+
if (opts.output === 'csv' || opts.output === 'ndjson') {
102+
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
103+
if (opts.output === 'csv') {
104+
out.printCsv(page.items, PROJECT_CSV_COLUMNS);
105+
} else {
106+
out.printNdjson(page.items);
107+
}
108+
if (page.nextToken) {
109+
stderr(
110+
opts.output === 'csv'
111+
? `nextToken: ${page.nextToken}`
112+
: JSON.stringify({ nextToken: page.nextToken }),
113+
);
114+
}
115+
return page;
116+
}
117+
91118
out.print(page, data => {
92119
const p = data as Page<CliProject>;
93120
return renderProjectListText(p, { columns: opts.columns, noHeader: opts.noHeader });
@@ -1029,6 +1056,20 @@ const PROJECT_LIST_COLUMNS: ReadonlyArray<TextTableColumn<CliProject>> = [
10291056
{ header: 'CREATED', width: 0, render: project => project.createdAt },
10301057
];
10311058

1059+
/**
1060+
* `--output csv`/`--output ndjson` columns for `project list`, derived
1061+
* straight from the `CliProject` source-of-truth shape (not the
1062+
* possibly-reordered/subset `PROJECT_LIST_COLUMNS` used for `--output text`).
1063+
*/
1064+
const PROJECT_CSV_COLUMNS: ReadonlyArray<ListColumn<CliProject>> = [
1065+
{ header: 'id', value: p => p.id },
1066+
{ header: 'name', value: p => p.name },
1067+
{ header: 'type', value: p => p.type },
1068+
{ header: 'createdFrom', value: p => p.createdFrom },
1069+
{ header: 'createdAt', value: p => p.createdAt },
1070+
{ header: 'updatedAt', value: p => p.updatedAt },
1071+
];
1072+
10321073
function renderProjectListText(
10331074
page: Page<CliProject>,
10341075
options: { columns?: string; noHeader?: boolean } = {},

src/commands/test.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,13 @@ import {
5454
import { REQUEST_TIMEOUT_DEFAULT_MS, REQUEST_TIMEOUT_MAX_MS } from '../lib/http.js';
5555
import type { FetchImpl } from '../lib/http.js';
5656
import type { HttpClient } from '../lib/http.js';
57-
import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js';
57+
import {
58+
GLOBAL_OPTS_HINT,
59+
Output,
60+
resolveOutputMode,
61+
type ListColumn,
62+
type OutputMode,
63+
} from '../lib/output.js';
5864
import {
5965
fetchSinglePage,
6066
paginate,
@@ -604,6 +610,26 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise<P
604610
);
605611
}
606612

613+
// csv/ndjson: handled before `out.print` (which rejects both modes for
614+
// every non-list command) — row data to stdout, pagination cursor to
615+
// stderr so stdout stays a pure, parseable table/stream (M-hackathon-164).
616+
if (opts.output === 'csv' || opts.output === 'ndjson') {
617+
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
618+
if (opts.output === 'csv') {
619+
out.printCsv(page.items, TEST_CSV_COLUMNS);
620+
} else {
621+
out.printNdjson(page.items);
622+
}
623+
if (page.nextToken) {
624+
stderr(
625+
opts.output === 'csv'
626+
? `nextToken: ${page.nextToken}`
627+
: JSON.stringify({ nextToken: page.nextToken }),
628+
);
629+
}
630+
return page;
631+
}
632+
607633
out.print(page, data =>
608634
renderTestListText(data as Page<CliTest>, { columns: opts.columns, noHeader: opts.noHeader }),
609635
);
@@ -4620,11 +4646,34 @@ export async function runResultHistory(
46204646
since: sinceIso,
46214647
});
46224648

4623-
if (opts.output !== 'text') {
4649+
if (opts.output === 'json') {
46244650
out.print({ runs: resp.runs, nextCursor: resp.nextCursor }, data => JSON.stringify(data));
46254651
return resp;
46264652
}
46274653

4654+
// csv/ndjson: handled before `out.print` (which rejects both modes for
4655+
// every non-list command) — run rows go to stdout, `nextCursor` and the
4656+
// `meta` block go to stderr so stdout stays a pure, parseable table/stream
4657+
// (M-hackathon-164).
4658+
if (opts.output === 'csv' || opts.output === 'ndjson') {
4659+
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
4660+
if (opts.output === 'csv') {
4661+
out.printCsv(resp.runs, RUN_HISTORY_CSV_COLUMNS);
4662+
} else {
4663+
out.printNdjson(resp.runs);
4664+
}
4665+
if (resp.nextCursor !== null) {
4666+
stderr(
4667+
opts.output === 'csv'
4668+
? `nextCursor: ${resp.nextCursor}`
4669+
: JSON.stringify({ nextCursor: resp.nextCursor }),
4670+
);
4671+
}
4672+
if (resp.meta.note) stderr(`note: ${resp.meta.note}`);
4673+
if (resp.meta.portalUrl) stderr(`portal: ${resp.meta.portalUrl}`);
4674+
return resp;
4675+
}
4676+
46284677
// Text mode rendering
46294678
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
46304679

@@ -4697,6 +4746,27 @@ const RUN_HISTORY_TABLE_COLUMNS: ReadonlyArray<TextTableColumn<RunHistoryItem>>
46974746
},
46984747
];
46994748

4749+
/**
4750+
* `--output csv`/`--output ndjson` columns for `test result --history`,
4751+
* derived straight from the `RunHistoryItem` source-of-truth shape (not the
4752+
* text-table-only `RUN_HISTORY_TABLE_COLUMNS`, which drops several fields
4753+
* and derives `RERUN?`/`DURATION` for display).
4754+
*/
4755+
const RUN_HISTORY_CSV_COLUMNS: ReadonlyArray<ListColumn<RunHistoryItem>> = [
4756+
{ header: 'runId', value: run => run.runId },
4757+
{ header: 'status', value: run => run.status },
4758+
{ header: 'source', value: run => run.source },
4759+
{ header: 'isRerun', value: run => run.isRerun },
4760+
{ header: 'createdFrom', value: run => run.createdFrom },
4761+
{ header: 'createdAt', value: run => run.createdAt },
4762+
{ header: 'startedAt', value: run => run.startedAt },
4763+
{ header: 'finishedAt', value: run => run.finishedAt },
4764+
{ header: 'codeVersion', value: run => run.codeVersion },
4765+
{ header: 'failureKind', value: run => run.failureKind },
4766+
{ header: 'targetUrl', value: run => run.targetUrl },
4767+
{ header: 'targetUrlSource', value: run => run.targetUrlSource },
4768+
];
4769+
47004770
/**
47014771
* Max width of the `test steps` DESCRIPTION column in text mode. Long /
47024772
* multi-line step descriptions are collapsed to one line and truncated to
@@ -10170,6 +10240,30 @@ const TEST_LIST_COLUMNS: ReadonlyArray<TextTableColumn<CliTest>> = [
1017010240
{ header: 'UPDATED', width: 0, render: test => test.updatedAt },
1017110241
];
1017210242

10243+
/**
10244+
* `--output csv`/`--output ndjson` columns for `test list`, derived straight
10245+
* from the `CliTest` source-of-truth shape (not the possibly-reordered/subset
10246+
* `TEST_LIST_COLUMNS` used for `--output text`). `produces`/`consumes` are
10247+
* comma-joined; `renderCsv`'s escaping quotes the cell automatically since a
10248+
* joined list contains commas.
10249+
*/
10250+
const TEST_CSV_COLUMNS: ReadonlyArray<ListColumn<CliTest>> = [
10251+
{ header: 'id', value: t => t.id },
10252+
{ header: 'projectId', value: t => t.projectId },
10253+
{ header: 'projectName', value: t => t.projectName },
10254+
{ header: 'name', value: t => t.name },
10255+
{ header: 'type', value: t => t.type },
10256+
{ header: 'createdFrom', value: t => t.createdFrom },
10257+
{ header: 'status', value: t => t.status },
10258+
{ header: 'createdAt', value: t => t.createdAt },
10259+
{ header: 'updatedAt', value: t => t.updatedAt },
10260+
{ header: 'planStepCount', value: t => t.planStepCount },
10261+
{ header: 'priority', value: t => t.priority },
10262+
{ header: 'produces', value: t => t.produces?.join(',') },
10263+
{ header: 'consumes', value: t => t.consumes?.join(',') },
10264+
{ header: 'category', value: t => t.category },
10265+
];
10266+
1017310267
function renderTestListText(
1017410268
page: Page<CliTest>,
1017510269
options: { columns?: string; noHeader?: boolean } = {},

0 commit comments

Comments
 (0)