From d2ee8f24ed04c2cff9a699ac2062c45261f9a0cf Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Wed, 7 Oct 2026 09:40:17 -0400 Subject: [PATCH 1/7] feat(cli): Add sentry games snake Make the Snake game from the init waiting screen playable on its own. The command runs full-screen in the alternate screen and restores the terminal on exit. It refuses to run without a TTY or under an AI agent. Co-Authored-By: Claude Opus 5.5 --- .../cli-docs/src/content/docs/contributing.md | 1 + apps/cli-docs/src/fragments/commands/games.md | 11 +++ .../sentry-cli/skills/sentry-cli/SKILL.md | 8 ++ .../skills/sentry-cli/references/games.md | 24 ++++++ packages/cli/src/app.ts | 2 + packages/cli/src/commands/games/index.ts | 12 +++ packages/cli/src/commands/games/snake.ts | 70 +++++++++++++++ packages/cli/src/lib/init/ui/ink-app.tsx | 70 ++++++++++++--- packages/cli/src/lib/init/ui/ink-ui.ts | 25 ++++-- .../cli/test/commands/games/snake.test.ts | 38 +++++++++ .../test/lib/init/ui/ink-snake-app.test.tsx | 85 +++++++++++++++++++ 11 files changed, 326 insertions(+), 20 deletions(-) create mode 100644 apps/cli-docs/src/fragments/commands/games.md create mode 100644 packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md create mode 100644 packages/cli/src/commands/games/index.ts create mode 100644 packages/cli/src/commands/games/snake.ts create mode 100644 packages/cli/test/commands/games/snake.test.ts create mode 100644 packages/cli/test/lib/init/ui/ink-snake-app.test.tsx diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index f73eca9b6..d2d8046cd 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -68,6 +68,7 @@ toolkit/ │ │ │ ├── dsn/ # list │ │ │ ├── event/ # list, send, view │ │ │ ├── feedback/ # list, resolve, spam, unresolve, view +│ │ │ ├── games/ # snake │ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view │ │ │ ├── local/ # run, serve │ │ │ ├── log/ # list, view diff --git a/apps/cli-docs/src/fragments/commands/games.md b/apps/cli-docs/src/fragments/commands/games.md new file mode 100644 index 000000000..7e3f4bd05 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/games.md @@ -0,0 +1,11 @@ +## Examples + +### Play Snake + +```bash +sentry games snake +``` + +Steer with the arrow keys, pause with `p`, retry with `r`, and quit with `esc` +or `q`. The game needs an interactive terminal and is not available when an AI +agent runs the CLI. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index a7b5b8eff..257d25e03 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -641,6 +641,14 @@ Manage User Feedback → Full flags and examples: `references/feedback.md` +### Games + +Terminal games + +- `sentry games snake` — Play Snake in your terminal + +→ Full flags and examples: `references/games.md` + ### Log View Sentry logs diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md new file mode 100644 index 000000000..306643e3e --- /dev/null +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md @@ -0,0 +1,24 @@ +--- +name: sentry-cli-games +version: 0.48.0-dev.0 +description: Terminal games +requires: + bins: ["sentry"] + auth: true +--- + +# Games Commands + +Terminal games + +### `sentry games snake` + +Play Snake in your terminal + +**Examples:** + +```bash +sentry games snake +``` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index f0eff58a1..1220ba5c8 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -26,6 +26,7 @@ import { eventRoute } from "./commands/event/index.js"; import { listCommand as eventListCommand } from "./commands/event/list.js"; import { exploreCommand } from "./commands/explore.js"; import { feedbackRoute } from "./commands/feedback/index.js"; +import { gamesRoute } from "./commands/games/index.js"; import { helpCommand } from "./commands/help.js"; import { infoCommand } from "./commands/info.js"; import { initCommand } from "./commands/init.js"; @@ -141,6 +142,7 @@ export const routes = buildRouteMap({ events: eventListCommand, explore: exploreCommand, feedback: feedbackRoute, + games: gamesRoute, log: logRoute, monitor: monitorRoute, snapshots: snapshotsRoute, diff --git a/packages/cli/src/commands/games/index.ts b/packages/cli/src/commands/games/index.ts new file mode 100644 index 000000000..cf1da1f9e --- /dev/null +++ b/packages/cli/src/commands/games/index.ts @@ -0,0 +1,12 @@ +import { buildRouteMap } from "../../lib/route-map.js"; +import { snakeCommand } from "./snake.js"; + +export const gamesRoute = buildRouteMap({ + routes: { + snake: snakeCommand, + }, + docs: { + brief: "Terminal games", + fullDescription: "Small games to play in your terminal while you wait.", + }, +}); diff --git a/packages/cli/src/commands/games/snake.ts b/packages/cli/src/commands/games/snake.ts new file mode 100644 index 000000000..e097e6e1a --- /dev/null +++ b/packages/cli/src/commands/games/snake.ts @@ -0,0 +1,70 @@ +/** + * sentry games snake + * + * Full-screen Snake in the alternate screen buffer. Renders through the Ink + * sidecar, like the init wizard. + */ + +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { detectAgent } from "../../lib/detect-agent.js"; +import { ValidationError } from "../../lib/errors.js"; +import { + loadInkSidecar, + openFreshTtyForInk, +} from "../../lib/init/ui/ink-ui.js"; + +const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[2J\x1b[H"; +const LEAVE_ALT_SCREEN = "\x1b[?1049l"; + +export const snakeCommand = buildCommand({ + docs: { + brief: "Play Snake in your terminal", + fullDescription: + "Play Snake full-screen in your terminal. Steer with the arrow keys, " + + "pause with `p`, and quit with `esc` or `q`.\n\n" + + "Needs an interactive terminal and is not available when an AI agent " + + "runs the CLI.", + }, + auth: false, + parameters: {}, + // oxlint-disable-next-line require-yield -- the game renders through Ink instead of command output + async *func(this: SentryContext) { + const stdout = this.stdout as { isTTY?: boolean } & typeof this.stdout; + if (!(this.stdin.isTTY && stdout.isTTY)) { + throw new ValidationError("Snake needs an interactive terminal."); + } + if (detectAgent()) { + throw new ValidationError( + "Snake is not available when an AI agent runs the CLI.", + ); + } + + const app = await loadInkSidecar(); + const freshStdin = openFreshTtyForInk(); + this.stdout.write(ENTER_ALT_SCREEN); + let instance: ReturnType | undefined; + try { + instance = app.mountSnakeGame({ + // Ctrl+C is routed through the game's own shortcut so it exits cleanly. + exitOnCtrlC: false, + patchConsole: false, + ...(freshStdin ? { stdin: freshStdin } : {}), + }); + await instance.waitUntilExit(); + } finally { + instance?.unmount(); + this.stdout.write(LEAVE_ALT_SCREEN); + if (freshStdin) { + // oxlint-disable-next-line sentry-cli/no-silent-catch -- best-effort terminal restore + try { + freshStdin.setRawMode(false); + freshStdin.pause(); + freshStdin.destroy(); + } catch { + // stream already torn down + } + } + } + }, +}); diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index b3bd3dcc0..cf71f1c54 100644 --- a/packages/cli/src/lib/init/ui/ink-app.tsx +++ b/packages/cli/src/lib/init/ui/ink-app.tsx @@ -24,7 +24,7 @@ * Tab 2 (Files): Scrollable file read tree */ -import { Box, render as inkRender, Text } from "ink"; +import { Box, render as inkRender, Text, useApp } from "ink"; import Spinner from "ink-spinner"; import { createElement, @@ -3090,6 +3090,19 @@ function MultiSelectPromptOptionRow({ ); } +type MountOptions = { + exitOnCtrlC: boolean; + patchConsole: boolean; + stdin?: import("node:tty").ReadStream; +}; + +type InkInstance = { + unmount: () => void; + waitUntilExit: () => Promise; + rerender: (node: React.ReactNode) => void; + clear: () => void; +}; + /** * Mount the wizard App component via Ink and return the Ink instance. * @@ -3102,16 +3115,49 @@ function MultiSelectPromptOptionRow({ */ export function mountApp( store: WizardStore, - options: { - exitOnCtrlC: boolean; - patchConsole: boolean; - stdin?: import("node:tty").ReadStream; - }, -): { - unmount: () => void; - waitUntilExit: () => Promise; - rerender: (node: React.ReactNode) => void; - clear: () => void; -} { + options: MountOptions, +): InkInstance { return inkRender(createElement(App, { store }), options); } + +/** Full-screen Snake for `sentry games snake`; esc, q, and ctrl+c exit the app. */ +export function SnakeGameApp(): React.ReactNode { + return ( + + + + ); +} + +function SnakeGameScreen(): React.ReactNode { + const { exit } = useApp(); + const { columns, rows } = useInkFrameSize(); + const [session] = useState(createSnakeSession); + const close = useCallback(() => exit(), [exit]); + const width = getInkFrameWidth(columns); + + return ( + + + + + + + ); +} + +/** Mount the standalone Snake game; same sidecar rules as `mountApp`. */ +export function mountSnakeGame(options: MountOptions): InkInstance { + return inkRender(createElement(SnakeGameApp), options); +} diff --git a/packages/cli/src/lib/init/ui/ink-ui.ts b/packages/cli/src/lib/init/ui/ink-ui.ts index 9f1501027..e64ce9778 100644 --- a/packages/cli/src/lib/init/ui/ink-ui.ts +++ b/packages/cli/src/lib/init/ui/ink-ui.ts @@ -241,7 +241,7 @@ import inkAppPath from "./ink-app.tsx" with { type: "file" }; * to `process.stdin` in that case, which works on Node but is * broken in Bun-compiled binaries (see module docstring). */ -function openFreshTtyForInk(): ReadStream | null { +export function openFreshTtyForInk(): ReadStream | null { // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { const fd = openSync("/dev/tty", "r"); @@ -252,14 +252,10 @@ function openFreshTtyForInk(): ReadStream | null { } /** - * Async factory for `InkUI`. Imports `ink`, `react`, and the local - * `App` component lazily, mounts the React tree, and returns the - * bridge instance. Throws if Ink can't be loaded (e.g. missing peer - * deps). + * Load the Ink sidecar bundle for the current runtime context. Both the init + * wizard and `sentry games` mount their UI through it. */ -export async function createInkUI( - opts: CreateInkUIOptions = {}, -): Promise { +export async function loadInkSidecar(): Promise { // Import the Ink App sidecar. Three runtime contexts: // // 1. Node SEA binary: the sidecar is embedded as a SEA asset. @@ -323,6 +319,19 @@ export async function createInkUI( } } + return app; +} + +/** + * Async factory for `InkUI`. Loads the Ink sidecar, mounts the React tree, + * and returns the bridge instance. Throws if Ink can't be loaded (e.g. + * missing peer deps). + */ +export async function createInkUI( + opts: CreateInkUIOptions = {}, +): Promise { + const app = await loadInkSidecar(); + const store = new WizardStore({ cliVersion: CLI_VERSION, // Seed with the full banner; IntroScreen re-fits it to the live terminal diff --git a/packages/cli/test/commands/games/snake.test.ts b/packages/cli/test/commands/games/snake.test.ts new file mode 100644 index 000000000..35406fbd9 --- /dev/null +++ b/packages/cli/test/commands/games/snake.test.ts @@ -0,0 +1,38 @@ +/** + * Tests for `sentry games snake` guard rails. The interactive game itself is + * covered by the sidecar render tests in test/lib/init/ui. + */ + +import { describe, expect, test } from "vitest"; +import { snakeCommand } from "../../../src/commands/games/snake.js"; +import { withEnv } from "../../../src/lib/env.js"; +import { ValidationError } from "../../../src/lib/errors.js"; + +function createContext(isTTY: boolean) { + return { + stdout: { write: () => true, isTTY }, + stdin: { isTTY }, + }; +} + +async function run(isTTY: boolean): Promise { + const func = await snakeCommand.loader(); + await func.call(createContext(isTTY) as never, {}); +} + +describe("games snake", () => { + test("rejects a non-interactive terminal", async () => { + await expect(run(false)).rejects.toThrow( + "Snake needs an interactive terminal.", + ); + await expect(run(false)).rejects.toBeInstanceOf(ValidationError); + }); + + test("rejects when an AI agent runs the CLI", async () => { + await withEnv({ ...process.env, AI_AGENT: "test-agent" }, () => + expect(run(true)).rejects.toThrow( + "Snake is not available when an AI agent runs the CLI.", + ), + ); + }); +}); diff --git a/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx b/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx new file mode 100644 index 000000000..2270efe09 --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx @@ -0,0 +1,85 @@ +/** + * Render test for the standalone Snake app mounted by `sentry games snake`. + */ + +import { Readable, Writable } from "node:stream"; +import { setTimeout as sleep } from "node:timers/promises"; +import { describe, expect, test } from "vitest"; +import { mountSnakeGame } from "../../../../src/lib/init/ui/ink-app.js"; + +// oxlint-disable-next-line no-control-regex -- matching ANSI escape sequences in captured Ink output +const ANSI_RE = /\u001B\[[0-9;?]*[ -/]*[@-~]/g; +const ESCAPE = "\u001B"; +const SETTLE_MS = 150; + +class CaptureStream extends Writable { + chunks: string[] = []; + columns = 100; + rows = 30; + isTTY = true; + _write(chunk: Buffer, _enc: string, cb: () => void): void { + this.chunks.push(chunk.toString()); + cb(); + } + text(): string { + return this.chunks.join("").replace(ANSI_RE, ""); + } +} + +function makeStdin(): Readable { + const s = new Readable({ + read() { + // Keystrokes are pushed by the tests. + }, + }); + return Object.assign(s, { + isTTY: true, + setRawMode: () => s, + resume: () => s, + pause: () => s, + ref: () => s, + unref: () => s, + }); +} + +function mount() { + const out = new CaptureStream(); + const stdin = makeStdin(); + const instance = mountSnakeGame({ + exitOnCtrlC: false, + patchConsole: false, + stdin: stdin as unknown as import("node:tty").ReadStream, + stdout: out, + // Ink writes no live frames in CI unless forced interactive. + interactive: true, + } as Parameters[0]); + return { instance, out, stdin }; +} + +describe("mountSnakeGame", () => { + test("renders the board and the steer hint", async () => { + const { instance, out } = mount(); + try { + await sleep(SETTLE_MS); + const frame = out.text(); + expect(frame).toContain("Bugs squashed"); + expect(frame).toContain("Press an arrow key to start"); + } finally { + instance.unmount(); + } + }); + + test("esc exits the app", async () => { + const { instance, stdin } = mount(); + await sleep(SETTLE_MS); + stdin.push(ESCAPE); + await Promise.race([ + instance.waitUntilExit(), + sleep(2000).then(() => { + throw new Error("Snake did not exit on esc"); + }), + ]); + instance.unmount(); + expect(true).toBe(true); + }); +}); From 59be536fb06adb9b914dcf09228651d282a2040b Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Wed, 7 Oct 2026 10:29:31 -0400 Subject: [PATCH 2/7] fix(cli): Restore the screen on SIGINT and say quit in the Snake footer A ctrl+c that arrives while raw mode is off skipped the finally block and left the terminal in the alternate screen. The standalone game footer also said "back to setup". The render tests now always unmount and cancel their timers, so a failed exit cannot keep the test worker alive. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/games/snake.ts | 5 ++ packages/cli/src/lib/init/ui/ink-app.tsx | 1 + packages/cli/src/lib/init/ui/ink-snake.tsx | 6 ++- .../test/lib/init/ui/ink-snake-app.test.tsx | 52 +++++++++++++++---- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/games/snake.ts b/packages/cli/src/commands/games/snake.ts index e097e6e1a..668c8e401 100644 --- a/packages/cli/src/commands/games/snake.ts +++ b/packages/cli/src/commands/games/snake.ts @@ -44,6 +44,10 @@ export const snakeCommand = buildCommand({ const freshStdin = openFreshTtyForInk(); this.stdout.write(ENTER_ALT_SCREEN); let instance: ReturnType | undefined; + // Node delivers SIGINT instead of ctrl+c input while raw mode is off; the + // default handler would exit before `finally` restores the screen. + const quit = () => instance?.unmount(); + process.on("SIGINT", quit); try { instance = app.mountSnakeGame({ // Ctrl+C is routed through the game's own shortcut so it exits cleanly. @@ -53,6 +57,7 @@ export const snakeCommand = buildCommand({ }); await instance.waitUntilExit(); } finally { + process.removeListener("SIGINT", quit); instance?.unmount(); this.stdout.write(LEAVE_ALT_SCREEN); if (freshStdin) { diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index cf71f1c54..c1338b0dc 100644 --- a/packages/cli/src/lib/init/ui/ink-app.tsx +++ b/packages/cli/src/lib/init/ui/ink-app.tsx @@ -3146,6 +3146,7 @@ function SnakeGameScreen(): React.ReactNode { = { type SnakeGameProps = { accent: string; + exitAction?: string; muted: string; onCancel: () => void; onExit: () => void; @@ -99,6 +100,7 @@ export function SnakeGame(props: SnakeGameProps): React.ReactNode { function SnakeBoard({ accent, + exitAction = "back to setup", height, muted, onCancel, @@ -183,7 +185,7 @@ function SnakeBoard({ }, { key: "esc", - action: "back to setup", + action: exitAction, priority: 40, match: (input, key) => key.escape || input === "q", run: () => { @@ -193,7 +195,7 @@ function SnakeBoard({ }, }, ], - [apply, height, onCancel, onExit, width], + [apply, exitAction, height, onCancel, onExit, width], ); useInkShortcuts("snake-game", bindings); diff --git a/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx b/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx index 2270efe09..a2d81b305 100644 --- a/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx @@ -56,9 +56,27 @@ function mount() { return { instance, out, stdin }; } +/** Settles when the app exits, or rejects after `ms` without leaving a timer behind. */ +async function exitsWithin( + instance: ReturnType["instance"], + ms: number, +): Promise { + const timer = new AbortController(); + try { + await Promise.race([ + instance.waitUntilExit(), + sleep(ms, undefined, { signal: timer.signal }).then(() => { + throw new Error("Snake did not exit on esc"); + }), + ]); + } finally { + timer.abort(); + } +} + describe("mountSnakeGame", () => { test("renders the board and the steer hint", async () => { - const { instance, out } = mount(); + const { instance, out, stdin } = mount(); try { await sleep(SETTLE_MS); const frame = out.text(); @@ -66,20 +84,32 @@ describe("mountSnakeGame", () => { expect(frame).toContain("Press an arrow key to start"); } finally { instance.unmount(); + stdin.destroy(); + } + }); + + test("labels the esc shortcut as quit, not back to setup", async () => { + const { instance, out, stdin } = mount(); + try { + await sleep(SETTLE_MS); + const frame = out.text(); + expect(frame).toContain("quit"); + expect(frame).not.toContain("back to setup"); + } finally { + instance.unmount(); + stdin.destroy(); } }); test("esc exits the app", async () => { const { instance, stdin } = mount(); - await sleep(SETTLE_MS); - stdin.push(ESCAPE); - await Promise.race([ - instance.waitUntilExit(), - sleep(2000).then(() => { - throw new Error("Snake did not exit on esc"); - }), - ]); - instance.unmount(); - expect(true).toBe(true); + try { + await sleep(SETTLE_MS); + stdin.push(ESCAPE); + await exitsWithin(instance, 2000); + } finally { + instance.unmount(); + stdin.destroy(); + } }); }); From 79f0014ffa4da36c592b965a631c334f280c9862 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Wed, 7 Oct 2026 10:57:44 -0400 Subject: [PATCH 3/7] fix(cli): Load the Ink UI lazily in sentry games snake A static import loaded Ink and its WebAssembly layout engine when the app started, so createSentrySDK() failed in the library build. Co-authored-by: Claude --- packages/cli/src/commands/games/snake.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/games/snake.ts b/packages/cli/src/commands/games/snake.ts index 668c8e401..f577fb0cb 100644 --- a/packages/cli/src/commands/games/snake.ts +++ b/packages/cli/src/commands/games/snake.ts @@ -9,10 +9,6 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { detectAgent } from "../../lib/detect-agent.js"; import { ValidationError } from "../../lib/errors.js"; -import { - loadInkSidecar, - openFreshTtyForInk, -} from "../../lib/init/ui/ink-ui.js"; const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[2J\x1b[H"; const LEAVE_ALT_SCREEN = "\x1b[?1049l"; @@ -40,6 +36,10 @@ export const snakeCommand = buildCommand({ ); } + // A static import would load Ink and its WebAssembly layout engine + // whenever the app starts, which breaks the library SDK build. + const { loadInkSidecar, openFreshTtyForInk } = + await import("../../lib/init/ui/ink-ui.js"); const app = await loadInkSidecar(); const freshStdin = openFreshTtyForInk(); this.stdout.write(ENTER_ALT_SCREEN); From 0ee330fa7dba2563e4a24a88a50d26e6955a9012 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Wed, 7 Oct 2026 11:25:28 -0400 Subject: [PATCH 4/7] test(cli): Wait for the expected Snake frame instead of one settle window The esc test failed on a slow CI runner because Ink had not applied the key within the fixed 80 ms wait. Co-authored-by: Claude --- .../lib/init/ui/ink-app.snapshot.test.tsx | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx index f514be6d8..287d871ca 100644 --- a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -1392,6 +1392,19 @@ describe("snake waiting game", () => { return stripAnsi(out.latestFrame()); } + // Slow CI runners can need more than one settle window to apply a key. + async function frameWhere( + out: CaptureStream, + predicate: (frame: string) => boolean, + ): Promise { + const deadline = Date.now() + 2000; + let frame = await settledFrame(out); + while (!predicate(frame) && Date.now() < deadline) { + frame = await settledFrame(out); + } + return frame; + } + test("stays hidden when the game is disabled", async () => { const store = new WizardStore(); store.startSpinner("Verifying setup..."); @@ -1425,11 +1438,15 @@ describe("snake waiting game", () => { const store = new WizardStore({ snakeEnabled: true }); store.startSpinner("Verifying setup..."); const live = renderLive(store); - expect(await settledFrame(live.out)).toContain("to play Snake"); + expect( + await frameWhere(live.out, (frame) => frame.includes("to play Snake")), + ).toContain("to play Snake"); live.stdin.push("g"); await sleep(20); live.stdin.push("\u001B[A"); - expect(await settledFrame(live.out)).toContain("Bugs squashed"); + expect( + await frameWhere(live.out, (frame) => frame.includes("Bugs squashed")), + ).toContain("Bugs squashed"); return { ...live, store }; } @@ -1437,7 +1454,10 @@ describe("snake waiting game", () => { const { instance, out, store } = await startGame(); try { store.appendLog("warn", "Could not verify setup: app failed to start"); - const frame = await settledFrame(out); + const frame = await frameWhere( + out, + (current) => !current.includes("Bugs squashed"), + ); expect(frame).toContain("Could not verify setup"); expect(frame).not.toContain("Bugs squashed"); expect(frame).toContain("to resume Snake"); @@ -1450,7 +1470,10 @@ describe("snake waiting game", () => { const { instance, out, stdin } = await startGame(); try { stdin.push("\u001B"); - const frame = await settledFrame(out); + const frame = await frameWhere( + out, + (current) => !current.includes("Bugs squashed"), + ); expect(frame).not.toContain("Bugs squashed"); expect(frame).toContain("to resume Snake"); } finally { From 6c5096f2b108a7f0c381f40030b549e0d06d5fe7 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Thu, 8 Oct 2026 12:53:44 -0400 Subject: [PATCH 5/7] fix(cli): Let Ink own the Snake terminal lifecycle Move the Snake render and teardown out of the command into a UI-layer runSnakeGame, mirroring how init delegates to the Ink sidecar. The game now mounts with Ink's alternateScreen option, so Ink enters and restores the primary screen itself and the manual escape sequences and SIGINT handler are gone. SIGTERM now restores the primary screen too. Add a child-process regression test that checks the primary screen returns on q, Ctrl+C, SIGINT, and SIGTERM. Co-authored-by: Claude --- packages/cli/src/commands/games/snake.ts | 41 +-------- packages/cli/src/lib/init/ui/ink-app.tsx | 11 ++- packages/cli/src/lib/init/ui/ink-ui.ts | 29 +++++++ .../lib/init/ui/fixtures/snake-lifecycle.tsx | 37 ++++++++ .../lib/init/ui/ink-snake-lifecycle.test.ts | 85 +++++++++++++++++++ 5 files changed, 163 insertions(+), 40 deletions(-) create mode 100644 packages/cli/test/lib/init/ui/fixtures/snake-lifecycle.tsx create mode 100644 packages/cli/test/lib/init/ui/ink-snake-lifecycle.test.ts diff --git a/packages/cli/src/commands/games/snake.ts b/packages/cli/src/commands/games/snake.ts index f577fb0cb..cb050bac3 100644 --- a/packages/cli/src/commands/games/snake.ts +++ b/packages/cli/src/commands/games/snake.ts @@ -1,8 +1,7 @@ /** * sentry games snake * - * Full-screen Snake in the alternate screen buffer. Renders through the Ink - * sidecar, like the init wizard. + * Full-screen Snake. Renders through the Ink sidecar, like the init wizard. */ import type { SentryContext } from "../../context.js"; @@ -10,9 +9,6 @@ import { buildCommand } from "../../lib/command.js"; import { detectAgent } from "../../lib/detect-agent.js"; import { ValidationError } from "../../lib/errors.js"; -const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[2J\x1b[H"; -const LEAVE_ALT_SCREEN = "\x1b[?1049l"; - export const snakeCommand = buildCommand({ docs: { brief: "Play Snake in your terminal", @@ -38,38 +34,7 @@ export const snakeCommand = buildCommand({ // A static import would load Ink and its WebAssembly layout engine // whenever the app starts, which breaks the library SDK build. - const { loadInkSidecar, openFreshTtyForInk } = - await import("../../lib/init/ui/ink-ui.js"); - const app = await loadInkSidecar(); - const freshStdin = openFreshTtyForInk(); - this.stdout.write(ENTER_ALT_SCREEN); - let instance: ReturnType | undefined; - // Node delivers SIGINT instead of ctrl+c input while raw mode is off; the - // default handler would exit before `finally` restores the screen. - const quit = () => instance?.unmount(); - process.on("SIGINT", quit); - try { - instance = app.mountSnakeGame({ - // Ctrl+C is routed through the game's own shortcut so it exits cleanly. - exitOnCtrlC: false, - patchConsole: false, - ...(freshStdin ? { stdin: freshStdin } : {}), - }); - await instance.waitUntilExit(); - } finally { - process.removeListener("SIGINT", quit); - instance?.unmount(); - this.stdout.write(LEAVE_ALT_SCREEN); - if (freshStdin) { - // oxlint-disable-next-line sentry-cli/no-silent-catch -- best-effort terminal restore - try { - freshStdin.setRawMode(false); - freshStdin.pause(); - freshStdin.destroy(); - } catch { - // stream already torn down - } - } - } + const { runSnakeGame } = await import("../../lib/init/ui/ink-ui.js"); + await runSnakeGame(); }, }); diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index c1338b0dc..357a6d7cf 100644 --- a/packages/cli/src/lib/init/ui/ink-app.tsx +++ b/packages/cli/src/lib/init/ui/ink-app.tsx @@ -3158,7 +3158,14 @@ function SnakeGameScreen(): React.ReactNode { ); } -/** Mount the standalone Snake game; same sidecar rules as `mountApp`. */ +/** + * Mount the standalone Snake game; same sidecar rules as `mountApp`. Ink owns + * the alternate screen, so it also restores the primary screen on exit and on + * SIGINT/SIGTERM. + */ export function mountSnakeGame(options: MountOptions): InkInstance { - return inkRender(createElement(SnakeGameApp), options); + return inkRender(createElement(SnakeGameApp), { + ...options, + alternateScreen: true, + }); } diff --git a/packages/cli/src/lib/init/ui/ink-ui.ts b/packages/cli/src/lib/init/ui/ink-ui.ts index e64ce9778..b435e2e2f 100644 --- a/packages/cli/src/lib/init/ui/ink-ui.ts +++ b/packages/cli/src/lib/init/ui/ink-ui.ts @@ -322,6 +322,35 @@ export async function loadInkSidecar(): Promise { return app; } +/** + * Run the standalone Snake game until the player quits. Ink handles the + * alternate screen and restores it on exit and on SIGINT/SIGTERM. + */ +export async function runSnakeGame(): Promise { + const app = await loadInkSidecar(); + const freshStdin = openFreshTtyForInk(); + try { + const instance = app.mountSnakeGame({ + // Ctrl+C is routed through the game's own shortcut so it exits cleanly. + exitOnCtrlC: false, + patchConsole: false, + ...(freshStdin ? { stdin: freshStdin } : {}), + }); + await instance.waitUntilExit(); + } finally { + if (freshStdin) { + // oxlint-disable-next-line sentry-cli/no-silent-catch -- best-effort terminal restore + try { + freshStdin.setRawMode(false); + freshStdin.pause(); + freshStdin.destroy(); + } catch { + // stream already torn down + } + } + } +} + /** * Async factory for `InkUI`. Loads the Ink sidecar, mounts the React tree, * and returns the bridge instance. Throws if Ink can't be loaded (e.g. diff --git a/packages/cli/test/lib/init/ui/fixtures/snake-lifecycle.tsx b/packages/cli/test/lib/init/ui/fixtures/snake-lifecycle.tsx new file mode 100644 index 000000000..aa202ded7 --- /dev/null +++ b/packages/cli/test/lib/init/ui/fixtures/snake-lifecycle.tsx @@ -0,0 +1,37 @@ +/** + * Child process for ink-snake-lifecycle.test.ts. Mounts the Snake game on a + * fake TTY that writes to the real stdout, so the parent can watch the + * alternate-screen escape sequences. Keys arrive through the real stdin pipe. + */ + +import { writeSync } from "node:fs"; +import { Writable } from "node:stream"; +import { mountSnakeGame } from "../../../../../src/lib/init/ui/ink-app.js"; + +class ForwardingTty extends Writable { + columns = 100; + rows = 30; + isTTY = true; + _write(chunk: Buffer, _enc: string, cb: () => void): void { + // Synchronous, like a real TTY: a signal teardown must not lose writes. + writeSync(1, chunk); + cb(); + } +} + +const stdin = Object.assign(process.stdin, { + isTTY: true, + setRawMode: () => process.stdin, +}); + +const instance = mountSnakeGame({ + exitOnCtrlC: false, + patchConsole: false, + stdin: stdin as unknown as import("node:tty").ReadStream, + stdout: new ForwardingTty(), + interactive: true, +} as Parameters[0]); + +await instance.waitUntilExit(); +process.stdout.write("EXITED\n"); +process.exit(0); diff --git a/packages/cli/test/lib/init/ui/ink-snake-lifecycle.test.ts b/packages/cli/test/lib/init/ui/ink-snake-lifecycle.test.ts new file mode 100644 index 000000000..27183e5b2 --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-snake-lifecycle.test.ts @@ -0,0 +1,85 @@ +/** + * Terminal lifecycle of the standalone Snake game. Each case runs the game in + * a child process and checks that the primary screen comes back however the + * game ends, including signals that kill the process. + */ + +import { type ChildProcess, spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +const FIXTURE = fileURLToPath( + new URL("./fixtures/snake-lifecycle.tsx", import.meta.url), +); +const TSX = fileURLToPath( + new URL("../../../../node_modules/.bin/tsx", import.meta.url), +); +const ENTER_ALT_SCREEN = "\u001B[?1049h"; +const LEAVE_ALT_SCREEN = "\u001B[?1049l"; +const TIMEOUT_MS = 20_000; + +type Result = { output: string; code: number | null; signal: string | null }; + +/** Runs the game, waits until it is on the alternate screen, then ends it with `end`. */ +function runGame(end: (child: ChildProcess) => void): Promise { + return new Promise((resolve, reject) => { + const child = spawn(TSX, [FIXTURE], { stdio: ["pipe", "pipe", "inherit"] }); + let output = ""; + let ended = false; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`Snake did not finish. Output: ${output}`)); + }, TIMEOUT_MS); + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + if (!ended && output.includes(ENTER_ALT_SCREEN)) { + ended = true; + // Let the first frame render before ending the game. + setTimeout(() => end(child), 300); + } + }); + child.on("error", reject); + child.on("close", (code, signal) => { + clearTimeout(timer); + resolve({ output, code, signal }); + }); + }); +} + +function expectPrimaryScreenRestored(output: string): void { + expect(output.lastIndexOf(LEAVE_ALT_SCREEN)).toBeGreaterThan( + output.lastIndexOf(ENTER_ALT_SCREEN), + ); +} + +describe("snake terminal lifecycle", () => { + test("enters the alternate screen on start", async () => { + const { output } = await runGame((child) => child.kill("SIGTERM")); + expect(output).toContain(ENTER_ALT_SCREEN); + }, 30_000); + + test("q returns to the primary screen", async () => { + const { output, code } = await runGame((child) => child.stdin?.write("q")); + expectPrimaryScreenRestored(output); + expect(output).toContain("EXITED"); + expect(code).toBe(0); + }, 30_000); + + test("ctrl+c returns to the primary screen", async () => { + const { output, code } = await runGame((child) => + child.stdin?.write("\u0003"), + ); + expectPrimaryScreenRestored(output); + expect(output).toContain("EXITED"); + expect(code).toBe(0); + }, 30_000); + + test.each(["SIGINT", "SIGTERM"] as const)( + "%s returns to the primary screen", + async (signal) => { + const { output } = await runGame((child) => child.kill(signal)); + expectPrimaryScreenRestored(output); + }, + 30_000, + ); +}); From 18ad5b41362b139265cc3299f7a6cfed0c1d71b7 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Thu, 8 Oct 2026 12:53:47 -0400 Subject: [PATCH 6/7] fix(cli): Generate accurate auth and global flags in command references Carry the buildCommand auth option into introspection so the skill reference front matter reports auth from the commands themselves. List --json and --fields in the reference footer, and in the docs footer, only when a command in the group accepts them. Regenerate the skill references. Co-authored-by: Claude --- .../skills/sentry-cli/references/cli.md | 2 +- .../sentry-cli/references/dart-symbol-map.md | 2 +- .../sentry-cli/references/debug-files.md | 2 +- .../skills/sentry-cli/references/games.md | 4 +-- .../skills/sentry-cli/references/info.md | 2 +- .../skills/sentry-cli/references/init.md | 2 +- .../skills/sentry-cli/references/local.md | 4 +-- .../skills/sentry-cli/references/platform.md | 2 +- .../skills/sentry-cli/references/proguard.md | 2 +- .../skills/sentry-cli/references/schema.md | 2 +- .../skills/sentry-cli/references/status.md | 2 +- .../sentry-cli/references/wasm-split.md | 2 +- packages/cli/script/generate-command-docs.ts | 22 ++++++++++++--- packages/cli/script/generate-skill.ts | 20 +++++++++++--- packages/cli/src/lib/command.ts | 3 +++ packages/cli/src/lib/introspect.ts | 5 ++++ .../script/generate-skill-markdown.test.ts | 27 +++++++++++++++++++ 17 files changed, 83 insertions(+), 22 deletions(-) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md index 3025e135f..c51e5e72d 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: CLI-related commands requires: bins: ["sentry"] - auth: true + auth: false --- # CLI Commands diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md index 5179747fa..d7cbaed2d 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Work with Dart/Flutter symbol maps requires: bins: ["sentry"] - auth: true + auth: false --- # Dart-symbol-map Commands diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md index 06ad6b1dd..124c64e8d 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Work with debug information files requires: bins: ["sentry"] - auth: true + auth: false --- # Debug-files Commands diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md index 306643e3e..075fced92 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Terminal games requires: bins: ["sentry"] - auth: true + auth: false --- # Games Commands @@ -21,4 +21,4 @@ Play Snake in your terminal sentry games snake ``` -All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. +All commands also support `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/info.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/info.md index 3d81d9d71..cff0437b3 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/info.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/info.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Print configuration and verify authentication requires: bins: ["sentry"] - auth: true + auth: false --- # Info Commands diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md index d04ccc67c..77726054e 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md @@ -51,4 +51,4 @@ sentry init acme/ --team backend sentry init --features profiling,replay ``` -All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. +All commands also support `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index df92efaab..154c9819c 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Sentry for local development requires: bins: ["sentry"] - auth: true + auth: false --- # Local Commands @@ -86,4 +86,4 @@ curl -X POST http://127.0.0.1:3030/api/agent/run \ curl -i http://127.0.0.1:3030/api/broken ``` -All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. +All commands also support `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/platform.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/platform.md index c684a5a08..d410afabe 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/platform.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/platform.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: List valid Sentry platform identifiers requires: bins: ["sentry"] - auth: true + auth: false --- # Platform Commands diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md index 98d82f876..6bdc067bb 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Work with ProGuard/R8 mapping files requires: bins: ["sentry"] - auth: true + auth: false --- # Proguard Commands diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md index 4d4f4abb8..58f9234d0 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Browse the Sentry API schema requires: bins: ["sentry"] - auth: true + auth: false --- # Schema Commands diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md index 86ce11bcd..1c651ccfe 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Check Sentry service status requires: bins: ["sentry"] - auth: true + auth: false --- # Status Commands diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/wasm-split.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/wasm-split.md index 33179e2cb..8da85ad5c 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/wasm-split.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/wasm-split.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Add build ids to WebAssembly modules and split out debug data requires: bins: ["sentry"] - auth: true + auth: false --- # Wasm-split Commands diff --git a/packages/cli/script/generate-command-docs.ts b/packages/cli/script/generate-command-docs.ts index e09d28d39..d36cdc116 100644 --- a/packages/cli/script/generate-command-docs.ts +++ b/packages/cli/script/generate-command-docs.ts @@ -236,10 +236,11 @@ function generatePage(route: RouteInfo): string { } // Global flags footer - lines.push( - "All commands support `--json` for machine-readable output and `--fields` to select specific JSON fields.", - ); - lines.push(""); + const jsonFooter = formatJsonFooter(route); + if (jsonFooter) { + lines.push(jsonFooter); + lines.push(""); + } // End marker lines.push(GENERATED_END_MARKER); @@ -247,6 +248,19 @@ function generatePage(route: RouteInfo): string { return lines.join("\n"); } +/** Footer for the JSON flags; empty when no command in the route accepts them. */ +function formatJsonFooter(route: RouteInfo): string { + const accepted = (name: string) => + route.commands.some((cmd) => cmd.flags.some((f) => f.name === name)); + if (accepted("json") && accepted("fields")) { + return "All commands support `--json` for machine-readable output and `--fields` to select specific JSON fields."; + } + if (accepted("json")) { + return "All commands support `--json` for machine-readable output."; + } + return ""; +} + /** Known acronyms that should be fully uppercased in titles */ const ACRONYMS = new Set(["api", "cli"]); diff --git a/packages/cli/script/generate-skill.ts b/packages/cli/script/generate-skill.ts index 2ebe69b2b..94f5810f0 100644 --- a/packages/cli/script/generate-skill.ts +++ b/packages/cli/script/generate-skill.ts @@ -616,6 +616,20 @@ function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } +/** List the global flags that at least one command in the route accepts. */ +function formatGlobalFlagList(route: RouteInfo): string { + const accepted = (name: string) => + route.commands.some((cmd) => cmd.flags.some((f) => f.name === name)); + const names = [ + ...["json", "fields"].filter(accepted), + "help", + "log-level", + "verbose", + ]; + const flags = names.map((name) => `\`--${name}\``); + return `${flags.slice(0, -1).join(", ")}, and ${flags.at(-1)}`; +} + /** * Generate a complete reference file for a single route. * @@ -644,7 +658,7 @@ function generateReferenceFile( lines.push(`description: ${description}`); lines.push("requires:"); lines.push(' bins: ["sentry"]'); - lines.push(" auth: true"); + lines.push(` auth: ${route.commands.some((cmd) => cmd.requiresAuth)}`); lines.push("---"); lines.push(""); @@ -661,9 +675,7 @@ function generateReferenceFile( } // Note about global flags - lines.push( - "All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.", - ); + lines.push(`All commands also support ${formatGlobalFlagList(route)} flags.`); lines.push(""); return lines.join("\n"); diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index e5ef0d76a..7c93b25ab 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -875,6 +875,9 @@ export function buildCommand< builderArgs.docs.examples; } + // Introspection reads this to document whether the command needs credentials. + (cmd as unknown as Record).__auth = builderArgs.auth; + // Attach the JSON schema to the built command as a non-standard property. // introspect.ts reads this to populate CommandInfo.jsonFields for help // output and SKILL.md generation. diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index b907bf2a0..666f87025 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -61,6 +61,8 @@ export type Command = { */ __primaryUsage?: string; __examples?: readonly CommandExample[]; + /** The `auth` option passed to `buildCommand`; unset means credentials are required. */ + __auth?: boolean | "dsn"; }; /** Positional parameter definitions — either fixed-length tuple or variadic array */ @@ -113,6 +115,8 @@ export type CommandInfo = { positionals: PositionalInfo[]; aliases: Record; examples: string[]; + /** False for commands built with `auth: false`; they run without credentials. */ + requiresAuth: boolean; /** JSON output field metadata extracted from `OutputConfig.schema` */ jsonFields?: SchemaFieldInfo[]; }; @@ -317,6 +321,7 @@ export function buildCommandInfo( ({ description, command }) => `# ${description}\n${command}`, ) : examples, + requiresAuth: cmd.__auth !== false, jsonFields: jsonFields?.length ? jsonFields : undefined, }; } diff --git a/packages/cli/test/script/generate-skill-markdown.test.ts b/packages/cli/test/script/generate-skill-markdown.test.ts index 78265a027..b60210d4f 100644 --- a/packages/cli/test/script/generate-skill-markdown.test.ts +++ b/packages/cli/test/script/generate-skill-markdown.test.ts @@ -11,6 +11,7 @@ import { import { listCommand } from "../../src/commands/agent-conversation/list.js"; import { viewCommand } from "../../src/commands/agent-conversation/view.js"; import { sendCommand } from "../../src/commands/event/send.js"; +import { snakeCommand } from "../../src/commands/games/snake.js"; import { mergeCommand } from "../../src/commands/issue/merge.js"; import { createCommand } from "../../src/commands/project/create.js"; import { buildCommandInfo } from "../../src/lib/introspect.js"; @@ -187,3 +188,29 @@ test("published skill matches the plugin and links to its references", async () await realpath(`${plugin}/references`), ); }); + +describe("games reference", () => { + const reference = () => + readFile( + "plugins/sentry-cli/skills/sentry-cli/references/games.md", + "utf8", + ); + + test("marks commands built with auth: false as not requiring auth", () => { + expect( + buildCommandInfo(snakeCommand as never, "sentry games snake") + .requiresAuth, + ).toBe(false); + expect( + buildCommandInfo(createCommand as never, "sentry project create") + .requiresAuth, + ).toBe(true); + }); + + test("does not advertise auth or JSON flags that snake lacks", async () => { + const content = await reference(); + expect(content).toContain("auth: false"); + expect(content).not.toContain("--json"); + expect(content).not.toContain("--fields"); + }); +}); From c738e37eca359ebf1b6c40219d9cd28168d00b42 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 8 Oct 2026 20:55:53 +0200 Subject: [PATCH 7/7] fix(cli): Document credentials for deferred auth commands --- .../sentry-cli/references/dart-symbol-map.md | 2 +- .../sentry-cli/references/debug-files.md | 2 +- .../skills/sentry-cli/references/proguard.md | 2 +- packages/cli/src/commands/auth/refresh.ts | 1 + .../src/commands/dart-symbol-map/upload.ts | 1 + .../cli/src/commands/debug-files/upload.ts | 1 + packages/cli/src/commands/proguard/upload.ts | 1 + .../cli/src/commands/react-native/xcode.ts | 1 + packages/cli/src/lib/command.ts | 13 +++++++-- packages/cli/src/lib/introspect.ts | 8 +++--- .../script/generate-skill-markdown.test.ts | 28 +++++++++++++++++++ 11 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md index d7cbaed2d..5179747fa 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Work with Dart/Flutter symbol maps requires: bins: ["sentry"] - auth: false + auth: true --- # Dart-symbol-map Commands diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md index 124c64e8d..06ad6b1dd 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Work with debug information files requires: bins: ["sentry"] - auth: false + auth: true --- # Debug-files Commands diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md index 6bdc067bb..98d82f876 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md @@ -4,7 +4,7 @@ version: 0.48.0-dev.0 description: Work with ProGuard/R8 mapping files requires: bins: ["sentry"] - auth: false + auth: true --- # Proguard Commands diff --git a/packages/cli/src/commands/auth/refresh.ts b/packages/cli/src/commands/auth/refresh.ts index 20c99059b..edb878c83 100644 --- a/packages/cli/src/commands/auth/refresh.ts +++ b/packages/cli/src/commands/auth/refresh.ts @@ -87,6 +87,7 @@ function formatRefreshResult(data: RefreshOutput): string { export const refreshCommand = buildCommand({ auth: false, docs: { + requiresAuth: true, brief: "Refresh your OAuth access token", fullDescription: ` Manually refresh your OAuth access token using the stored refresh token. diff --git a/packages/cli/src/commands/dart-symbol-map/upload.ts b/packages/cli/src/commands/dart-symbol-map/upload.ts index c86ad33dc..f05864bf0 100644 --- a/packages/cli/src/commands/dart-symbol-map/upload.ts +++ b/packages/cli/src/commands/dart-symbol-map/upload.ts @@ -159,6 +159,7 @@ export const uploadCommand = buildCommand({ // The upload path calls resolveOrgAndProject which triggers auth. auth: false, docs: { + requiresAuth: true, brief: "Upload a Dart/Flutter symbol map to Sentry", fullDescription: "Upload a Dart/Flutter obfuscation map for deobfuscating Dart exception " + diff --git a/packages/cli/src/commands/debug-files/upload.ts b/packages/cli/src/commands/debug-files/upload.ts index 2477063e6..e7a25179f 100644 --- a/packages/cli/src/commands/debug-files/upload.ts +++ b/packages/cli/src/commands/debug-files/upload.ts @@ -523,6 +523,7 @@ export const uploadCommand = buildCommand({ // resolveOrgAndProject which triggers auth resolution. auth: false, docs: { + requiresAuth: true, brief: "Upload debug information files to Sentry", fullDescription: "Scan files and directories for native debug information files and " + diff --git a/packages/cli/src/commands/proguard/upload.ts b/packages/cli/src/commands/proguard/upload.ts index 97632cc2f..9f8b02605 100644 --- a/packages/cli/src/commands/proguard/upload.ts +++ b/packages/cli/src/commands/proguard/upload.ts @@ -151,6 +151,7 @@ export const uploadCommand = buildCommand({ // The upload path calls resolveOrgAndProject which triggers auth. auth: false, docs: { + requiresAuth: true, brief: "Upload ProGuard/R8 mapping files to Sentry", fullDescription: "Upload one or more ProGuard/R8 mapping files to Sentry using " + diff --git a/packages/cli/src/commands/react-native/xcode.ts b/packages/cli/src/commands/react-native/xcode.ts index 05dd18bcd..e1cd9a184 100644 --- a/packages/cli/src/commands/react-native/xcode.ts +++ b/packages/cli/src/commands/react-native/xcode.ts @@ -421,6 +421,7 @@ async function preparePair( export const xcodeCommand = buildCommand({ docs: { + requiresAuth: true, brief: "Upload React Native sourcemaps (Xcode build step)", fullDescription: "Upload React Native sourcemaps from an Xcode build phase. In a release " + diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 7c93b25ab..27a5800fb 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -101,11 +101,17 @@ export type CommandExample = { }; /** - * Native Stricli documentation plus canonical examples for generated docs. + * Native Stricli documentation plus metadata for generated docs. */ export type CommandDocumentation = StricliBuilderArgs["docs"] & { readonly examples?: readonly CommandExample[]; + /** + * Credential requirement for documentation; defaults to the `auth` option. + * Set to true when `auth: false` only defers authentication, e.g. to allow + * an offline dry run. This does not change the runtime auth guard. + */ + readonly requiresAuth?: boolean; }; /** @@ -436,7 +442,7 @@ function enrichDocsWithSchema( function prepareNativeDocs( docs: CommandDocumentation, ): StricliBuilderArgs["docs"] { - const { examples, ...nativeDocs } = docs; + const { examples, requiresAuth: _requiresAuth, ...nativeDocs } = docs; if (!examples?.length) { return nativeDocs; } @@ -876,7 +882,8 @@ export function buildCommand< } // Introspection reads this to document whether the command needs credentials. - (cmd as unknown as Record).__auth = builderArgs.auth; + (cmd as unknown as Record).__requiresAuth = + builderArgs.docs.requiresAuth ?? builderArgs.auth !== false; // Attach the JSON schema to the built command as a non-standard property. // introspect.ts reads this to populate CommandInfo.jsonFields for help diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index 666f87025..94c3f1d76 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -61,8 +61,8 @@ export type Command = { */ __primaryUsage?: string; __examples?: readonly CommandExample[]; - /** The `auth` option passed to `buildCommand`; unset means credentials are required. */ - __auth?: boolean | "dsn"; + /** Documented credential requirement; independent of when the auth guard runs. */ + __requiresAuth?: boolean; }; /** Positional parameter definitions — either fixed-length tuple or variadic array */ @@ -115,7 +115,7 @@ export type CommandInfo = { positionals: PositionalInfo[]; aliases: Record; examples: string[]; - /** False for commands built with `auth: false`; they run without credentials. */ + /** Whether normal operation needs credentials; offline modes may be exempt. */ requiresAuth: boolean; /** JSON output field metadata extracted from `OutputConfig.schema` */ jsonFields?: SchemaFieldInfo[]; @@ -321,7 +321,7 @@ export function buildCommandInfo( ({ description, command }) => `# ${description}\n${command}`, ) : examples, - requiresAuth: cmd.__auth !== false, + requiresAuth: cmd.__requiresAuth !== false, jsonFields: jsonFields?.length ? jsonFields : undefined, }; } diff --git a/packages/cli/test/script/generate-skill-markdown.test.ts b/packages/cli/test/script/generate-skill-markdown.test.ts index b60210d4f..f37d30cec 100644 --- a/packages/cli/test/script/generate-skill-markdown.test.ts +++ b/packages/cli/test/script/generate-skill-markdown.test.ts @@ -10,10 +10,15 @@ import { } from "../../script/generate-skill-markdown.js"; import { listCommand } from "../../src/commands/agent-conversation/list.js"; import { viewCommand } from "../../src/commands/agent-conversation/view.js"; +import { refreshCommand } from "../../src/commands/auth/refresh.js"; +import { uploadCommand as dartUploadCommand } from "../../src/commands/dart-symbol-map/upload.js"; +import { uploadCommand as debugUploadCommand } from "../../src/commands/debug-files/upload.js"; import { sendCommand } from "../../src/commands/event/send.js"; import { snakeCommand } from "../../src/commands/games/snake.js"; import { mergeCommand } from "../../src/commands/issue/merge.js"; import { createCommand } from "../../src/commands/project/create.js"; +import { uploadCommand as proguardUploadCommand } from "../../src/commands/proguard/upload.js"; +import { xcodeCommand } from "../../src/commands/react-native/xcode.js"; import { buildCommandInfo } from "../../src/lib/introspect.js"; describe("extractCommandPathFromHeading", () => { @@ -214,3 +219,26 @@ describe("games reference", () => { expect(content).not.toContain("--fields"); }); }); + +describe("conditional authentication documentation", () => { + test.each([ + ["dart-symbol-map", "upload", dartUploadCommand], + ["debug-files", "upload", debugUploadCommand], + ["proguard", "upload", proguardUploadCommand], + ["react-native", "xcode", xcodeCommand], + ["auth", "refresh", refreshCommand], + ] as const)( + "documents credentials for %s %s despite deferring the auth guard", + async (group, command, definition) => { + expect( + buildCommandInfo(definition as never, `sentry ${group} ${command}`) + .requiresAuth, + ).toBe(true); + const reference = await readFile( + `plugins/sentry-cli/skills/sentry-cli/references/${group}.md`, + "utf8", + ); + expect(reference).toContain(" auth: true\n"); + }, + ); +});