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 00000000..3acb655a --- /dev/null +++ b/packages/cli/src/commands/cli-errors.test.ts @@ -0,0 +1,51 @@ +// 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 single structured 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 of JSON on stderr — no second, stack-trace error. + const lines = stderr.trim().split('\n').filter(Boolean); + expect(lines.length).toBe(1); + const err = JSON.parse(lines[0]!); + expect(err.error).toBe('FILE_READ_ERROR'); + expect(typeof err.message).toBe('string'); + expect(err.path).toBe('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 1bb50d5b..7c659c16 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; @@ -57,7 +58,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; } @@ -68,7 +69,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; } @@ -79,7 +80,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; } diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 22756e4d..6542226d 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -36,8 +36,10 @@ export async function readInput(filePath: string): Promise { message: error instanceof Error ? error.message : String(error), path: filePath, })); - process.exitCode = 2; - throw error; // bubbles up, but process will exit with code 2 if uncaught + // Exit immediately with a dedicated I/O exit code. Throwing here instead + // would let the CLI framework print a second, stack-trace error on top of + // the JSON above and override the exit code with 1. + process.exit(2); } }