Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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.
145 changes: 145 additions & 0 deletions packages/react-doctor/src/cli/commands/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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 = "";
const serverInfoLine = await new Promise<string>((resolve, reject) => {
childProcess.stdout!.on("data", (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
const newlineIndex = stdoutBuffer.indexOf("\n");
if (newlineIndex !== -1) resolve(stdoutBuffer.slice(0, newlineIndex));
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
});
childProcess.on("error", reject);
childProcess.on("exit", (code) => {
if (code !== 0) reject(new Error(`Debug server process exited with code ${code}`));
});
});
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