Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
512 changes: 510 additions & 2 deletions .agents/skills/react-doctor/SKILL.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions .changeset/debug-command-and-skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-doctor": minor
---

Add a `react-doctor debug` subcommand that starts an NDJSON logging server for evidence-based debugging. It supports an interactive mode, a `--daemon` mode (spawns a detached background server and prints one JSON line), and a `--json` mode, and is idempotent via a singleton lock file so re-running returns the existing session. The bundled `react-doctor` agent skill gains `/debug` (a runtime-instrumentation root-cause loop) and `/performance` (a LoAF + commit attribution rig) sections that drive this server.
159 changes: 159 additions & 0 deletions packages/react-doctor/src/cli/commands/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { spawn } from "node:child_process";
import { Command } from "commander";
import { highlighter } from "@react-doctor/core";
import { cliLogger as logger } from "../utils/cli-logger.js";
import { createDebugServer } from "../utils/debug-server.js";
import { spinner } from "../utils/spinner.js";

interface DebugCommandOptions {
port?: number;
host: string;
sessionId?: string;
logPath?: string;
daemon?: boolean;
json?: boolean;
}

// `--json` is also a root-level flag, so Commander binds it to the parent
// program rather than the `debug` subcommand. Read it back from the parent
// (same interplay the `install` command handles for `--yes`).
interface DebugCommandContext {
parent?: {
opts?: () => {
json?: boolean;
};
};
}

const startDaemon = async (options: DebugCommandOptions): Promise<void> => {
const childArgs = [process.argv[1], "debug", "--json"];
if (options.port) childArgs.push("-p", String(options.port));
if (options.host !== "127.0.0.1") childArgs.push("-H", options.host);
if (options.sessionId) childArgs.push("-s", options.sessionId);
if (options.logPath) childArgs.push("-l", options.logPath);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Daemon omits telemetry opt-out flag

Medium Severity

startDaemon spawns the background server with a fixed argv list and never forwards --no-score, even when the parent CLI invocation included it. Sentry initialization reads raw process.argv and disables crash reporting only when that flag is present, so the detached child can still initialize Sentry and report crashes after the user opted out on the parent command.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d1268db. Configure here.


const childProcess = spawn(process.execPath, childArgs, {
detached: true,
stdio: ["ignore", "pipe", "ignore"],
});

if (!childProcess.stdout) {
logger.error("Failed to start debug server daemon.");
process.exit(1);
}

let stdoutBuffer = "";
let isSettled = false;
const serverInfoLine = await new Promise<string>((resolve, reject) => {
const settle = (action: () => void) => {
if (isSettled) return;
isSettled = true;
action();
};
childProcess.stdout!.on("data", (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
const newlineIndex = stdoutBuffer.indexOf("\n");
// Strip a trailing CR so the printed line is valid JSON on Windows.
if (newlineIndex !== -1) {
settle(() => resolve(stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "")));
}
});
childProcess.on("error", (error) => settle(() => reject(error)));
childProcess.on("exit", (code) =>
// The server child stays alive once it prints its info line, so a
// resolve always wins the race; reaching `exit` first means it died
// before printing — reject rather than hang the parent forever.
settle(() =>
reject(new Error(`Debug server process exited (code ${code ?? "unknown"}) before startup`)),
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Daemon treats exit zero as failure

Medium Severity

In --daemon mode, the parent rejects if the child exits before stdout settles, for any exit code including 0. On idempotent reuse the JSON child prints one line and exits 0, so an exit event before the pipe data event yields a false startup failure even though the shared server is fine.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4b8c66. Configure here.

);
});
Comment thread
cursor[bot] marked this conversation as resolved.

console.log(serverInfoLine);
childProcess.unref();
process.exit(0);
};

