Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ describe('createDoctorCommand wiring', () => {
expect(rejection).toMatchObject({
code: 'VALIDATION_ERROR',
exitCode: 5,
nextAction: 'Flag `--output` is invalid: must be one of: json, text.',
nextAction: 'Flag `--output` is invalid: must be one of: json, text, csv, ndjson.',
});
});

Expand Down
168 changes: 168 additions & 0 deletions src/commands/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1423,3 +1423,171 @@ describe('dogfood 2026-06-30 — whitespace-only --name is rejected (parity with
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});
});

// ---------------------------------------------------------------------------
// issue #164 — `project list --output csv|ndjson`
// ---------------------------------------------------------------------------

describe('project list --output csv', () => {
it('renders an RFC 4180 header + one row per project, quoting fields with commas/quotes/newlines', async () => {
const { credentialsPath } = makeCreds();
const quirky: CliProject = {
...PROJECT_FIXTURE,
id: 'project_quirky',
name: 'Say "hi", then\nnewline',
};
const fetchImpl = makeFetch(() => ({
body: { items: [PROJECT_FIXTURE, quirky], nextToken: null },
}));

const out: string[] = [];
const err: string[] = [];
await runList(
{ profile: 'default', output: 'csv', debug: false },
{ credentialsPath, fetchImpl, stdout: line => out.push(line), stderr: line => err.push(line) },
);

expect(out).toHaveLength(1);
const lines = out[0]!.split('\r\n');
expect(lines[0]).toBe('id,name,type,createdFrom,createdAt,updatedAt');
expect(lines[1]).toBe(
`${PROJECT_FIXTURE.id},${PROJECT_FIXTURE.name},${PROJECT_FIXTURE.type},${PROJECT_FIXTURE.createdFrom},${PROJECT_FIXTURE.createdAt},${PROJECT_FIXTURE.updatedAt}`,
);
// Embedded comma, double quote, and (raw) newline all force RFC 4180
// quoting; the embedded `"` is doubled per §2 rule 7. The record is one
// CSV row even though it contains a literal `\n` — only `\r\n` is a row
// separator, so `.split('\r\n')` does not break it apart.
expect(lines).toHaveLength(3);
expect(lines[2]).toBe(
'project_quirky,"Say ""hi"", then\nnewline",frontend,portal,2026-04-15T10:23:00.000Z,2026-05-05T08:12:00.000Z',
);
expect(err).toEqual([]);
});

it('emits the header row only (no data rows) for an empty page', async () => {
const { credentialsPath } = makeCreds();
const fetchImpl = makeFetch(() => ({ body: { items: [], nextToken: null } }));

const out: string[] = [];
await runList(
{ profile: 'default', output: 'csv', debug: false },
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
);

expect(out).toEqual(['id,name,type,createdFrom,createdAt,updatedAt']);
});

it('routes the pagination cursor to stderr, keeping stdout pure CSV', async () => {
const { credentialsPath } = makeCreds();
const fetchImpl = makeFetch(() => ({
body: { items: [PROJECT_FIXTURE], nextToken: 'opaque-cursor-1' },
}));

const out: string[] = [];
const err: string[] = [];
await runList(
{ profile: 'default', output: 'csv', debug: false, pageSize: 1 },
{ credentialsPath, fetchImpl, stdout: line => out.push(line), stderr: line => err.push(line) },
);

expect(out).toHaveLength(1);
expect(out[0]).not.toContain('nextToken');
expect(err).toEqual(['nextToken: opaque-cursor-1']);
});
});

describe('project list --output ndjson', () => {
it('emits one compact JSON object per line, no wrapping array', async () => {
const { credentialsPath } = makeCreds();
const second = { ...PROJECT_FIXTURE, id: 'project_2' };
const fetchImpl = makeFetch(() => ({ body: { items: [PROJECT_FIXTURE, second], nextToken: null } }));

const out: string[] = [];
await runList(
{ profile: 'default', output: 'ndjson', debug: false },
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
);

expect(out).toHaveLength(1);
const lines = out[0]!.split('\n');
expect(lines).toHaveLength(2);
expect(JSON.parse(lines[0]!)).toEqual(PROJECT_FIXTURE);
expect(JSON.parse(lines[1]!)).toEqual(second);
// Compact — no pretty-printed indentation.
expect(lines[0]).not.toContain('\n ');
});

it('writes nothing to stdout for an empty page (no stray blank line)', async () => {
const { credentialsPath } = makeCreds();
const fetchImpl = makeFetch(() => ({ body: { items: [], nextToken: null } }));

const out: string[] = [];
await runList(
{ profile: 'default', output: 'ndjson', debug: false },
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
);

expect(out).toEqual([]);
});

it('routes the pagination cursor to stderr as JSON, keeping stdout pure NDJSON', async () => {
const { credentialsPath } = makeCreds();
const fetchImpl = makeFetch(() => ({
body: { items: [PROJECT_FIXTURE], nextToken: 'opaque-cursor-1' },
}));

const out: string[] = [];
const err: string[] = [];
await runList(
{ profile: 'default', output: 'ndjson', debug: false, pageSize: 1 },
{ credentialsPath, fetchImpl, stdout: line => out.push(line), stderr: line => err.push(line) },
);

expect(out).toHaveLength(1);
expect(err).toEqual([JSON.stringify({ nextToken: 'opaque-cursor-1' })]);
});
});

