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
16 changes: 13 additions & 3 deletions packages/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import { defineCommand } from 'citty';
import { lint } from '../linter/index.js';
import { readInput, formatOutput, diffMaps } from '../utils.js';
import { readInput, formatOutput, diffMaps, FileReadError } from '../utils.js';
import type { ComponentDef } from '../linter/model/spec.js';

export default defineCommand({
Expand All @@ -40,8 +40,18 @@ export default defineCommand({
},
},
async run({ args }) {
const beforeContent = await readInput(args.before);
const afterContent = await readInput(args.after);
let beforeContent: string, afterContent: string;
try {
beforeContent = await readInput(args.before);
afterContent = await readInput(args.after);
} catch (error) {
if (error instanceof FileReadError) {
process.stderr.write(`Error: ${error.friendlyMessage}\n`);
process.exitCode = 2;
return;
}
throw error;
}

const beforeReport = lint(beforeContent);
const afterReport = lint(afterContent);
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import { defineCommand } from 'citty';
import { lint, TailwindEmitterHandler, TailwindV4EmitterHandler, serializeTailwindV4 } from '../linter/index.js';
import { DtcgEmitterHandler } from '../linter/dtcg/handler.js';
import { readInput } from '../utils.js';
import { readInput, FileReadError } from '../utils.js';

const FORMATS = ['css-tailwind', 'json-tailwind', 'tailwind', 'dtcg'] as const;
type ExportFormat = typeof FORMATS[number];
Expand Down Expand Up @@ -49,7 +49,17 @@ export default defineCommand({
return;
}

const content = await readInput(args.file);
let content: string;
try {
content = await readInput(args.file);
} catch (error) {
if (error instanceof FileReadError) {
process.stderr.write(`Error: ${error.friendlyMessage}\n`);
process.exitCode = 2;
return;
}
throw error;
}
const report = lint(content);

if (format === 'css-tailwind') {
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import { defineCommand } from 'citty';
import { lint } from '../linter/index.js';
import { readInput, formatOutput } from '../utils.js';
import { readInput, formatOutput, FileReadError } from '../utils.js';

export default defineCommand({
meta: {
Expand All @@ -34,7 +34,17 @@ export default defineCommand({
},
},
async run({ args }) {
const content = await readInput(args.file);
let content: string;
try {
content = await readInput(args.file);
} catch (error) {
if (error instanceof FileReadError) {
process.stderr.write(`Error: ${error.friendlyMessage}\n`);
process.exitCode = 2;
return;
}
throw error;
}
const report = lint(content);

const output = {
Expand Down
37 changes: 36 additions & 1 deletion packages/cli/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,42 @@
// limitations under the License.

import { describe, it, expect } from 'bun:test';
import { formatOutput } from './utils.js';
import { readInput, FileReadError, formatOutput } from './utils.js';

describe('readInput', () => {
it('throws FileReadError when file does not exist', async () => {
const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e);
expect(err).toBeInstanceOf(FileReadError);
});

it('FileReadError carries the missing file path', async () => {
const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e);
expect((err as FileReadError).filePath).toBe('/nonexistent-path/DESIGN.md');
});

it('FileReadError carries the underlying OS error message', async () => {
const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e);
expect((err as FileReadError).message).toContain('ENOENT');
});

it('friendlyMessage says "not found" for ENOENT', async () => {
const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e);
expect((err as FileReadError).friendlyMessage).toContain('not found');
});

it('friendlyMessage says "permission denied" for EACCES', () => {
const cause = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
const err = new FileReadError('/some/file.md', cause);
expect(err.friendlyMessage).toContain('permission denied');
expect(err.friendlyMessage).not.toContain('not found');
});

it('friendlyMessage falls back to the raw message for unknown errors', () => {
const cause = Object.assign(new Error('ENOMEM: out of memory'), { code: 'ENOMEM' });
const err = new FileReadError('/some/file.md', cause);
expect(err.friendlyMessage).toContain('ENOMEM');
});
});

describe('formatOutput', () => {
describe('--format markdown', () => {
Expand Down
30 changes: 21 additions & 9 deletions packages/cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,31 @@

import { readFileSync } from 'node:fs';

export class FileReadError extends Error {
readonly code = 'FILE_READ_ERROR' as const;
constructor(public readonly filePath: string, cause: unknown) {
super(cause instanceof Error ? cause.message : String(cause), { cause });
this.name = 'FileReadError';
}

get friendlyMessage(): string {
const errCode = (this.cause as { code?: string })?.code;
if (errCode === 'ENOENT') {
return `"${this.filePath}" not found. Create a DESIGN.md file or pass "-" to read from stdin.`;
}
if (errCode === 'EACCES') {
return `"${this.filePath}" could not be read: permission denied.`;
}
return `"${this.filePath}" could not be read: ${this.message}`;
}
}

/**
* Read input from a file path or stdin ("-").
* Never throws — returns the content string or exits with error JSON.
* Throws FileReadError if the file cannot be read.
*/
export async function readInput(filePath: string): Promise<string> {
if (filePath === '-') {
// Read from stdin
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
Expand All @@ -31,13 +49,7 @@ export async function readInput(filePath: string): Promise<string> {
try {
return readFileSync(filePath, 'utf-8');
} catch (error) {
console.error(JSON.stringify({
error: 'FILE_READ_ERROR',
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
throw new FileReadError(filePath, error);
}
}

Expand Down