From 29e34801d535c420cc2339ec73a4889e562f048a Mon Sep 17 00:00:00 2001 From: yudhi Date: Thu, 25 Jun 2026 22:29:15 +0700 Subject: [PATCH] fix(cli): emit a single structured error with the intended exit code on input failure On a missing or unreadable input file, readInput printed a structured FILE_READ_ERROR JSON to stderr and then re-threw. The throw let the CLI framework print a second, stack-trace error on top of the JSON and override the exit code with 1 instead of the intended 2. Exit cleanly with code 2 right after the JSON, matching the function's documented "exits with error JSON" contract. Also unify the export command's stderr error envelope with readInput's `{ error: , message }` shape: an unknown --format now reports `INVALID_FORMAT` (the human text moves to `message`), and emitter failures forward the emitter's structured code (e.g. INVALID_TOKEN_NAME) instead of discarding it. --- packages/cli/src/commands/cli-errors.test.ts | 49 ++++++++++++++++++++ packages/cli/src/commands/export.ts | 9 ++-- 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/commands/cli-errors.test.ts diff --git a/packages/cli/src/commands/cli-errors.test.ts b/packages/cli/src/commands/cli-errors.test.ts new file mode 100644 index 0000000..42d2501 --- /dev/null +++ b/packages/cli/src/commands/cli-errors.test.ts @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from 'bun:test'; +import { join } from 'node:path'; + +const CLI = join(import.meta.dir, '../index.ts'); + +function runCli(args: string[]): { code: number | null; stdout: string; stderr: string } { + const proc = Bun.spawnSync(['bun', 'run', CLI, ...args], { stdout: 'pipe', stderr: 'pipe' }); + return { + code: proc.exitCode, + stdout: Buffer.from(proc.stdout).toString('utf-8'), + stderr: Buffer.from(proc.stderr).toString('utf-8'), + }; +} + +describe('CLI error output', () => { + it('exits 2 with a friendly error and no stack trace when the input file is missing', () => { + const { code, stdout, stderr } = runCli(['lint', 'definitely-does-not-exist-90af.md']); + expect(code).toBe(2); + expect(stdout).toBe(''); + // Exactly one line on stderr — no second, stack-trace error. + const lines = stderr.trim().split('\n').filter(Boolean); + expect(lines.length).toBe(1); + expect(lines[0]).toContain('not found'); + expect(lines[0]).toContain('definitely-does-not-exist-90af.md'); + }); + + it('reports an unknown export format with a coded error envelope and exit 1', () => { + // Format is validated before any input is read, so the file path is unused. + const { code, stderr } = runCli(['export', '--format', 'bogus', 'unused.md']); + expect(code).toBe(1); + const err = JSON.parse(stderr.trim()); + expect(err.error).toBe('INVALID_FORMAT'); + expect(err.message).toContain('Invalid format'); + }); +}); diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 97e743a..b9e9cae 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -43,7 +43,8 @@ export default defineCommand({ // Validate --format against closed enum if (!FORMATS.includes(format as ExportFormat)) { console.error(JSON.stringify({ - error: `Invalid format "${format}". Valid formats: ${FORMATS.join(', ')}`, + error: 'INVALID_FORMAT', + message: `Invalid format "${format}". Valid formats: ${FORMATS.join(', ')}`, })); process.exitCode = 1; return; @@ -67,7 +68,7 @@ export default defineCommand({ const result = handler.execute(report.designSystem); if (!result.success) { - console.error(JSON.stringify({ error: result.error.message })); + console.error(JSON.stringify({ error: result.error.code, message: result.error.message })); process.exitCode = 1; return; } @@ -78,7 +79,7 @@ export default defineCommand({ const result = handler.execute(report.designSystem); if (!result.success) { - console.error(JSON.stringify({ error: result.error.message })); + console.error(JSON.stringify({ error: result.error.code, message: result.error.message })); process.exitCode = 1; return; } @@ -89,7 +90,7 @@ export default defineCommand({ const result = handler.execute(report.designSystem); if (!result.success) { - console.error(JSON.stringify({ error: result.error.message })); + console.error(JSON.stringify({ error: result.error.code, message: result.error.message })); process.exitCode = 1; return; }