-
Notifications
You must be signed in to change notification settings - Fork 387
feat(cli): add debug subcommand + debug/performance skill sections
#624
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
f2250c2
d4b8c66
707659b
d1268db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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. |
| 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); | ||
|
|
||
| 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`)), | ||
| ), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Daemon treats exit zero as failureMedium Severity In Reviewed by Cursor Bugbot for commit d4b8c66. Configure here. |
||
| ); | ||
| }); | ||
|
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); | ||
| }; | ||
| 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 {} | ||
| }; |


There was a problem hiding this comment.
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
startDaemonspawns the background server with a fixed argv list and never forwards--no-score, even when the parent CLI invocation included it. Sentry initialization reads rawprocess.argvand 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.Reviewed by Cursor Bugbot for commit d1268db. Configure here.