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/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/games.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md new file mode 100644 index 000000000..075fced92 --- /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: false +--- + +# Games Commands + +Terminal games + +### `sentry games snake` + +Play Snake in your terminal + +**Examples:** + +```bash +sentry games snake +``` + +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/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/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/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/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..cb050bac3 --- /dev/null +++ b/packages/cli/src/commands/games/snake.ts @@ -0,0 +1,40 @@ +/** + * sentry games snake + * + * Full-screen Snake. 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"; + +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.", + ); + } + + // A static import would load Ink and its WebAssembly layout engine + // whenever the app starts, which breaks the library SDK build. + const { runSnakeGame } = await import("../../lib/init/ui/ink-ui.js"); + await runSnakeGame(); + }, +}); 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 e5ef0d76a..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; } @@ -875,6 +881,10 @@ export function buildCommand< builderArgs.docs.examples; } + // Introspection reads this to document whether the command needs credentials. + (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 // output and SKILL.md generation. diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index 33dff9e22..859d98d3d 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, @@ -3097,6 +3097,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. * @@ -3109,16 +3122,57 @@ 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`. 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, + alternateScreen: true, + }); +} diff --git a/packages/cli/src/lib/init/ui/ink-snake.tsx b/packages/cli/src/lib/init/ui/ink-snake.tsx index cf5dd225b..bb3c2ec10 100644 --- a/packages/cli/src/lib/init/ui/ink-snake.tsx +++ b/packages/cli/src/lib/init/ui/ink-snake.tsx @@ -77,6 +77,7 @@ const STATUS_HINT: Record = { type SnakeGameProps = { accent: string; + exitAction?: string; muted: string; onCancel: () => void; onExit: () => void; @@ -108,6 +109,7 @@ export function SnakeGame(props: SnakeGameProps): React.ReactNode { function SnakeBoard({ accent, + exitAction = "back to setup", height, muted, onCancel, @@ -192,7 +194,7 @@ function SnakeBoard({ }, { key: "esc", - action: "back to setup", + action: exitAction, priority: 40, match: (input, key) => key.escape || input === "q", run: () => { @@ -202,7 +204,7 @@ function SnakeBoard({ }, }, ], - [apply, height, onCancel, onExit, width], + [apply, exitAction, height, onCancel, onExit, width], ); useInkShortcuts("snake-game", bindings); diff --git a/packages/cli/src/lib/init/ui/ink-ui.ts b/packages/cli/src/lib/init/ui/ink-ui.ts index 9f1501027..b435e2e2f 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,48 @@ export async function createInkUI( } } + 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. + * 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/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index b907bf2a0..94c3f1d76 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[]; + /** Documented credential requirement; independent of when the auth guard runs. */ + __requiresAuth?: boolean; }; /** Positional parameter definitions — either fixed-length tuple or variadic array */ @@ -113,6 +115,8 @@ export type CommandInfo = { positionals: PositionalInfo[]; aliases: Record; examples: string[]; + /** Whether normal operation needs credentials; offline modes may be exempt. */ + 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.__requiresAuth !== false, jsonFields: jsonFields?.length ? jsonFields : undefined, }; } 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/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-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx index 9c280430c..7ba1e8e7b 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 @@ -1391,6 +1391,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..."); @@ -1427,11 +1440,15 @@ describe("snake waiting game", () => { const store = new WizardStore({ snakeEnabled: true }); store.startSpinner("Verifying setup..."); const live = renderLive(store, columns, rows); - 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 }; } @@ -1439,7 +1456,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"); @@ -1452,7 +1472,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 { 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..a2d81b305 --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx @@ -0,0 +1,115 @@ +/** + * 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 }; +} + +/** 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, stdin } = 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(); + 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(); + try { + await sleep(SETTLE_MS); + stdin.push(ESCAPE); + await exitsWithin(instance, 2000); + } finally { + instance.unmount(); + stdin.destroy(); + } + }); +}); 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, + ); +}); diff --git a/packages/cli/test/script/generate-skill-markdown.test.ts b/packages/cli/test/script/generate-skill-markdown.test.ts index 78265a027..f37d30cec 100644 --- a/packages/cli/test/script/generate-skill-markdown.test.ts +++ b/packages/cli/test/script/generate-skill-markdown.test.ts @@ -10,9 +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", () => { @@ -187,3 +193,52 @@ 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"); + }); +}); + +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"); + }, + ); +});