Skip to content
Merged
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
49 changes: 49 additions & 0 deletions src/cli-error-report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import yaml from 'js-yaml';
import { reportCliError } from './cli.js';
import { CliError, EXIT_CODES } from './errors.js';

describe('reportCliError', () => {
const previousExitCode = process.exitCode;
afterEach(() => { process.exitCode = previousExitCode; });

function capture(err: unknown): string {
let text = '';
reportCliError(err, { write: (chunk: string) => { text += chunk; return true; } } as unknown as NodeJS.WritableStream);
return text;
}

it('renders a CliError as the shared envelope and keeps its exit code', () => {
const text = capture(new CliError('SITE_MEMORY_NOT_FOUND', 'Verify fixture a/b was not found.', undefined, EXIT_CODES.EMPTY_RESULT));

expect(yaml.load(text)).toEqual({
ok: false,
error: { code: 'SITE_MEMORY_NOT_FOUND', message: 'Verify fixture a/b was not found.', exitCode: EXIT_CODES.EMPTY_RESULT },
});
expect(process.exitCode).toBe(EXIT_CODES.EMPTY_RESULT);
});

it('carries the hint through as help', () => {
const text = capture(new CliError('X', 'broke', 'try this', EXIT_CODES.USAGE_ERROR));

expect(yaml.load(text)).toMatchObject({ error: { help: 'try this', exitCode: EXIT_CODES.USAGE_ERROR } });
expect(process.exitCode).toBe(EXIT_CODES.USAGE_ERROR);
});

it('falls back to UNKNOWN and a generic exit code for a plain error', () => {
const text = capture(new Error('boom'));

expect(yaml.load(text)).toMatchObject({ error: { code: 'UNKNOWN', message: 'boom', exitCode: EXIT_CODES.GENERIC_ERROR } });
expect(process.exitCode).toBe(EXIT_CODES.GENERIC_ERROR);
});

it('omits the stack unless WEBCMD_DEBUG is set', () => {
expect(yaml.load(capture(new Error('boom'))) as any).not.toHaveProperty('error.stack');
vi.stubEnv('WEBCMD_DEBUG', '1');
try {
expect(String((yaml.load(capture(new Error('boom'))) as any).error.stack)).toContain('Error: boom');
} finally {
vi.unstubAllEnvs();
}
});
});
31 changes: 27 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ import './fetch/command.js';
import { commandListPresentation, filterCommandsByTag, toPresentableCommand } from './command-presentation.js';
import { configureCompletionCommandSurface, configureListCommandSurface, configurePluginInstallSurface, configurePluginListSurface, configurePluginSearchSurface } from './builtin-command-surface.js';
import { OUTPUT_FORMAT_HELP, resolveOutputFormat } from './command-surface.js';
import { render as renderOutput } from './output.js';
import { render as renderOutput, formatErrorEnvelope } from './output.js';
import { PKG_VERSION } from './version.js';
import { printCompletionScript } from './completion.js';
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js';
import { addWebcmdSkills, listWebcmdSkills, removeWebcmdSkills, updateWebcmdSkill, type WebcmdSkillAddResult } from './skills.js';
import { registerAllCommands } from './commanderAdapter.js';
import { buildRootHelpPresentation, classifyAdapter, installCommanderNamespaceStructuredHelp, installRootPresentationHelp, leadingPositionalFromUsage, rootHelpData, type RootAdapterGroups } from './help.js';
import { EXIT_CODES, getErrorMessage, BrowserConnectError, CliError, ArgumentError } from './errors.js';
import { EXIT_CODES, getErrorMessage, toEnvelope, BrowserConnectError, CliError, ArgumentError } from './errors.js';
import { TargetError, type TargetErrorCode } from './browser/target-errors.js';
import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs, type ResolveOptions, type TargetMatchLevel } from './browser/target-resolver.js';
import { buildFindJs, buildSemanticFindJs, isFindError, type FindResult, type FindError, type SemanticFindOptions } from './browser/find.js';
Expand Down Expand Up @@ -2064,8 +2064,31 @@ export async function loadAntigravityServe(pluginsDir: string = PLUGINS_DIR): Pr
return import(pathToFileURL(path.join(pluginsDir, 'antigravity', 'serve.js')).href);
}

export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
createProgram(BUILTIN_CLIS, USER_CLIS).parse();
/**
* Run the local CLI, reporting anything a built-in command throws as the same
* error envelope adapter commands already emit.
*
* Built-in actions used to have no handler at all: `parse()` does not await
* async actions, so a throw either crashed with a raw Node stack trace or
* surfaced as an unhandled rejection, and the `exitCode` the error carried was
* lost. `parseAsync` lets the rejection reach this catch.
*/
export async function runCli(BUILTIN_CLIS: string, USER_CLIS: string): Promise<void> {
try {
await createProgram(BUILTIN_CLIS, USER_CLIS).parseAsync();
} catch (err) {
reportCliError(err);
}
}

/** Render a thrown error as the shared envelope and set the exit code it carries. */
export function reportCliError(err: unknown, stderr: NodeJS.WritableStream = process.stderr): void {
const envelope = toEnvelope(err);
if (process.env.WEBCMD_DEBUG && err instanceof Error && err.stack) {
envelope.error.stack = err.stack;
}
stderr.write(formatErrorEnvelope(envelope));
process.exitCode = envelope.error.exitCode;
}

// ── Helpers ─────────────────────────────────────────────────────────────────
Expand Down
3 changes: 3 additions & 0 deletions src/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@ afterAll(() => fs.rmSync(managedBinaryDir, { recursive: true, force: true }));

describe('doctor report rendering', () => {
const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
const isolatedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-doctor-render-'));
afterAll(() => fs.rmSync(isolatedConfigDir, { recursive: true, force: true }));

beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.stubEnv('WEBCMD_CONFIG_DIR', isolatedConfigDir);
mockFindShadowedUserAdapters.mockReturnValue([]);
mockSetDaemonCommandTimeoutSeconds.mockClear();
mockEnsureBinary.mockResolvedValue(managedBinaryPath);
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,5 +211,5 @@ try {
}

await emitHook('onStartup', { command: '__startup__', args: {} });
runCli(BUILTIN_CLIS, USER_CLIS);
await runCli(BUILTIN_CLIS, USER_CLIS);
}
3 changes: 2 additions & 1 deletion src/plugin-pr-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function fixture(change: (root: string) => void) {
git(root, 'init', '--quiet');
git(root, 'config', 'user.email', 'test@example.com');
git(root, 'config', 'user.name', 'Test');
git(root, 'config', 'commit.gpgsign', 'false');
write(root, 'README.md', 'before\n');
write(root, 'webcmd-plugin.json', '{}\n');
write(root, 'package.json', '{}\n');
Expand All @@ -56,7 +57,7 @@ function runGuard(root: string, ...args: string[]) {
return spawnSync(process.execPath, [checkerPath, ...args], { cwd: root, encoding: 'utf8' });
}

describe('plugin-only PR scope guard', () => {
describe('plugin-only PR scope guard', { timeout: 20_000 }, () => {
it('accepts changes contained in one newly added plugin', () => {
const { root, base, head } = fixture((repo) => addPlugin(repo, 'foo'));

Expand Down
Loading