const startJson = async (options: DebugCommandOptions): Promise<void> => {
const { server, info } = await createDebugServer({
port: options.port,
host: options.host,
sessionId: options.sessionId,
logPath: options.logPath,
});

console.log(JSON.stringify(info));

if (!server) {
process.exit(0);
}

const shutdown = () => {
server.close();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
};

const startInteractive = async (options: DebugCommandOptions): Promise<void> => {
const startSpinner = spinner("Starting React Doctor debug server...").start();

const { server, info, reused } = await createDebugServer({
port: options.port,
host: options.host,
sessionId: options.sessionId,
logPath: options.logPath,
});

if (reused || !server) {
startSpinner.succeed(
`Debug server already running on port ${highlighter.bold(String(info.port))}`,
);
logger.dim(` Endpoint: ${info.endpoint}`);
logger.dim(` Log path: ${info.logPath}`);
return;
}

startSpinner.succeed(`Debug server listening on port ${highlighter.bold(String(info.port))}`);
logger.dim(` Endpoint: ${info.endpoint}`);
logger.dim(` Log path: ${info.logPath}`);

const shutdown = () => {
server.close();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
};

export const debugAction = async (
options: DebugCommandOptions,
command?: DebugCommandContext,
): Promise<void> => {
const isJson = options.json ?? command?.parent?.opts?.().json ?? false;
if (options.daemon) {
await startDaemon(options);
return;
}
if (isJson) {
await startJson(options);
return;
}
await startInteractive(options);
};

export const registerDebugCommand = (program: Command): void => {
program
.command("debug")
.description("Start the NDJSON debug logging server for evidence-based debugging")
.option("-p, --port <number>", "port to listen on (default: random)", (value) =>
parseInt(value, 10),
)
.option("-H, --host <address>", "host to bind to", "127.0.0.1")
.option("-s, --session-id <id>", "session ID (default: random 6-char hex)")
.option("-l, --log-path <path>", "log file path (default: <tmpdir>/react-doctor-debug/...)")
.option("-d, --daemon", "start the server in the background and exit")
.option("--json", "output server info as JSON (no spinner/colors)")
.action(debugAction);
};
3 changes: 3 additions & 0 deletions packages/react-doctor/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command } from "commander";
import { CANONICAL_GITHUB_URL, highlighter } from "@react-doctor/core";
import { initializeSentry } from "../instrument.js";
import { registerDebugCommand } from "./commands/debug.js";
Comment thread
cursor[bot] marked this conversation as resolved.
import { inspectAction } from "./commands/inspect.js";
import { installAction } from "./commands/install.js";
import { exitGracefully } from "./utils/exit-gracefully.js";
Expand Down Expand Up @@ -98,6 +99,8 @@ program
.option("-c, --cwd <cwd>", "working directory", process.cwd())
.action(installAction);

registerDebugCommand(program);

// HACK: when stdout is piped into a process that closes early (e.g.
// `react-doctor . | head`), Node throws an uncaught EPIPE on the next
// write. Exit cleanly instead of dumping a stack trace.
Expand Down
16 changes: 16 additions & 0 deletions packages/react-doctor/src/cli/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,19 @@ export const INTERNAL_ERROR_JSON_FALLBACK =
// never the programmatic `@react-doctor/api` library.
export const SENTRY_DSN =
"https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920";

// Bytes of randomness for a `react-doctor debug` session id; hex-encoded
// into a 6-char id that namespaces the per-session NDJSON log file.
export const DEBUG_SESSION_ID_BYTE_LENGTH = 3;

// How long the idempotency probe waits for an already-running debug
// server to answer its health check before assuming the lock is stale.
export const DEBUG_LOCK_PING_TIMEOUT_MS = 1000;

// Subdirectory (under the project root, or the OS tmpdir as a fallback)
// that holds debug session logs and the singleton server lock file.
export const DEBUG_LOG_DIRECTORY_NAME = "react-doctor-debug";

// Cap on remembered log-entry ids per session for POST de-duplication;
// the set is cleared once it grows past this to bound memory.
export const DEBUG_MAX_DEDUP_ENTRIES = 10_000;
37 changes: 37 additions & 0 deletions packages/react-doctor/src/cli/utils/debug-server-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import fs from "node:fs";
import path from "node:path";

export interface DebugServerLock {
pid: number;
host: string;
port: number;
sessionId: string;
endpoint: string;
logPath: string;
}

const LOCK_FILENAME = "debug-server.lock";

const getLockPath = (directory: string): string => path.join(directory, LOCK_FILENAME);

export const readDebugServerLock = (directory: string): DebugServerLock | null => {
const lockPath = getLockPath(directory);
if (!fs.existsSync(lockPath)) return null;
try {
return JSON.parse(fs.readFileSync(lockPath, "utf-8"));
} catch {
return null;
}
};

export const writeDebugServerLock = (directory: string, lockData: DebugServerLock): void => {
fs.writeFileSync(getLockPath(directory), JSON.stringify(lockData, null, 2));
};

export const removeDebugServerLock = (directory: string): void => {
const lockPath = getLockPath(directory);
if (!fs.existsSync(lockPath)) return;
try {
fs.unlinkSync(lockPath);
} catch {}
};
Loading
Loading