Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
9671469
feat(local): add --verify, --timeout, auto-detect dev script, post-in…
MathurAditya724 May 21, 2026
a00b642
fix(test): use TEST_TMP_DIR instead of /tmp/opencode for CI compat
MathurAditya724 May 21, 2026
ce01fd9
fix: clear timeout handles, forward signals in verify mode, shell-wra…
MathurAditya724 May 21, 2026
06bdcd5
fix: add signal forwarding in verifySetup, await child.exited after kill
MathurAditya724 May 21, 2026
c171241
fix: default 30s timeout for --verify, redact env vars from telemetry
MathurAditya724 May 21, 2026
e5317da
fix: remove stale signal handlers after Promise.race resolves
MathurAditya724 May 21, 2026
dad2f31
fix: SIGKILL fallback after 5s grace period if child ignores SIGTERM
MathurAditya724 May 21, 2026
02b8918
fix: remove numeric separators and fix maskToken property test
MathurAditya724 May 21, 2026
40fc9e3
fix: clear grace timer after child exits promptly
MathurAditya724 May 21, 2026
5dc4afa
fix: remove unnecessary numeric separator in verify-setup timeout
MathurAditya724 May 21, 2026
05a825f
fix: detect $VAR shell expansion, handle already-exited child, broade…
MathurAditya724 May 21, 2026
1858f9d
docs: clarify --timeout flag behavior in --verify mode
MathurAditya724 May 21, 2026
a97abd6
chore: regenerate docs
github-actions[bot] May 21, 2026
1673921
fix: defer signal handler removal until after gracefulKill completes
MathurAditya724 May 21, 2026
1e9ec12
fix: use gracefulKill in non-verify timeout path
MathurAditya724 May 21, 2026
5c40365
fix: break long brief string for biome formatter
MathurAditya724 May 21, 2026
fc94462
merge: resolve conflicts with main (vitest migration)
MathurAditya724 May 21, 2026
0d99c0a
fix: use node:fs in dev-script.ts, fix async handleFinalResult tests,…
MathurAditya724 May 21, 2026
744915e
fix: avoid trailing colon in PATH when env.PATH is undefined
MathurAditya724 May 21, 2026
e50caae
fix: await gracefulKill in timeout callback, trim script value before…
MathurAditya724 May 21, 2026
71869ed
fix: guard SIGKILL in gracefulKill against already-exited child
MathurAditya724 May 21, 2026
3d86a28
fix: guard signal handlers against already-exited child
MathurAditya724 May 21, 2026
81e1cc6
fix: address CI lint failures and Warden findings
MathurAditya724 May 21, 2026
667a6c8
fix: replace nested ternary with if/else, extract parseScriptArgs helper
MathurAditya724 May 21, 2026
b709246
ci: re-trigger CI (flaky response-cache test)
MathurAditya724 May 21, 2026
a0df3f9
merge: resolve conflicts with main, migrate Bun.spawn to Node child_p…
MathurAditya724 May 25, 2026
3b22ef3
fix(init): use stdout-based verification instead of envelope-only det…
MathurAditya724 May 26, 2026
e73c021
fix(init): run verification before summary, tighten log messages
MathurAditya724 May 26, 2026
d0ff643
fix(init): remove platform deny-list, fix Warden findings in runWithV…
MathurAditya724 May 26, 2026
fc4ca25
fix(init): guard cleanup against already-exited child, scrub telemetr…
MathurAditya724 May 26, 2026
f3bb724
fix: respect user's SENTRY_TRACES_SAMPLE_RATE instead of overriding to 1
MathurAditya724 May 26, 2026
3612906
fix: address Warden findings — signal handler race, SIGKILL race, wro…
MathurAditya724 May 26, 2026
b83029a
fix: distinguish silent vs timeout telemetry, clean up buffer subscri…
MathurAditya724 May 26, 2026
baa07ee
fix: scrub URI credentials from telemetry, fix false success on crash…
MathurAditya724 May 26, 2026
d2804b2
fix: clear timer on fatal error, broaden redaction patterns
MathurAditya724 May 26, 2026
78a09d5
fix: guard gracefulKill post-SIGKILL await, scrub error line in terminal
MathurAditya724 May 26, 2026
5664d7c
fix: await close after SIGKILL in cleanupChild, fix formatting
MathurAditya724 May 26, 2026
b4f40c5
fix: read exitCode before cleanup, wrap verifySetup in try-catch
MathurAditya724 May 26, 2026
d98340b
fix: trim script before shell detection, remove stale lint suppressions
MathurAditya724 May 26, 2026
414e0f7
fix: allow digits in env var names in SHELL_FEATURES_RE
MathurAditya724 May 26, 2026
517a76f
fix: correct silent+crash outcome to report exit code
MathurAditya724 May 26, 2026
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
2 changes: 2 additions & 0 deletions plugins/sentry-cli/skills/sentry-cli/references/local.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Run a command with the local dev server enabled
**Flags:**
- `-p, --port <value> - Port for the local server (default 8969) - (default: "8969")`
- `--host <value> - Hostname for the local server (default localhost) - (default: "localhost")`
- `-V, --verify - Verify SDK sends events, then exit`
- `-t, --timeout <value> - Kill the child after N seconds (0 = no timeout) - (default: "0")`