describe('issue #164 — single-object commands reject --output csv|ndjson', () => {
it('runGet exits 5 (VALIDATION_ERROR) for --output csv', async () => {
const { credentialsPath } = makeCreds();
const fetchImpl = makeFetch(() => ({ body: PROJECT_FIXTURE }));

await expect(
runGet(
{ profile: 'default', output: 'csv', debug: false, projectId: 'p1' },
{ credentialsPath, fetchImpl, stdout: () => {} },
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});

it('runGet exits 5 (VALIDATION_ERROR) for --output ndjson', async () => {
const { credentialsPath } = makeCreds();
const fetchImpl = makeFetch(() => ({ body: PROJECT_FIXTURE }));

await expect(
runGet(
{ profile: 'default', output: 'ndjson', debug: false, projectId: 'p1' },
{ credentialsPath, fetchImpl, stdout: () => {} },
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});

it('runCreate exits 5 (VALIDATION_ERROR) for --output csv', async () => {
const { credentialsPath } = makeCreds();
const fetchImpl = makeFetch(() => ({ body: PROJECT_FIXTURE }));

await expect(
runCreate(
{
profile: 'default',
output: 'csv',
debug: false,
type: 'backend',
name: 'Checkout',
},
{ credentialsPath, fetchImpl, stdout: () => {}, stderr: () => {} },
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});
});
43 changes: 42 additions & 1 deletion src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import {
import { ApiError } from '../lib/errors.js';
import type { FetchImpl } from '../lib/http.js';
import type { HttpClient } from '../lib/http.js';
import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js';
import {
GLOBAL_OPTS_HINT,
Output,
resolveOutputMode,
type ListColumn,
type OutputMode,
} from '../lib/output.js';
import { assertNotLocal } from '../lib/target-url.js';
import { renderTextTable, resolveTextColumns, type TextTableColumn } from '../lib/text-table.js';
import { assertIdempotencyKey } from '../lib/validate.js';
Expand Down Expand Up @@ -88,6 +94,27 @@ export async function runList(
);
}

// csv/ndjson: routed here (before `out.print`, which rejects both modes
// for every non-list command) so the row data goes to stdout and the
// pagination cursor goes to stderr — keeping stdout a pure, parseable
// table/stream per M-hackathon-164.
if (opts.output === 'csv' || opts.output === 'ndjson') {
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
if (opts.output === 'csv') {
out.printCsv(page.items, PROJECT_CSV_COLUMNS);
} else {
out.printNdjson(page.items);
}
if (page.nextToken) {
stderr(
opts.output === 'csv'
? `nextToken: ${page.nextToken}`
: JSON.stringify({ nextToken: page.nextToken }),
);
}
return page;
}

out.print(page, data => {
const p = data as Page<CliProject>;
return renderProjectListText(p, { columns: opts.columns, noHeader: opts.noHeader });
Expand Down Expand Up @@ -1029,6 +1056,20 @@ const PROJECT_LIST_COLUMNS: ReadonlyArray<TextTableColumn<CliProject>> = [
{ header: 'CREATED', width: 0, render: project => project.createdAt },
];

/**
* `--output csv`/`--output ndjson` columns for `project list`, derived
* straight from the `CliProject` source-of-truth shape (not the
* possibly-reordered/subset `PROJECT_LIST_COLUMNS` used for `--output text`).
*/
const PROJECT_CSV_COLUMNS: ReadonlyArray<ListColumn<CliProject>> = [
{ header: 'id', value: p => p.id },
{ header: 'name', value: p => p.name },
{ header: 'type', value: p => p.type },
{ header: 'createdFrom', value: p => p.createdFrom },
{ header: 'createdAt', value: p => p.createdAt },
{ header: 'updatedAt', value: p => p.updatedAt },
];

function renderProjectListText(
page: Page<CliProject>,
options: { columns?: string; noHeader?: boolean } = {},
Expand Down
98 changes: 96 additions & 2 deletions src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ import {
import { REQUEST_TIMEOUT_DEFAULT_MS, REQUEST_TIMEOUT_MAX_MS } from '../lib/http.js';
import type { FetchImpl } from '../lib/http.js';
import type { HttpClient } from '../lib/http.js';
import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js';
import {
GLOBAL_OPTS_HINT,
Output,
resolveOutputMode,
type ListColumn,
type OutputMode,
} from '../lib/output.js';
import {
fetchSinglePage,
paginate,
Expand Down Expand Up @@ -604,6 +610,26 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise<P
);
}

// csv/ndjson: handled before `out.print` (which rejects both modes for
// every non-list command) — row data to stdout, pagination cursor to
// stderr so stdout stays a pure, parseable table/stream (M-hackathon-164).
if (opts.output === 'csv' || opts.output === 'ndjson') {
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
if (opts.output === 'csv') {
out.printCsv(page.items, TEST_CSV_COLUMNS);
} else {
out.printNdjson(page.items);
}
if (page.nextToken) {
stderr(
opts.output === 'csv'
? `nextToken: ${page.nextToken}`
: JSON.stringify({ nextToken: page.nextToken }),
);
}
return page;
}

out.print(page, data =>
renderTestListText(data as Page<CliTest>, { columns: opts.columns, noHeader: opts.noHeader }),
);
Expand Down Expand Up @@ -4620,11 +4646,34 @@ export async function runResultHistory(
since: sinceIso,
});

if (opts.output !== 'text') {
if (opts.output === 'json') {
out.print({ runs: resp.runs, nextCursor: resp.nextCursor }, data => JSON.stringify(data));
return resp;
}

// csv/ndjson: handled before `out.print` (which rejects both modes for
// every non-list command) — run rows go to stdout, `nextCursor` and the
// `meta` block go to stderr so stdout stays a pure, parseable table/stream
// (M-hackathon-164).
if (opts.output === 'csv' || opts.output === 'ndjson') {
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
if (opts.output === 'csv') {
out.printCsv(resp.runs, RUN_HISTORY_CSV_COLUMNS);
} else {
out.printNdjson(resp.runs);
}
if (resp.nextCursor !== null) {
stderr(
opts.output === 'csv'
? `nextCursor: ${resp.nextCursor}`
: JSON.stringify({ nextCursor: resp.nextCursor }),
);
}
if (resp.meta.note) stderr(`note: ${resp.meta.note}`);
if (resp.meta.portalUrl) stderr(`portal: ${resp.meta.portalUrl}`);
return resp;
}

// Text mode rendering
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));

Expand Down Expand Up @@ -4697,6 +4746,27 @@ const RUN_HISTORY_TABLE_COLUMNS: ReadonlyArray<TextTableColumn<RunHistoryItem>>
},
];

/**
* `--output csv`/`--output ndjson` columns for `test result --history`,
* derived straight from the `RunHistoryItem` source-of-truth shape (not the
* text-table-only `RUN_HISTORY_TABLE_COLUMNS`, which drops several fields
* and derives `RERUN?`/`DURATION` for display).
*/
const RUN_HISTORY_CSV_COLUMNS: ReadonlyArray<ListColumn<RunHistoryItem>> = [
{ header: 'runId', value: run => run.runId },
{ header: 'status', value: run => run.status },
{ header: 'source', value: run => run.source },
{ header: 'isRerun', value: run => run.isRerun },
{ header: 'createdFrom', value: run => run.createdFrom },
{ header: 'createdAt', value: run => run.createdAt },
{ header: 'startedAt', value: run => run.startedAt },
{ header: 'finishedAt', value: run => run.finishedAt },
{ header: 'codeVersion', value: run => run.codeVersion },
{ header: 'failureKind', value: run => run.failureKind },
{ header: 'targetUrl', value: run => run.targetUrl },
{ header: 'targetUrlSource', value: run => run.targetUrlSource },
];

/**
* Max width of the `test steps` DESCRIPTION column in text mode. Long /
* multi-line step descriptions are collapsed to one line and truncated to
Expand Down Expand Up @@ -10170,6 +10240,30 @@ const TEST_LIST_COLUMNS: ReadonlyArray<TextTableColumn<CliTest>> = [
{ header: 'UPDATED', width: 0, render: test => test.updatedAt },
];

/**
* `--output csv`/`--output ndjson` columns for `test list`, derived straight
* from the `CliTest` source-of-truth shape (not the possibly-reordered/subset
* `TEST_LIST_COLUMNS` used for `--output text`). `produces`/`consumes` are
* comma-joined; `renderCsv`'s escaping quotes the cell automatically since a
* joined list contains commas.
*/
const TEST_CSV_COLUMNS: ReadonlyArray<ListColumn<CliTest>> = [
{ header: 'id', value: t => t.id },
{ header: 'projectId', value: t => t.projectId },
{ header: 'projectName', value: t => t.projectName },
{ header: 'name', value: t => t.name },
{ header: 'type', value: t => t.type },
{ header: 'createdFrom', value: t => t.createdFrom },
{ header: 'status', value: t => t.status },
{ header: 'createdAt', value: t => t.createdAt },
{ header: 'updatedAt', value: t => t.updatedAt },
{ header: 'planStepCount', value: t => t.planStepCount },
{ header: 'priority', value: t => t.priority },
{ header: 'produces', value: t => t.produces?.join(',') },
{ header: 'consumes', value: t => t.consumes?.join(',') },
{ header: 'category', value: t => t.category },
];

function renderTestListText(
page: Page<CliTest>,
options: { columns?: string; noHeader?: boolean } = {},
Expand Down
Loading
Loading