Files
hyperframes/packages/cli/src/utils/project.ts
T
Miguel Ángel 5f6ced116d feat(cli): report command failure reasons to telemetry (de-blind browser/info) (#1484)
Observability showed `browser` (~75% fail, ~1.3k users/day) and `info` (~60%
fail) failing at high rates with no captured reason — only
`cli_command_result success=false`. citty's `runMain` catches a command's
thrown error and `process.exit(1)`s without re-throwing, so a thrown failure
never reached the existing `cli_error` telemetry (which only fired from the
uncaughtException / unhandledRejection handlers).

Wrap every command's `run()` at the dispatch boundary (cli.ts) so a thrown
failure reports its reason via `cli_error` (kind=command_error) before being
re-thrown unchanged — citty's print + exit-1 behavior is preserved. This
de-blinds every throw-style command at once: `browser ensure` (Chrome
download), `tts`, `inspect`, `render`, etc.

Paths that bypass the wrapper are handled inline:
- `browser` self-exits (`path` download failure, unknown subcommand) — report
  inline; the ARM64 `ensure` branch previously swallowed a failed install and
  returned success, now reports and exits 1.
- `resolveProject()` self-exits on InvalidProjectError (the dominant `info`
  failure — run outside a project) — report inline before exit.

Hardening:
- PII: `trackCliError` now redacts error_message + stack_trace via
  redactTelemetryString (matching render_* events) — CLI errors and stacks
  carry absolute install paths / cache dirs / user args.
- Race: the wrapper awaits an on-demand telemetry import before re-throwing, so
  a command that fails before the lazy telemetry import settles still reports
  (a telemetry failure is swallowed and never masks the real error).

Pure helpers in utils/command-failure-tracking.ts with unit tests for the
throw / success / no-run / onFailure-rejection cases, the reporter wiring, and
trackCliError redaction. CommandDef<any> mirrors citty's SubCommandsDef.

Known scope: commands that print + `process.exit(1)` on their own validation
paths (tts/validate/lint argument errors) remain wrapper-blind — follow-up.
2026-06-16 01:39:34 -04:00

70 lines
2.1 KiB
TypeScript

import { existsSync, statSync } from "node:fs";
import { resolve, basename } from "node:path";
import { errorBox } from "../ui/format.js";
import { trackCommandFailure } from "../telemetry/events.js";
export interface ProjectDir {
dir: string;
name: string;
indexPath: string;
}
export class InvalidProjectError extends Error {
readonly title: string;
readonly hint?: string;
readonly suggestion?: string;
constructor(title: string, hint?: string, suggestion?: string) {
super(title);
this.name = "InvalidProjectError";
this.title = title;
this.hint = hint;
this.suggestion = suggestion;
}
}
export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir {
const trimmed = dirArg?.trim();
if (trimmed === "#") {
throw new InvalidProjectError(
"Invalid project directory: #",
"# is a URL fragment, not a project path.",
"Run hyperframes preview . from your project directory.",
);
}
const dir = resolve(dirArg ?? ".");
const name = basename(dir);
const indexPath = resolve(dir, "index.html");
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
throw new InvalidProjectError("Not a directory: " + dir);
}
if (!existsSync(indexPath)) {
throw new InvalidProjectError(
"No composition found in " + dir,
"No index.html file found.",
"Run npx hyperframes init to create a new composition.",
);
}
return { dir, name, indexPath };
}
export function resolveProject(dirArg: string | undefined): ProjectDir {
try {
return resolveProjectOrThrow(dirArg);
} catch (err) {
if (err instanceof InvalidProjectError) {
// Self-exit (not a throw) so the cli.ts wrapper never sees it — report
// inline. argv[2] is the running command (info / inspect / render / ...).
// This is the dominant failure for read-only commands like `info` run
// outside a project; the redaction in trackCliError strips the dir path.
trackCommandFailure(process.argv[2] ?? "unknown", err);
errorBox(err.title, err.hint, err.suggestion);
process.exit(1);
}
throw err;
}
}