**Examples:**

Expand Down
266 changes: 248 additions & 18 deletions src/commands/local/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
*/

import type { Server } from "node:http";
import { resolve } from "node:path";
import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk";
import type { SentryContext } from "../../context.js";
import { buildCommand } from "../../lib/command.js";
import { detectDevCommand } from "../../lib/dev-script.js";
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
import { CliError, EXIT, ValidationError } from "../../lib/errors.js";
import { bold } from "../../lib/formatters/colors.js";
import { logger } from "../../lib/logger.js";
Expand All @@ -26,6 +28,8 @@
type RunFlags = {
readonly port: number;
readonly host: string;
readonly verify: boolean;
readonly timeout: number;
};
Comment thread
sentry-warden[bot] marked this conversation as resolved.

/** Parse and validate a port number. */
Expand All @@ -41,21 +45,100 @@
}

/** Buffer size for the auto-started background server. */
const BUFFER_SIZE = 500;
export const BUFFER_SIZE = 500;

/**
* Shut down a background server, closing all connections so keep-alive
* sockets (e.g. SSE subscribers) don't block exit.
*/
function shutdownServer(server: Server): Promise<void> {
return new Promise<void>((resolve) => {
server.close(() => resolve());
export function shutdownServer(server: Server): Promise<void> {
return new Promise<void>((done) => {
server.close(() => done());
if (typeof server.closeAllConnections === "function") {
server.closeAllConnections();
}
});
}

/** Parse a timeout value, ensuring it's a non-negative integer. */
function parseTimeout(value: string): number {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) {
throw new ValidationError(
`Invalid timeout: ${value}. Must be a non-negative number.`,
"timeout"
);
Comment thread
MathurAditya724 marked this conversation as resolved.
}
return n;
Comment thread
sentry-warden[bot] marked this conversation as resolved.
}

/**
* Whether the detected command originated from a package.json script.
* Used to decide if `./node_modules/.bin` should be prepended to PATH.
*/
function isPackageJsonSource(source: string): boolean {
return source.startsWith("package.json");
}

/** Augment PATH with `./node_modules/.bin` for Node project scripts. */
function augmentPathForNode(
env: Record<string, string | undefined>,
cwd: string
): Record<string, string | undefined> {
const binDir = resolve(cwd, "node_modules", ".bin");
const sep = process.platform === "win32" ? ";" : ":";
return {
...env,
PATH: `${binDir}${sep}${env.PATH ?? ""}`,
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
};
}

const AUTO_DETECT_ERROR_MESSAGE = [
"No command provided and could not auto-detect a dev script.",
"Usage: sentry local run -- <command>",
"",
"Supported auto-detection:",
" - package.json (scripts: dev, develop, serve, start)",
" - manage.py (Django)",
" - app.py / main.py (Python)",
" - go.mod (Go)",
" - docker-compose.yml / compose.yml (Docker Compose)",
].join("\n");

/** Build the env vars for the child process. */
function buildChildEnv(
spotlightUrl: string,
commandSource: string,
cwd: string
): Record<string, string | undefined> {
let env: Record<string, string | undefined> = {
...process.env,
SENTRY_SPOTLIGHT: spotlightUrl,
NEXT_PUBLIC_SENTRY_SPOTLIGHT: spotlightUrl,
SENTRY_TRACES_SAMPLE_RATE: "1",
};
if (isPackageJsonSource(commandSource)) {
env = augmentPathForNode(env, cwd);
}
return env;
}

/** Resolve args and source — auto-detect from filesystem when no args provided. */
async function resolveArgs(
stripped: string[],
cwd: string
): Promise<{ args: string[]; commandSource: string }> {
if (stripped.length > 0) {
return { args: stripped, commandSource: "" };
Comment thread
sentry-warden[bot] marked this conversation as resolved.
}
const detected = await detectDevCommand(cwd);

Check warning on line 134 in src/commands/local/run.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

SHELL_FEATURES_RE misses lowercase env-var assignments, causing spawn failure

In `dev-script.ts`, the regex `^[A-Z_]+=\S` only detects uppercase env-var assignments (e.g., `PORT=3000 node app.js`); scripts like `port=3000 node server.js` aren't matched, so they're split by whitespace into `["port=3000", "node", "server.js"]` and `Bun.spawn` fails with command-not-found.
if (!detected) {
throw new ValidationError(AUTO_DETECT_ERROR_MESSAGE, "command");
}
logger.info(`Detected ${detected.source}: ${detected.args.join(" ")}`);
Comment thread
sentry-warden[bot] marked this conversation as resolved.
return { args: detected.args, commandSource: detected.source };
}

export const runCommand = buildCommand({
docs: {
brief: "Run a command with the local dev server enabled",
Expand Down Expand Up @@ -93,20 +176,32 @@
brief: "Hostname for the local server (default localhost)",
default: "localhost",
},
verify: {
kind: "boolean",
brief: "Verify SDK sends events, then exit",
default: false,
},
timeout: {
kind: "parsed",
parse: parseTimeout,
brief: "Kill the child after N seconds (0 = no timeout)",
default: "0",
},
},
aliases: {
p: "port",
V: "verify",
t: "timeout",
},
},
auth: false,
async *func(this: SentryContext, flags: RunFlags, ...rawArgs: string[]) {
// Strip leading "--" separator that Stricli passes through
const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
if (args.length === 0) {
throw new ValidationError(
"No command provided. Usage: sentry local run -- <command>",
"command"
);
const stripped = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
const { args, commandSource } = await resolveArgs(stripped, this.cwd);

if (flags.verify) {
yield* runWithVerify(args, flags, this.cwd, commandSource);
return;
Comment thread
sentry-warden[bot] marked this conversation as resolved.
}

let url = `http://${flags.host}:${flags.port}`;
Expand All @@ -131,15 +226,13 @@
logger.info(`Starting: ${bold(args.join(" "))}`);
logger.info(`SENTRY_SPOTLIGHT=${spotlightUrl}`);

const childEnv = buildChildEnv(spotlightUrl, commandSource, this.cwd);
Comment thread
MathurAditya724 marked this conversation as resolved.

let child: ReturnType<typeof Bun.spawn>;
try {
child = Bun.spawn(args, {
env: {
...process.env,
SENTRY_SPOTLIGHT: spotlightUrl,
NEXT_PUBLIC_SENTRY_SPOTLIGHT: spotlightUrl,
SENTRY_TRACES_SAMPLE_RATE: "1",
},
cwd: this.cwd,
env: childEnv,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
Expand All @@ -154,15 +247,26 @@
);
}

// Forward signals to the child so the whole process tree shuts down.
const forwardSignal = (signal: NodeJS.Signals) => {
child.kill(signal);
};
process.once("SIGINT", () => forwardSignal("SIGINT"));
process.once("SIGTERM", () => forwardSignal("SIGTERM"));

let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (flags.timeout > 0) {
timeoutId = setTimeout(() => {
logger.warn(`Timeout: killing child after ${flags.timeout}s`);
child.kill("SIGTERM");
}, flags.timeout * 1000);
}

const exitCode = await child.exited;
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated

if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}

if (bgServer) {
logger.info("Stopping background server...");
await shutdownServer(bgServer);
Expand All @@ -173,3 +277,129 @@
}
},
});

/**
* Run in --verify mode: start a background server, subscribe to the buffer
* for the first envelope, and race between envelope arrival, timeout,
* and child exit.
*/
async function* runWithVerify(
args: string[],
flags: RunFlags,
cwd: string,
commandSource: string
): AsyncGenerator<never, void, unknown> {
const buffer = createSpotlightBuffer(BUFFER_SIZE);
const app = buildApp(buffer);
const { server, port: boundPort } = await tryListen(
app,
flags.port,
flags.host
);
const url = `http://${flags.host}:${boundPort}`;
logger.info(`Verify server listening on ${bold(url)}`);

const spotlightUrl = `${url}/stream`;

const envelopeReceived = new Promise<void>((resolveEnvelope) => {
buffer.subscribe(() => {
resolveEnvelope();
});
});

const childEnv = buildChildEnv(spotlightUrl, commandSource, cwd);

let child: ReturnType<typeof Bun.spawn>;
try {
child = Bun.spawn(args, {
cwd,
env: childEnv,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
});
Comment thread
sentry-warden[bot] marked this conversation as resolved.
} catch (err) {
await shutdownServer(server);
throw new CliError(
`Failed to start "${args[0]}": ${err instanceof Error ? err.message : String(err)}`,
EXIT.GENERAL
);
}

const forwardSignal = (signal: NodeJS.Signals) => {
child.kill(signal);
};
process.once("SIGINT", () => forwardSignal("SIGINT"));
process.once("SIGTERM", () => forwardSignal("SIGTERM"));
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated

const childExited = child.exited.then((code) => ({
kind: "exited" as const,
code,
}));

let timeoutHandle: ReturnType<typeof setTimeout> | undefined;

const racers: Promise<
| { kind: "envelope" }
| { kind: "exited"; code: number }
| { kind: "timeout" }
>[] = [
envelopeReceived.then(() => ({ kind: "envelope" as const })),
childExited,
];

if (flags.timeout > 0) {
racers.push(
new Promise((r) => {
timeoutHandle = setTimeout(
() => r({ kind: "timeout" as const }),
flags.timeout * 1000
);
})
);
}

const outcome = await Promise.race(racers);
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated

if (timeoutHandle !== undefined) {
clearTimeout(timeoutHandle);
}

switch (outcome.kind) {
case "envelope": {
logger.info("Setup verified — your app is sending events to Sentry");
child.kill("SIGTERM");
await shutdownServer(server);
return;
}
case "timeout": {
logger.warn(
`Verification timed out after ${flags.timeout}s — no events received from the SDK`
);
child.kill("SIGTERM");
await shutdownServer(server);
throw new CliError(
`Verification timed out after ${flags.timeout}s`,
EXIT.WIZARD_VERIFY
);
}
case "exited": {
await shutdownServer(server);
if (outcome.code === 0) {
logger.warn("Process exited before sending any events");
throw new CliError(
"Process exited before sending any events",
EXIT.WIZARD_VERIFY
);
}
logger.warn(`Process crashed with code ${outcome.code}`);
throw new CliError(
`Process crashed with code ${outcome.code}`,
outcome.code
);
}
default: {
throw new CliError("Unexpected verification outcome", EXIT.GENERAL);
}
}
}
Loading
Loading