mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(cli): add od templates subcommand for user-saved templates
Closes the UI/API/CLI triple for user-saved project templates: the
HTTP layer (POST/GET/DELETE /api/templates) and the New Project ->
Templates UI surface already existed, but external agents could not
list, snapshot, or remove templates without rendering the web UI.
Adds `od templates {help,list,save,delete}` plus the standard
--daemon-url / --json options. `save` takes <projectId> + --name
(and optional --description) and POSTs the same body the UI sends;
`list` and `delete` mirror GET / DELETE /api/templates.
New tests/cli-templates.test.ts pins the contract by spinning up a
tiny HTTP stub and exec-ing the real cli.ts entry, so the SUBCOMMAND
dispatch, flag parsing, missing-arg exit codes, and JSON output mode
are all covered end to end.
* fix(cli): surface clean daemon-unreachable error from `od templates` verbs (#2428 review follow-up)
Reviewer P2 from lefarcen on #2428: each `od templates` sub-verb
(`list`, `save`, `delete`) called `fetch()` without a try/catch, so
when the daemon wasn't running Node threw a raw
`TypeError: fetch failed` instead of the
`failed to reach daemon at <url>: <code>` shape every other CLI
surface emits via `surfaceFetchError`.
Wrap each `fetch` in the same pattern `runAutomation` /
`runResearch` / the project verbs already use:
let resp;
try {
resp = await fetch(...);
} catch (err) {
surfaceFetchError(err, base);
process.exit(3);
}
Adds three regression cases — one per sub-verb — pointing the CLI at
an unreachable port to assert exit code 3 plus the clean error string
and no raw `TypeError` leak.
* fix(cli): make od templates positional id flag-order tolerant + map 404/400 to precise error codes (#2428 review follow-up)
Reviewer (#2428 non-blocking, round 3) found two robustness issues:
1. `od templates save <projectId>` and `od templates delete <id>`
both required the positional id to be the first token after the
sub-verb. A headless caller that put shared options first —
`od templates save --daemon-url http://... proj-1 --name Cards`
— would hit the missing-id usage path before reaching the daemon.
Switch to a `positionalArgs(rest)` helper that mirrors
`runAutomation`, stepping past `--flag value` pairs for every
templates string flag so the id can appear before or after flags.
2. `structuredHttpFailure(resp)` with the default fallback maps any
non-OK templates response to `daemon-not-running`, but the
`/api/templates` route returns 404 for an unknown source project
and 400 for body validation — reachable user errors while the
daemon is up. Map 404 → `project-not-found` and 400 →
`missing-input` on save, and 404 → `template-not-found` on delete
(new code, exit 74, alongside `project-not-found`/`run-not-found`).
Agents reading the exit number alone can now branch correctly
without parsing the envelope.
Five new regression tests pin:
- positional id after `--daemon-url` for save and delete
- save → 404 surfaces `project-not-found` (exit 68)
- save → 400 surfaces `missing-input` (exit 67)
- delete → 404 surfaces `template-not-found` (exit 74)
- the existing 11 cases still pass under the new flag parsing
* test(cli): reserve-and-release a free port for unreachable-daemon checks (#2428 review follow-up)
The unreachable-daemon regressions previously pointed the CLI at stub.baseUrl's port + 1, but the OS only guaranteed the stub's ephemeral port itself was free — port + 1 could already be bound by another local process on a shared CI host. If that happened, the three cases exercised whatever server was on that port instead of the connection-refused path and could fail or pass for the wrong reason.
Switch to a reserve-and-release helper: open a probe HTTP server on port 0, capture the OS-assigned port, close the server, then point the CLI at that exact port. The connection fails ECONNREFUSED on every platform because nothing is listening on it now and the port was deliberately picked from the same ephemeral pool the kernel uses for free-port allocation.
* fix(cli): align `od templates delete` with the daemon's idempotent contract (#2428 review follow-up)
DELETE /api/templates/:id in the real daemon (project-routes.ts:729-731,
server.ts:6064-6066) calls deleteTemplate(...) unconditionally and
returns { ok: true } for unknown ids. The CLI's earlier 404 special
case mapped a status the production route never emits to a
template-not-found exit code that could not fire, and the stub-only
regression test documented behavior the shipped API does not expose —
exactly the CLI/API drift the dual-track exposure rule is meant to
avoid.
Drop the 404 branch, the RECOVERABLE_EXIT_CODES entry for
template-not-found, and the stub-only regression. Any unexpected
non-2xx still falls through to structuredHttpFailure(resp). Delete
stays documented as idempotent success until the daemon contract
itself changes in a follow-up.
Surfaced by Siri-Ray and mrcfps's matching inline reviews on cli.ts:5135.
* fix(cli): preserve daemon flat-string error messages in structuredHttpFailure (#2428 review follow-up)
POST /api/templates returns flat `{ error: '<message>' }` bodies for
400/404 (project-routes.ts:669, :675, :679), but structuredHttpFailure
expected the nested `{ error: { code, message } }` shape. With a flat
body, parsed.error.message was undefined and the structured envelope
collapsed to a `HTTP 400: ` message — losing the only diagnostic the
daemon actually returned to headless callers.
Normalize a string-shaped error.body into `{ message }` before the
recoverable-code lookup, so:
- The exit-code mapping stays the same (still falls through to the
fallback code at the call site).
- envelope.error.message now carries the daemon's literal text
(`source project not found`, `name required`) instead of an empty
HTTP placeholder.
The two save-error regressions are also realigned to stub the flat
payload the route actually emits and now assert on the message body,
so the CLI/daemon contract stays locked across both the structured
and the legacy flat shapes.
Surfaced by mrcfps's inline review on cli-templates.test.ts:480.
504 lines
16 KiB
TypeScript
504 lines
16 KiB
TypeScript
// Contract test for the `od templates` CLI surface. Keeps the
|
|
// UI / API / CLI triple wired together (AGENTS.md "Capability exposure"):
|
|
// the CLI must drive the same /api/templates endpoints the web UI uses
|
|
// (NewProjectPanel / ExamplesTab), with --json support for headless
|
|
// agents that pipe through jq / xargs.
|
|
//
|
|
// We spin up a tiny stub HTTP server to capture requests instead of
|
|
// booting the full daemon: `execFile` is enough to prove that
|
|
// SUBCOMMAND_MAP routes `templates`, parseFlags accepts the documented
|
|
// flags, and the right HTTP call is emitted for each sub-verb.
|
|
|
|
import http from 'node:http';
|
|
import { execFile } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve as pathResolve } from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
const execFileP = promisify(execFile);
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const DAEMON_ROOT = pathResolve(__dirname, '..');
|
|
const REPO_ROOT = pathResolve(__dirname, '../../..');
|
|
const CLI_SRC = pathResolve(__dirname, '../src/cli.ts');
|
|
const TSX_CLI = pathResolve(REPO_ROOT, 'node_modules/tsx/dist/cli.mjs');
|
|
|
|
interface CapturedRequest {
|
|
method: string;
|
|
url: string;
|
|
body: string;
|
|
}
|
|
|
|
interface StubServer {
|
|
baseUrl: string;
|
|
requests: CapturedRequest[];
|
|
setResponder: (
|
|
fn: (req: CapturedRequest) => { status: number; body: unknown } | null,
|
|
) => void;
|
|
close: () => Promise<void>;
|
|
}
|
|
|
|
async function startStubServer(): Promise<StubServer> {
|
|
const requests: CapturedRequest[] = [];
|
|
let responder:
|
|
| ((req: CapturedRequest) => { status: number; body: unknown } | null)
|
|
| null = null;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
let raw = '';
|
|
req.on('data', (chunk) => {
|
|
raw += chunk;
|
|
});
|
|
req.on('end', () => {
|
|
const captured: CapturedRequest = {
|
|
method: req.method ?? '',
|
|
url: req.url ?? '',
|
|
body: raw,
|
|
};
|
|
requests.push(captured);
|
|
const response = responder?.(captured) ?? { status: 200, body: { ok: true } };
|
|
res.statusCode = response.status;
|
|
res.setHeader('content-type', 'application/json');
|
|
res.end(JSON.stringify(response.body));
|
|
});
|
|
});
|
|
|
|
await new Promise<void>((resolveListen) => server.listen(0, '127.0.0.1', resolveListen));
|
|
const addr = server.address();
|
|
if (!addr || typeof addr === 'string') throw new Error('stub server has no address');
|
|
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
|
|
return {
|
|
baseUrl,
|
|
requests,
|
|
setResponder: (fn) => {
|
|
responder = fn;
|
|
},
|
|
close: () =>
|
|
new Promise<void>((resolveClose, rejectClose) => {
|
|
server.close((err) => (err ? rejectClose(err) : resolveClose()));
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function runCli(
|
|
args: string[],
|
|
options: { env?: NodeJS.ProcessEnv } = {},
|
|
): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
...options.env,
|
|
};
|
|
delete env.NODE_OPTIONS;
|
|
try {
|
|
const { stdout, stderr } = await execFileP(
|
|
process.execPath,
|
|
[TSX_CLI, CLI_SRC, ...args],
|
|
{
|
|
cwd: DAEMON_ROOT,
|
|
env,
|
|
timeout: 15_000,
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
},
|
|
);
|
|
return { stdout, stderr, code: 0 };
|
|
} catch (err) {
|
|
const failed = err as { stdout?: string; stderr?: string; code?: number | null };
|
|
return {
|
|
stdout: failed.stdout ?? '',
|
|
stderr: failed.stderr ?? '',
|
|
code: failed.code ?? 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Reserve a port the OS just told us is free, then close it before
|
|
// any test runs so connections to it fail with ECONNREFUSED. The
|
|
// previous `port + 1` heuristic only assumed the stub's ephemeral port
|
|
// was free — `+1` could already be bound by an unrelated process on a
|
|
// shared CI host, which would silently route the "unreachable daemon"
|
|
// tests at a real server and let them pass (or fail) for the wrong
|
|
// reason (#2428 round-4 reviewer-correctness fix).
|
|
async function reserveAndReleasePort(): Promise<{ host: string; port: number }> {
|
|
return new Promise((resolveListen, rejectListen) => {
|
|
const probe = http.createServer();
|
|
probe.unref();
|
|
probe.once('error', rejectListen);
|
|
probe.listen(0, '127.0.0.1', () => {
|
|
const addr = probe.address();
|
|
if (!addr || typeof addr === 'string') {
|
|
probe.close();
|
|
rejectListen(new Error('probe server has no address'));
|
|
return;
|
|
}
|
|
const { port } = addr;
|
|
probe.close((err) => {
|
|
if (err) rejectListen(err);
|
|
else resolveListen({ host: '127.0.0.1', port });
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
describe('od templates CLI', () => {
|
|
let stub: StubServer;
|
|
let unreachableDaemonUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
stub = await startStubServer();
|
|
const reserved = await reserveAndReleasePort();
|
|
unreachableDaemonUrl = `http://${reserved.host}:${reserved.port}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await stub.close();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
stub.requests.length = 0;
|
|
stub.setResponder(() => ({ status: 200, body: { ok: true } }));
|
|
});
|
|
|
|
afterEach(() => {
|
|
stub.setResponder(() => ({ status: 200, body: { ok: true } }));
|
|
});
|
|
|
|
it('prints usage on `od templates help` and exits 0', async () => {
|
|
const result = await runCli(['templates', 'help']);
|
|
expect(result.code).toBe(0);
|
|
expect(result.stdout).toMatch(/od templates/);
|
|
expect(result.stdout).toMatch(/save/);
|
|
expect(result.stdout).toMatch(/delete/);
|
|
expect(stub.requests).toHaveLength(0);
|
|
});
|
|
|
|
it('lists user-saved templates as `id\\tname` lines by default', async () => {
|
|
stub.setResponder((req) => {
|
|
if (req.method === 'GET' && req.url === '/api/templates') {
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
templates: [
|
|
{ id: 't-1', name: 'Cards', sourceProjectId: 'proj-1' },
|
|
{ id: 't-2', name: 'Pricing', sourceProjectId: 'proj-2' },
|
|
],
|
|
},
|
|
};
|
|
}
|
|
return { status: 404, body: { error: 'unexpected' } };
|
|
});
|
|
|
|
const result = await runCli(['templates', 'list', '--daemon-url', stub.baseUrl]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'GET',
|
|
url: '/api/templates',
|
|
});
|
|
expect(result.stdout).toContain('t-1\tCards');
|
|
expect(result.stdout).toContain('t-2\tPricing');
|
|
});
|
|
|
|
it('emits the raw GET /api/templates body under --json', async () => {
|
|
const payload = {
|
|
templates: [{ id: 't-1', name: 'Cards', sourceProjectId: 'proj-1', files: [] }],
|
|
};
|
|
stub.setResponder(() => ({ status: 200, body: payload }));
|
|
|
|
const result = await runCli([
|
|
'templates',
|
|
'list',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
'--json',
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual(payload);
|
|
});
|
|
|
|
it('POSTs /api/templates with name + sourceProjectId on `templates save`', async () => {
|
|
stub.setResponder((req) => {
|
|
if (req.method === 'POST' && req.url === '/api/templates') {
|
|
return {
|
|
status: 200,
|
|
body: { template: { id: 't-new', name: 'Cards', sourceProjectId: 'proj-1' } },
|
|
};
|
|
}
|
|
return { status: 404, body: { error: 'unexpected' } };
|
|
});
|
|
|
|
const result = await runCli([
|
|
'templates',
|
|
'save',
|
|
'proj-1',
|
|
'--name',
|
|
'Cards',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(stub.requests).toHaveLength(1);
|
|
const req = stub.requests[0]!;
|
|
expect(req.method).toBe('POST');
|
|
expect(req.url).toBe('/api/templates');
|
|
expect(JSON.parse(req.body)).toEqual({
|
|
name: 'Cards',
|
|
sourceProjectId: 'proj-1',
|
|
});
|
|
expect(result.stdout).toContain('t-new');
|
|
});
|
|
|
|
it('forwards an optional --description into the POST body', async () => {
|
|
stub.setResponder(() => ({
|
|
status: 200,
|
|
body: { template: { id: 't-2', name: 'Pricing', description: 'About pricing' } },
|
|
}));
|
|
|
|
const result = await runCli([
|
|
'templates',
|
|
'save',
|
|
'proj-9',
|
|
'--name',
|
|
'Pricing',
|
|
'--description',
|
|
'About pricing',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
const req = stub.requests[0]!;
|
|
expect(JSON.parse(req.body)).toEqual({
|
|
name: 'Pricing',
|
|
description: 'About pricing',
|
|
sourceProjectId: 'proj-9',
|
|
});
|
|
});
|
|
|
|
it('exits 2 and emits no HTTP request when `templates save` lacks --name', async () => {
|
|
const result = await runCli([
|
|
'templates',
|
|
'save',
|
|
'proj-1',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(2);
|
|
expect(stub.requests).toHaveLength(0);
|
|
expect(result.stderr).toMatch(/--name/);
|
|
});
|
|
|
|
it('exits 2 and emits no HTTP request when `templates save` lacks a projectId', async () => {
|
|
const result = await runCli([
|
|
'templates',
|
|
'save',
|
|
'--name',
|
|
'Cards',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(2);
|
|
expect(stub.requests).toHaveLength(0);
|
|
expect(result.stderr).toMatch(/projectId/i);
|
|
});
|
|
|
|
it('DELETEs /api/templates/:id on `templates delete <id>`', async () => {
|
|
stub.setResponder((req) => {
|
|
if (req.method === 'DELETE' && req.url === '/api/templates/t-1') {
|
|
return { status: 200, body: { ok: true } };
|
|
}
|
|
return { status: 404, body: { error: 'unexpected' } };
|
|
});
|
|
|
|
const result = await runCli([
|
|
'templates',
|
|
'delete',
|
|
't-1',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'DELETE',
|
|
url: '/api/templates/t-1',
|
|
});
|
|
expect(result.stdout).toContain('t-1');
|
|
});
|
|
|
|
it('exits 2 and emits no HTTP request when `templates delete` lacks an id', async () => {
|
|
const result = await runCli([
|
|
'templates',
|
|
'delete',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(2);
|
|
expect(stub.requests).toHaveLength(0);
|
|
expect(result.stderr).toMatch(/<id>/);
|
|
});
|
|
|
|
it('prints usage on `od templates` with no sub-verb and exits 2', async () => {
|
|
const result = await runCli(['templates']);
|
|
expect(result.code).toBe(2);
|
|
expect(result.stdout).toMatch(/od templates/);
|
|
});
|
|
|
|
it('exits non-zero on unknown sub-verb', async () => {
|
|
const result = await runCli([
|
|
'templates',
|
|
'frobnicate',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stderr).toMatch(/unknown subcommand/i);
|
|
});
|
|
|
|
// Reviewer-correctness fix from #2428: when the daemon isn't
|
|
// running, every sub-verb must surface the same clean
|
|
// "failed to reach daemon at <url>: <code>" error that the rest of
|
|
// the CLI emits via `surfaceFetchError`, not a raw
|
|
// `TypeError: fetch failed`. The CLI is pointed at a port the OS
|
|
// just told us was free and which we immediately released — see
|
|
// `reserveAndReleasePort` above — so the connection fails with
|
|
// ECONNREFUSED rather than silently routing to whatever happens to
|
|
// be bound nearby.
|
|
|
|
it('surfaces a clean fetch error from `templates list` when the daemon is unreachable', async () => {
|
|
const result = await runCli(['templates', 'list', '--daemon-url', unreachableDaemonUrl]);
|
|
expect(result.code).toBe(3);
|
|
expect(result.stderr).toMatch(/failed to reach daemon at http:\/\/127\.0\.0\.1:/);
|
|
expect(result.stderr).not.toMatch(/TypeError: fetch failed/);
|
|
});
|
|
|
|
it('surfaces a clean fetch error from `templates save` when the daemon is unreachable', async () => {
|
|
const result = await runCli([
|
|
'templates',
|
|
'save',
|
|
'proj-1',
|
|
'--name',
|
|
'Cards',
|
|
'--daemon-url',
|
|
unreachableDaemonUrl,
|
|
]);
|
|
expect(result.code).toBe(3);
|
|
expect(result.stderr).toMatch(/failed to reach daemon at http:\/\/127\.0\.0\.1:/);
|
|
expect(result.stderr).not.toMatch(/TypeError: fetch failed/);
|
|
});
|
|
|
|
it('surfaces a clean fetch error from `templates delete` when the daemon is unreachable', async () => {
|
|
const result = await runCli([
|
|
'templates',
|
|
'delete',
|
|
't-1',
|
|
'--daemon-url',
|
|
unreachableDaemonUrl,
|
|
]);
|
|
expect(result.code).toBe(3);
|
|
expect(result.stderr).toMatch(/failed to reach daemon at http:\/\/127\.0\.0\.1:/);
|
|
expect(result.stderr).not.toMatch(/TypeError: fetch failed/);
|
|
});
|
|
|
|
// Reviewer-correctness fixes from #2428 round 3.
|
|
// (1) positional id must work even when shared flags come first.
|
|
// (2) `templates save` 404 / 400 must map to project-not-found /
|
|
// missing-input — not the default `daemon-not-running`, which
|
|
// would send agents down the wrong recovery branch.
|
|
it('accepts positional projectId after --daemon-url for `templates save` (flag-before-id)', async () => {
|
|
stub.setResponder(() => ({
|
|
status: 200,
|
|
body: { template: { id: 't-after-flag', name: 'Cards' } },
|
|
}));
|
|
const result = await runCli([
|
|
'templates',
|
|
'save',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
'proj-1',
|
|
'--name',
|
|
'Cards',
|
|
]);
|
|
expect(result.code).toBe(0);
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]?.method).toBe('POST');
|
|
expect(JSON.parse(stub.requests[0]?.body ?? '{}')).toMatchObject({
|
|
sourceProjectId: 'proj-1',
|
|
name: 'Cards',
|
|
});
|
|
});
|
|
|
|
it('accepts positional id after --daemon-url for `templates delete` (flag-before-id)', async () => {
|
|
stub.setResponder(() => ({ status: 200, body: { ok: true } }));
|
|
const result = await runCli([
|
|
'templates',
|
|
'delete',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
't-after-flag',
|
|
]);
|
|
expect(result.code).toBe(0);
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]?.method).toBe('DELETE');
|
|
expect(stub.requests[0]?.url).toBe('/api/templates/t-after-flag');
|
|
});
|
|
|
|
it('reports project-not-found (not daemon-not-running) when `templates save` gets 404', async () => {
|
|
// POST /api/templates returns a flat `{ error: '<message>' }` body
|
|
// (project-routes.ts:679), so the stub mirrors that exact shape
|
|
// rather than the nested `{ error: { code, message } }` envelope.
|
|
// structuredHttpFailure normalises the flat string into the
|
|
// structured envelope, mapping the 404 to project-not-found via
|
|
// the fallback code passed at the call site and surfacing the
|
|
// daemon's literal message so headless callers don't lose the
|
|
// only diagnostic the route emitted.
|
|
stub.setResponder(() => ({
|
|
status: 404,
|
|
body: { error: 'source project not found' },
|
|
}));
|
|
const result = await runCli([
|
|
'templates',
|
|
'save',
|
|
'missing-project',
|
|
'--name',
|
|
'Cards',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
expect(result.code).toBe(68);
|
|
const envelope = JSON.parse(result.stderr.trim());
|
|
expect(envelope.error.code).toBe('project-not-found');
|
|
expect(envelope.error.message).toBe('source project not found');
|
|
});
|
|
|
|
it('reports missing-input (not daemon-not-running) when `templates save` gets 400', async () => {
|
|
// 400 from POST /api/templates is also a flat `{ error: '<msg>' }`
|
|
// body (project-routes.ts:669, :675). Same normalisation contract
|
|
// as the 404 case above.
|
|
stub.setResponder(() => ({
|
|
status: 400,
|
|
body: { error: 'name required' },
|
|
}));
|
|
const result = await runCli([
|
|
'templates',
|
|
'save',
|
|
'proj-1',
|
|
'--name',
|
|
'Cards',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
expect(result.code).toBe(67);
|
|
const envelope = JSON.parse(result.stderr.trim());
|
|
expect(envelope.error.code).toBe('missing-input');
|
|
expect(envelope.error.message).toBe('name required');
|
|
});
|
|
});
|