diff --git a/.github/dependency-review-config.yml b/.github/dependency-review-config.yml index 82760906c..92fc30ea5 100644 --- a/.github/dependency-review-config.yml +++ b/.github/dependency-review-config.yml @@ -1,8 +1,4 @@ # React Router's advisory only affects applications using its unstable RSC APIs. # This application uses the stable browser router and has no RSC integration. -# provider-utils 2.2.8 retains APIs required by ui-utils 1.2.11. Its checked-in -# pnpm patch backports the upstream bounded-response fix, and check:patches -# exercises oversized-body rejection and cancellation against the nested copy. allow-ghsas: - GHSA-qwww-vcr4-c8h2 - - GHSA-866g-f22w-33x8 diff --git a/.oxlintrc.json b/.oxlintrc.json index 08e9b21a1..0b8532eb7 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -13,8 +13,7 @@ "**/worker-configuration.d.ts", "**/tsconfig*.json", "packages/mcp-server-mocks/src/fixtures/**", - "packages/codemod-cli3to4/tests/**", - "packages/cli/test/init-eval/templates/sveltekit-app/**" + "packages/codemod-cli3to4/tests/**" ], "rules": { "no-unused-vars": "off", diff --git a/apps/cli-docs/public/get-started-with-sentry.md b/apps/cli-docs/public/get-started-with-sentry.md new file mode 120000 index 000000000..6ee7aefd4 --- /dev/null +++ b/apps/cli-docs/public/get-started-with-sentry.md @@ -0,0 +1 @@ +../../../packages/cli/init.md \ No newline at end of file diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index 76eb281b7..0d5a2b1b7 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -9,6 +9,7 @@ Best practices and operational guidance for AI coding agents using the Sentry CL - **Just run the command** — the CLI handles authentication and org/project detection automatically. Don't pre-authenticate or look up org/project before running commands. The CLI prompts for login if needed. - **Prefer CLI commands over raw API calls** — the CLI has dedicated commands for most tasks. Reach for `sentry issue view`, `sentry issue list`, `sentry trace view`, etc. before constructing API calls manually or fetching external documentation. +- **Use `sentry init` to add Sentry to a project** — it prints step-by-step setup instructions for you to follow: log in, pick a Sentry project, choose features with the user, install the SDK, and verify that a test error arrives. It also covers upgrading an existing setup. - **Use `sentry docs` for setup questions** — if you need to know how to configure a Sentry SDK or feature, run `sentry docs "your question"` to query the documentation directly. This is faster and more accurate than fetching docs externally. - **Use `sentry schema` to explore the API** — run `sentry schema` to list API resources or `sentry schema ` to search by API concepts or CLI command paths. Use `sentry help ` for CLI flags and usage examples. - **Use `sentry issue view` to investigate issues** — when asked about a specific issue (e.g., `CLI-G5`, `PROJECT-123`), use `sentry issue view` directly. Multiple IDs can be passed in one invocation: `sentry issue view A B C --json` returns an array of the same objects. diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 05b96b619..92a575c59 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -94,7 +94,7 @@ toolkit/ │ │ │ ├── explore.ts # Query aggregate event data (Explore) │ │ │ ├── help.ts # Help command │ │ │ ├── info.ts # Print configuration and verify authentication -│ │ │ ├── init.ts # Initialize Sentry in your project (experimental) +│ │ │ ├── init.ts # Print instructions for a coding agent to set up Sentry │ │ │ ├── mcp.ts # Start a local Sentry MCP server │ │ │ ├── schema.ts # Browse the Sentry API schema │ │ │ └── wasm-split.ts# Add build ids to WebAssembly modules and split out debug data diff --git a/apps/cli-docs/src/content/docs/exit-codes.md b/apps/cli-docs/src/content/docs/exit-codes.md index 0c85b0a25..c3dc3f6f9 100644 --- a/apps/cli-docs/src/content/docs/exit-codes.md +++ b/apps/cli-docs/src/content/docs/exit-codes.md @@ -42,10 +42,7 @@ react to failure categories without parsing stderr. | 50 | Upgrade Error | CLI upgrade operation failed | | 51 | Device Flow Error | OAuth device authorization flow failed | | 60 | Output Error | Command produced output but the operation failed | -| 61 | Wizard Error | Interactive setup wizard encountered an error | -| 62 | Wizard Deps | Wizard dependency installation failed | -| 63 | Wizard Codemod | Wizard codemod plan or apply failed | -| 64 | Wizard Verify | User stopped wizard after verification step | +| 64 | Verify Failed | `sentry local run --verify` received no events from the SDK | | 252 | Invalid Argument | An argument could not be parsed — see [Argument errors](#argument-errors) | ## Scripting Examples @@ -105,10 +102,6 @@ Run the command with `--help` to see the arguments it accepts. convention — the error response body is useful output. Parse the HTTP status from `--verbose` output or the JSON error body if you need to distinguish API error categories. -- The `sentry init` wizard maps its internal workflow exit codes to CLI - exit codes: platform not detected → 20 (Config), dependency install - failed → 62 (Wizard Deps), codemod failed → 63 (Wizard Codemod), - verification stopped → 64 (Wizard Verify), other → 61 (Wizard). - [Stricli](https://bloomberg.github.io/stricli/) (the CLI framework) uses negative exit codes (-5 to -1) for framework-level errors like unknown commands (251) or [invalid arguments](#argument-errors) (252). These diff --git a/apps/cli-docs/src/content/docs/features.md b/apps/cli-docs/src/content/docs/features.md index d28fc4f10..370808da9 100644 --- a/apps/cli-docs/src/content/docs/features.md +++ b/apps/cli-docs/src/content/docs/features.md @@ -7,7 +7,7 @@ The Sentry CLI includes several features designed to streamline your workflow, e ## Interactive Selection -When the CLI asks you to choose an organization or project, type to filter the loaded options by name or slug. The filter appears as `/text` below the list. Matching ignores case and tolerates small typos. Use arrow keys to navigate, Enter to select, and Backspace to edit. Esc clears the filter before cancelling; Ctrl+C cancels immediately. The same controls work in the organization, team, and monorepo app selectors in `sentry init`. +When the CLI asks you to choose an organization or project, type to filter the loaded options by name or slug. The filter appears as `/text` below the list. Matching ignores case and tolerates small typos. Use arrow keys to navigate, Enter to select, and Backspace to edit. Esc clears the filter before cancelling; Ctrl+C cancels immediately. Selectors tolerate typos in unfinished prefixes: `frone` can find `frontend-web`. Queries with one or two characters match literal text only, so `ap` finds `api` but `zz` does not find `ui`. diff --git a/apps/cli-docs/src/content/docs/getting-started.mdx b/apps/cli-docs/src/content/docs/getting-started.mdx index 0b59c0df5..7e4e61ae5 100644 --- a/apps/cli-docs/src/content/docs/getting-started.mdx +++ b/apps/cli-docs/src/content/docs/getting-started.mdx @@ -55,7 +55,7 @@ You can also set `SENTRY_INSTALL_DIR` to override the binary installation direct curl https://cli.sentry.dev/install -fsS | SENTRY_INSTALL_DIR="$HOME/.local/bin" bash ``` -Set `SENTRY_INIT=1` to run [`sentry init`](../commands/init/) right after installing. This requires an interactive terminal: +Set `SENTRY_INIT=1` to run [`sentry init`](../commands/init/) right after installing, which prints the guide a coding agent follows to add Sentry to your project: ```bash curl https://cli.sentry.dev/install -fsS | SENTRY_INIT=1 bash @@ -245,7 +245,7 @@ Credentials are stored in a SQLite database under `$XDG_CONFIG_HOME/sentry/` (de Once authenticated, you can start using the CLI: -- [Initialize Sentry](../commands/init/) - Set up Sentry in your project with the guided wizard +- [Initialize Sentry](../commands/init/) - Print the guide a coding agent follows to set up Sentry in your project - [Organization commands](../commands/org/) - List and view organizations - [Project commands](../commands/project/) - Manage projects - [Issue commands](../commands/issue/) - Track and manage issues diff --git a/apps/cli-docs/src/fragments/commands/init.md b/apps/cli-docs/src/fragments/commands/init.md index 383f2f80d..e04dee057 100644 --- a/apps/cli-docs/src/fragments/commands/init.md +++ b/apps/cli-docs/src/fragments/commands/init.md @@ -1,44 +1,32 @@ -> **Experimental:** `sentry init` is experimental and may modify your source files. Always review changes before committing. +## Setting up Sentry with an agent -**Authentication:** Interactive runs start the OAuth login flow automatically when credentials are missing or the stored session has expired. Non-interactive and CI runs must authenticate before running `sentry init`. +`sentry init` prints a guide for a coding agent to follow; it changes nothing +itself. Ask your agent to run it: -In organization, team, and monorepo app selectors, type to filter the loaded choices by name or slug. The filter appears as `/text` below the list. Matching ignores case and tolerates small typos, including in unfinished prefixes such as `frone` for `frontend-web`. Queries with one or two characters match literal text only. Use arrow keys to navigate, Enter to select, and Backspace to edit. Esc clears an active filter; with no filter, Esc cancels. Ctrl+C always cancels. +> Set up Sentry for me. Run `sentry init` and follow the steps. -## Target Syntax +If the CLI isn't installed yet, point the agent at the published copy, which +starts by installing it: -| Syntax | Meaning | -|--------|---------| -| _(omitted)_ | Auto-detect org and project | -| `acme/` | Use org `acme`, auto-detect or create project | -| `acme/my-app` | Use org `acme` and project `my-app` | -| `my-app` | Use the existing project `my-app` from any accessible org. If there is none, use org `my-app` when one exists; otherwise create a new project named `my-app` | +> Set up Sentry for me. Fetch https://cli.sentry.dev/get-started-with-sentry.md and follow it. -Path-like arguments (starting with `.`, `/`, or `~`) are always treated as the directory. The order of target and directory can be swapped — the CLI will auto-correct with a warning. +When the output is piped, as it is for agents, the command prints the guide's +Markdown source. In a terminal it renders the guide. -## Available Features +## What the guide covers -| Feature | Description | -|---------|-------------| -| `errors` | Error monitoring | -| `tracing` | Performance tracing | -| `logs` | Log integration | -| `replay` | Session replay | -| `profiling` | Profiling | -| `crons` | Cron job monitoring | -| `agent-tracing` | Agent tracing for AI/LLM apps | -| `mcp-observability` | MCP (Model Context Protocol) observability | - -## What the Wizard Does - -1. **Detects your framework** — scans your project files to identify the platform and framework -2. **Installs the SDK** — adds the appropriate Sentry SDK package to your project -3. **Instruments your code** — configures error monitoring, tracing, and any selected features - -### Supported Platforms - -- **JavaScript / TypeScript** — Next.js, Express, SvelteKit, React -- **Python** — Flask, FastAPI - -More platforms and frameworks are coming soon. +1. Install the CLI and log in with `sentry auth login` +2. Map the project: which apps to set up, which SDKs each one needs (a + framework on a specific runtime, such as TanStack Start on Cloudflare, + needs both SDKs), and whether Sentry is already there. If it is, upgrade + the SDKs or turn on more features instead +3. Pick or create a Sentry project and read its DSN +4. Choose features: errors, tracing, logs, metrics, Session Replay, profiling, + crons, User Feedback, AI agent monitoring, and MCP monitoring +5. Install and configure the SDKs from the current docs (`sentry docs`), never + from remembered versions or APIs +6. Verify that a test error reaches Sentry with `sentry local run --verify` + and `sentry issue list` +7. Report what changed and what is left for you to do diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index 111f12f3d..e1a7736f2 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -45,8 +45,8 @@ The package is a single file with no runtime dependencies. Cloudflare worker; mcp-core no longer ships SDK v1's server. `zod/v3` is stubbed in this bundle and the CLI's (`packages/cli/script/lib/zod-v3-stub-plugin.ts`): only Zod 3 code paths in - `@ai-sdk/provider-utils` and `@mastra/schema-compat` use it, and everything - bundled builds Zod 4 schemas. Zod keeps only its English locale + `@ai-sdk/provider-utils` use it, and everything bundled builds Zod 4 + schemas. Zod keeps only its English locale (`packages/cli/script/lib/zod-locales-plugin.ts`). - CLI-only code stays out: the other commands, agent skills, completions, syntax highlighting, the Ink UI, and debug-file processing. Help and `auth` diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 515dac651..fef5c8913 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -508,9 +508,8 @@ takes: | Org, project, issue, dashboard target | the bound parsers above (`boundPositional(kind, …)`, `boundArguments({ kind, … })`, `listTargetPositional`, `issuePositional`, `releaseParameter`) | `ORG_TARGET` org slugs; `ORG_PROJECT_TARGET` `org/`, `org/project`, aliases; `ISSUE_TARGET` plus cached issue short IDs; `DASHBOARD_TARGET` cached dashboards | | File or directory path (`--output`, `--ignore-file`) | `parseFilePath` | entries of the partial's directory, directories with a trailing `/` | | Directory | `parseDirectoryPath` | directories only | -| Target or directory (`init`) | `boundArguments({ kind: ORG_PROJECT_OR_DIRECTORY_TARGET, … })` | `org/project` targets, or directories once the word looks like a path (`./`, `/`, `~/`), so a suggestion is never read as a target | | Fixed values | `choiceParser(values)`, or `choiceParser(values, parse)` to keep a command's own validating parser (`choiceParser(VALID_SORT_VALUES, parseSort)`). Take the values from the module that defines them | those values; a variadic positional completes only its first word (`cli defaults `). Without `parse` the parser returns its input, so the command still validates it | -| Comma-separated fixed values (`--scope`, `--features`) | `choiceListParser(values)` (or `(values, parse)`) | the next item of the list, leaving out the items already in it (`org:read,org:w` → `org:read,org:write`) | +| Comma-separated fixed values (`--scope`) | `choiceListParser(values)` (or `(values, parse)`) | the next item of the list, leaving out the items already in it (`org:read,org:w` → `org:read,org:write`) | | Platform | `parsePlatform` | platform identifiers, common ones first | | Something that already exists in Sentry, as a flag value (`--environment`, `--release`, `--team`, `--owner`, `--integration`, `--app`, `--dsn`, `--workflow`, `--project`, `issue tags `) | `parseEnvironment`, `parseEnvironmentList`, `parseRelease`, `parseTeam`, `parseOwner` (`team:`), `parseIntegration`, `parseSentryApp`, `parseAppLinkField` (`name=value`), `parseDsn`, `parseWorkflowList`, `parseProjectSlug`, `parseProjectList`, `parseTagKey` | cached entities of the org the command's target names, else of every org, each once; the `*List` parsers complete the last item of a comma-separated list | | Field or aggregate of a dataset (`explore --field`/`--sort`, `dashboard widget --group-by`/`--query`/`--sort`, `alert metrics --aggregate`) | `fieldParser`, `fieldOrAggregateParser`, `aggregateParser` (`fn(field)`), or `aggregateShorthandParser` (`fn:field`), given a `DatasetCompletion`: what completes on each dataset (start from `DATASET_FIELDS`), the command's `--dataset` aliases, and its default dataset | the fields of the dataset the `--dataset` typed names (else the default; every dataset when there is none): cached attribute keys of spans, logs, and trace metrics, cached issue tag keys for errors, or a fixed set (replays), matched one `.` segment at a time (`span.d` finds `span.duration`, never `span.op`); then its whole aggregates (`aggregates`), and its aggregate functions as `fn()`, and `fn(field)` once a name is typed in full (`p95` → `p95(span.duration)`). Widgets take the functions of `aggregateFunctionsOf` in `types/dashboard.ts`; `alert metrics --aggregate` takes the narrower ones metric alerts accept (`METRIC_ALERT_FIELD_COMPLETION`). A word starting with `-` completes flag names, so a descending sort's `-` is typed after completing | @@ -612,7 +611,7 @@ Split by argument type — do not mix the conventions: `{ multi: true }` to `parseOrgProjectArg`; never enable it for mutation or single-project commands. - **Optional flags → comma-separated (sometimes also repeatable).** Split the - flag value on `,`: `--features errors,tracing`, set-commits `--path a,b`, + flag value on `,`: set-commits `--path a,b`, `auth login --scope a,b`. Use `value.split(",")` (repeatable array flags: `flags.x.flatMap((v) => v.split(","))`). @@ -822,7 +821,7 @@ Use `"date"` for timestamp-based sort (not `"time"`). Export sort types from the All command docs and skill files are generated via `pnpm run generate:docs`. This runs automatically as part of `dev`, `build`, `typecheck`, and `test` scripts. -- **One process**: the `generate:*` scripts (except `generate:grammar`, a plain Node script) call `script/generate.ts`, which runs the requested steps in one process so the route tree loads once. Phase 1 writes what the tree imports (API schema, grammars, output-schema field data, banner), runs those steps concurrently, and must not load the tree; its steps must not read each other's outputs. Phase 2 stubs the generated files the tree imports (`script/lib/route-tree-stubs.ts`), then runs each step's exported `emit()`. Scripts that load the tree without generating, such as `check-fragments`, leave those stubs behind, so `ensure-generated.mjs` treats a file that still holds its stub (`script/lib/route-tree-stub-files.mjs`) as missing. Phase-2 modules may import the tree statically, because the runner imports them only after the stubs exist. A new generator exports `emit()` and gets a step in `script/generate.ts`, with no stub code of its own. A phase-2 step may also declare `prepare`, which the runner calls as the run begins: `command-api` uses it to parse `src/` in a worker thread (`script/lib/command-api-analysis.ts`) during phase 1 and the tree load. The worker reads the API schema once the `schema` step finishes, and `command-api` runs last because its `emit` waits for that worker. In `package.json`, call the runner (`tsx script/generate.ts `) rather than chaining `pnpm run generate:*`. `docs` names every step but `sdk`, including `schema`, so callers need not run `generate:schema` first. +- **One process**: the `generate:*` scripts (except `generate:grammar`, a plain Node script) call `script/generate.ts`, which runs the requested steps in one process so the route tree loads once. Phase 1 writes what the tree imports (API schema, grammars, output-schema field data, banner, the `sentry init` guide), runs those steps concurrently, and must not load the tree; its steps must not read each other's outputs. Phase 2 stubs the generated files the tree imports (`script/lib/route-tree-stubs.ts`), then runs each step's exported `emit()`. Scripts that load the tree without generating, such as `check-fragments`, leave those stubs behind, so `ensure-generated.mjs` treats a file that still holds its stub (`script/lib/route-tree-stub-files.mjs`) as missing. Phase-2 modules may import the tree statically, because the runner imports them only after the stubs exist. A new generator exports `emit()` and gets a step in `script/generate.ts`, with no stub code of its own. A phase-2 step may also declare `prepare`, which the runner calls as the run begins: `command-api` uses it to parse `src/` in a worker thread (`script/lib/command-api-analysis.ts`) during phase 1 and the tree load. The worker reads the API schema once the `schema` step finishes, and `command-api` runs last because its `emit` waits for that worker. In `package.json`, call the runner (`tsx script/generate.ts `) rather than chaining `pnpm run generate:*`. `docs` names every step but `sdk`, including `schema`, so callers need not run `generate:schema` first. - **Skipped steps**: a phase-1 step with `files` in `script/generate.ts` runs only when the content hash of its inputs or outputs differs from its entry in `src/generated/.stamps.json` (`script/lib/step-stamp.ts`). When such a step starts reading a new file or package, add it to that step's inputs. `output-schemas` and the phase-2 steps have no stamp: their inputs are the source they import, which is no short list. - **Command docs** (`apps/cli-docs/src/content/docs/commands/*.md`) and the configuration page (`apps/cli-docs/src/content/docs/configuration.md`) are **gitignored** and generated from CLI metadata + hand-written fragments in `apps/cli-docs/src/fragments/` (paths live in `script/paths.ts`). @@ -909,8 +908,7 @@ CliError (base, exitCode=1) ├── DeviceFlowError (exitCode=51 — OAuth flow) ├── SeerError (exitCode=40–42 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled') ├── TimeoutError (exitCode=31 — operation timed out) -├── UpgradeError (exitCode=50 — upgrade failures) -└── WizardError (exitCode=61–64 by workflow step — init wizard error) +└── UpgradeError (exitCode=50 — upgrade failures) ``` > Exit code ranges: 1x=auth, 2x=input/config, 3x=API/network, 4x=feature/billing, @@ -1095,9 +1093,7 @@ Commands select a capability wrapper instead of performing API lookups: that returned 404. Do not call `findProjectsBySlug()` or implement org fallback directly in a -command. `sentry init` is the intentional not-found exception: it calls the -classifier with fuzzy recovery disabled, then treats `not-found` as a new -project name. Issue-short-ID recovery in `commands/issue/utils.ts` is not CLI +command. Issue-short-ID recovery in `commands/issue/utils.ts` is not CLI target resolution and may perform its own project lookup. The `no-direct-target-resolution` CLI Oxlint rule enforces this command-layer boundary. diff --git a/packages/cli/init.md b/packages/cli/init.md new file mode 100644 index 000000000..b117e08a7 --- /dev/null +++ b/packages/cli/init.md @@ -0,0 +1,260 @@ +# Set up Sentry + +You are a coding agent adding Sentry to the user's project. Work through the +steps below in order. Run the commands yourself. Stop and ask the user where a +step tells you to, and before anything that changes production configuration. + +## Use the docs, not your memory + +Sentry SDKs change often, and what you remember is often a major version +behind: `@sentry/tracing`, `new Sentry.BrowserTracing()`, +`Sentry.startTransaction()`, and `Sentry.Integrations.*` are all gone from +current SDKs. Look up package names, file names, init options, and APIs in +the current docs before you write any Sentry code, and follow them over what +you remember: + +```bash +sentry docs "How do I set up Sentry in a Next.js app?" # cited answer +sentry docs list "nextjs tracing" # matching doc pages +``` + +Every docs.sentry.io page is also available as Markdown: add `.md` to its +path, as in `https://docs.sentry.io/platforms/javascript/guides/nextjs.md`. +`sentry docs` needs a sentry.io login; with a self-hosted Sentry, read those +`.md` pages instead. Never write an SDK version number from memory. Install +without a version so the package manager picks the current release. + +## 1. Install the Sentry CLI + +Check whether the CLI is installed: + +```bash +sentry --version +``` + +If you are reading this from `sentry init`, it is. Otherwise install it: + +```bash +curl https://cli.sentry.dev/install -fsS | bash # macOS and Linux +irm https://cli.sentry.dev/install.ps1 | iex # Windows PowerShell +npm install -g sentry # any OS with Node.js 20+ +``` + +If your shell doesn't find `sentry` afterwards, start a new shell or run it by +the path the installer printed. After an npm or Homebrew install, run +`sentry cli setup` once to add shell completions and the Sentry agent skills. + +## 2. Log in + +```bash +sentry auth status +``` + +If it reports that nobody is logged in, run `sentry auth login`. It prints a +URL and a one-time code. Show both to the user and wait while they approve +the login in their browser; the command finishes when they do. For a +self-hosted Sentry, ask the user for its URL and pass `--url `. + +## 3. Map the project + +Read the manifests and framework configuration; don't guess from file names. +Work out four things. + +**Which apps to set up.** In a monorepo, deployable apps usually live in +`apps/*`, and `packages/*` are usually libraries. Ask the user which apps +should get Sentry, and change only their files. Tests, examples, docs, and CI +jobs are not apps. + +**Which SDKs they need.** Often more than one, for different reasons: + +- A frontend and a backend: a React frontend with a Django backend. +- A framework on a specific runtime: TanStack Start on Cloudflare. +- Separately deployed workers, edge functions, or background services. Each + needs its own setup, even with the same SDK: every Supabase Edge Function + initializes the Deno SDK itself. +- Mobile and desktop apps, and native code inside them. + +Other times one SDK covers several parts, as `@sentry/nextjs` does for a +Next.js app's browser, server, and edge code. The docs decide which, not +your memory. Search for each part's exact framework and runtime, and read the +setup pages before you choose: + +```bash +sentry docs list "tanstack start cloudflare" +sentry docs "How do I set up Sentry in TanStack Start on Cloudflare?" +``` + +Use the SDKs those pages name, and no others: a page can say one SDK is +enough, or that the setup depends on the runtime version. If the search +finds no page for a framework or runtime, look it up in +https://docs.sentry.io/platforms.md, the index of every SDK and its guides. + +**Which integrations apply.** Libraries the app uses can have a Sentry +integration: AI SDKs (Vercel AI SDK, OpenAI, Anthropic, LangChain), Zod, +Prisma, queues, and more. They aren't separate SDKs, but some must be added +to the init call or need an extra package. Check each SDK's integrations +against the app's dependencies: + +```bash +sentry docs list "nextjs integrations" +``` + +**Whether Sentry is already there.** Look for Sentry packages in the +dependency manifests (`package.json`, `requirements.txt`, `pyproject.toml`, +`Gemfile`, `go.mod`, `composer.json`, `pom.xml`, `build.gradle`, +`Cargo.toml`, `pubspec.yaml`, ...), a `Sentry.init`, `sentry_sdk.init`, or +`Sentry.withSentry` call, and a DSN in the code or environment files. + +- **Sentry is already set up.** Tell the user what you found: the SDKs and + their versions, the Sentry project the DSN points to, and which features + are on. Ask what they want: upgrade the SDKs, turn on more features + (step 5), or fix something that isn't working. Skip step 4 and change only + what they ask for. Compare the installed version with the latest release + (`npm view @sentry/nextjs version`, `pip index versions sentry-sdk`, or the + ecosystem's equivalent). Before a major-version upgrade, read the migration + guide with `sentry docs "migrate from v to v"`. +- **Sentry is not set up.** Continue with step 4. + +## 4. Pick the Sentry project + +Ask the user whether to use existing Sentry projects or create new ones. A +frontend and a backend usually get separate projects. + +Create new projects. Get each platform identifier first; the JSON output +includes each project's DSN: + +```bash +sentry platform list --search nextjs +sentry project create my-app:javascript-nextjs --json +sentry project create web:javascript-react api:python-django --json +``` + +If the CLI can't tell which organization to use, it says so. Ask the user, +then pass the organization as a prefix (`my-org/my-app:javascript-nextjs`); +`sentry org list` shows the choices. The CLI picks a team for the project; if +the user wants a different one, pass `--team ` (`sentry team list my-org/` +lists them). + +Or use existing projects and read their DSNs: + +```bash +sentry project list my-org/ +sentry dsn list my-org/my-project --json --fields name,dsn +``` + +## 5. Choose features + +Error monitoring is always on. Suggest the features that fit the app, check +in the docs that its SDKs support them, and ask the user which to enable: + +| Feature | What it gives you | Suggest it when | +| ------------------- | ----------------------------------------------------------------- | -------------------------------------------------- | +| Errors and issues | Exceptions and crashes, grouped into issues with stack traces | Always on | +| Tracing | Request and transaction performance, distributed across services | Web servers, APIs, frontends | +| Logs | Structured logs, linked to the trace and the error they belong to | Backends, workers | +| Metrics | Custom counters, gauges, and distributions | Backends with business metrics | +| Session Replay | Video-like replays of user sessions around errors | The app has browser code | +| Profiling | Function-level CPU profiles of slow code | Backends, mobile apps | +| Crons | Alerts when scheduled jobs fail, run late, or don't run | The code runs a recurring production job | +| User Feedback | An in-app widget for users to report problems | Browser and mobile apps | +| AI agent monitoring | Traces of LLM calls, tool calls, tokens, and cost | Application code calls an LLM or runs an agent | +| MCP monitoring | Tool, prompt, and resource calls on an MCP server | Application code implements or hosts an MCP server | + +Suggest Crons, AI agent monitoring, and MCP monitoring only when you found +the code for them. A scheduled CI job, a timeout, or a retry loop is not a +cron job; an MCP client is not an MCP server. Profiling, AI agent monitoring, +and MCP monitoring need tracing on. + +Readable stack traces also need source maps (JavaScript) or debug symbols +(native and mobile) uploaded at build time. Offer that when it applies; it +needs a Sentry auth token in the build environment, which the user has to +add. + +## 6. Install and configure the SDKs + +For each SDK and integration from step 3, read its docs page and follow it: + +```bash +sentry docs "How do I set up Sentry in on ?" +sentry docs "How do I enable in ?" +sentry docs "How do I add the integration in ?" +``` + +- Install every package the new code imports, with the project's package + manager and without a version, such as `@sentry/profiling-node` for + profiling. A docs page's install command can list fewer packages than its + code imports. In a workspace, install into the app, + not the root, and keep the project's conventions (such as pnpm catalogs). +- Configure only the features the user chose. Docs snippets include options + for other features; leave those out. +- Add an integration only when the docs say it isn't enabled by default. +- Put the DSN in the SDK init code, the way the docs do; a DSN is not a + secret. If the project already reads the DSN from an environment variable, + keep that. Real secrets, such as the auth token for source map uploads, go + in environment variables. +- Don't turn on `sendDefaultPii` or other options that collect personal data + unless the user agrees. +- Sample rates of `1.0` are right for development. Tell the user to lower + them for production traffic. +- Match the project's code style, and don't change code unrelated to Sentry. +- Don't run `npx @sentry/wizard`; it is interactive and expects a human. + +A few setups need more than an init call. The docs cover them; check that +you didn't skip them: + +- Browser profiling needs the `Document-Policy: js-profiling` response + header from the dev server and in production. +- AI agent monitoring: in the handlers that make AI calls, set the + conversation ID and the user with the SDK's API, using IDs the handler + already has. +- iOS and React Native need changes to the Xcode project, and Android and + React Native need changes to the Gradle build. + +## 7. Verify the setup + +Make sure events reach Sentry before you call the job done. + +First read back every file you changed: each SDK initializes, each chosen +feature is configured, and the packages are in the manifest and lockfile. + +Then add a temporary line right after the SDK initializes that reports a test +error, such as `Sentry.captureException(new Error("Sentry setup test"))`, or +the SDK's equivalent in other languages. For server code, start the app +through the CLI. It sets `SENTRY_SPOTLIGHT`, which server SDKs read, and +`--verify` waits up to 30 seconds for the SDK to send an event there, then +stops the app: + +```bash +sentry local run --verify -- npm run dev +``` + +If it times out, the SDK may not read `SENTRY_SPOTLIGHT`; that doesn't mean +the setup failed. For a browser app, load the page instead, or ask the user +to. Either way, confirm that the issue reached Sentry. It can take a minute to +show up: + +```bash +sentry issue list my-org/my-project --query "Sentry setup test" --period 1h +``` + +Check the other features you turned on the same way. Run the app, send it a +request, and look for the data with `sentry trace list my-org/my-project` or +`sentry log list my-org/my-project`. Remove the test line when you're done, +unless the user wants to keep it. + +If nothing arrives, check that the DSN is loaded where the SDK initializes, +that the SDK initializes before the rest of the app, and that the network +allows requests to the Sentry host. `sentry docs` has troubleshooting pages +for each SDK. + +## 8. Report back + +Tell the user, from what you actually checked: + +- The SDKs and integrations you added or upgraded, with versions, and for + which part of the app +- The Sentry organization and project, and where the DSN lives +- The features you turned on, and any you suggested that they declined +- What the verification showed, with the issue's short ID or link +- Anything left for them to do, such as adding an auth token for source map + uploads or lowering sample rates for production diff --git a/packages/cli/install b/packages/cli/install index 186231448..f27eb7d4d 100755 --- a/packages/cli/install +++ b/packages/cli/install @@ -475,21 +475,17 @@ fi # terminal only when both output streams are interactive; setup checks whether # this is a fresh human install before starting login. trap - ERR -if [[ "${SENTRY_INIT:-}" != "1" && -t 1 && -t 2 ]] && { exec 3/dev/null; then +if [[ -t 1 && -t 2 ]] && { exec 3/dev/null; then # shellcheck disable=SC2086 "$tmp_binary" cli setup $setup_args <&3 3<&- - exit 0 + exec 3<&- +else + # shellcheck disable=SC2086 + "$tmp_binary" cli setup $setup_args fi -# shellcheck disable=SC2086 -"$tmp_binary" cli setup $setup_args - -# Optionally launch the setup wizard after install. -# Reconnect stdin to the real terminal so the wizard can prompt -# interactively — when piped (curl | bash), stdin is the pipe. +# Optionally print the agent setup guide after install. `sentry init` only +# writes text, so it needs no terminal. if [[ "${SENTRY_INIT:-}" == "1" ]]; then - if [[ ! -e /dev/tty ]]; then - die "SENTRY_INIT=1 requires an interactive terminal (/dev/tty not available)" "init" - fi - "$init_binary" init ` to search by API concepts or CLI command paths. Use `sentry help ` for CLI flags and usage examples. - **Use `sentry issue view` to investigate issues** — when asked about a specific issue (e.g., `CLI-G5`, `PROJECT-123`), use `sentry issue view` directly. Multiple IDs can be passed in one invocation: `sentry issue view A B C --json` returns an array of the same objects. @@ -781,9 +782,9 @@ Manage product trials ### Init -Initialize Sentry in your project (experimental) +Print instructions for a coding agent to set up Sentry -- `sentry init [] []` — Initialize Sentry in your project (experimental) +- `sentry init` — Print instructions for a coding agent to set up Sentry → Full flags and examples: `references/init.md` 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 25dd3abce..7a8ae61b9 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 @@ -1,7 +1,7 @@ --- name: sentry-cli-init version: 0.48.0-dev.0 -description: Initialize Sentry in your project (experimental) +description: Print instructions for a coding agent to set up Sentry requires: bins: ["sentry"] auth: true @@ -9,58 +9,26 @@ requires: # Init Commands -Initialize Sentry in your project (experimental) +Print instructions for a coding agent to set up Sentry -### `sentry init [] []` +### `sentry init` -Initialize Sentry in your project (experimental) +Print instructions for a coding agent to set up Sentry -**Arguments:** +**JSON Fields** (use `--json --fields` to select specific fields): -| Argument | Description | -|----------|-------------| -| `[] []` | Optional target (<org>/<project>, <org>/, or <project>) and directory (default: current directory) | - -**Flags:** -- `-y, --yes - Accept non-interactive defaults (requires --features outside a TTY)` -- `-n, --dry-run - Show what would happen without making changes` -- `--features ... - Features to enable: errors,tracing,logs,replay,profiling,crons,agent-tracing,mcp-observability` -- `-t, --team - Team slug to create the project under` -- `--app - App to initialize in a monorepo (required with --yes when multiple apps are detected)` -- `--tui - Use the Ink-based interactive UI (default). Pass --no-tui to fall back to plain log output.` +| Field | Type | Description | +|-------|------|-------------| +| `guide` | string | The setup guide's Markdown source | **Examples:** ```bash -# Interactive setup +# Print the setup guide sentry init -# Non-interactive agent/CI setup -sentry init --yes --features errors,tracing,replay - -# Dry run to preview changes -sentry init --dry-run - -# Target a subdirectory -sentry init ./my-app - -# Use a specific organization (auto-detect project) -sentry init acme/ - -# Use a specific organization and project -sentry init acme/my-app - -# Search for a project across organizations -sentry init my-app - -# Use a specific project in a subdirectory -sentry init acme/my-app ./my-project - -# Assign a team when creating a new project -sentry init acme/ --team backend - -# Enable specific features -sentry init --features profiling,replay +# Print the guide as JSON +sentry init --json ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index 99d88ad94..319352996 100644 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -144,7 +144,6 @@ const BUNDLE_OPTIONS = { // external avoids pulling CJS React wrappers into the bundle. external: [ "ink", - "ink-spinner", "react", "react/*", "react-reconciler", @@ -310,10 +309,9 @@ async function compileAllTargets( // node:sea.getAsset() under the same keys. const assets = [...bundle.files]; - // Add ink sidecar as asset if it exists (pre-bundled by text-import-plugin) - // The text-import-plugin pre-bundles the Ink sidecar into BUILD_DIR. - // Pass it to fossilize as a SEA asset so it's available at runtime - // via node:sea.getAsset(INK_SIDECAR_ASSET_KEY). + // text-import-plugin pre-bundles the Ink picker sidecar into BUILD_DIR. + // Pass it to fossilize as a SEA asset; `ink-ui.ts` reads it back with + // node:sea.getAsset("dist-build/ink-app.js"). const INK_SIDECAR = `${BUILD_DIR}/ink-app.js`; if (existsSync(INK_SIDECAR)) { assets.push(INK_SIDECAR); diff --git a/packages/cli/script/bundle.ts b/packages/cli/script/bundle.ts index 97f7b9cc4..7d19208c2 100644 --- a/packages/cli/script/bundle.ts +++ b/packages/cli/script/bundle.ts @@ -145,7 +145,6 @@ const commonBuildOptions = { // The DIF loader resolves this .wasm at runtime (dev only); never bundle it. "@sentry/symbolic/symbolic_bg.wasm", "ink", - "ink-spinner", "react", "react/*", "react-reconciler", @@ -293,10 +292,9 @@ console.log(" -> dist/bin.cjs (CLI main script)"); console.log(" -> dist/index.d.cts + dist/index.d.mts (type declarations)"); // The `ink-app.js` sidecar (pre-bundled by text-import-plugin) ships -// with the npm package so `npx sentry@latest init` can load the -// interactive Ink UI on Node via dynamic import(). The sidecar is -// self-contained ESM with all deps inlined — no runtime dependencies -// needed. +// with the npm package so the organization and project pickers can load +// the Ink UI on Node via dynamic import(). The sidecar is self-contained +// ESM with all deps inlined — no runtime dependencies needed. // Ship the DIF parser WASM as a sibling of the bundle. The runtime loads it // (non-SEA path) via new URL("./vendor/symbolic_bg.wasm", import.meta.url), diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index 648048435..e0d002e43 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -62,6 +62,7 @@ const EXTRA_DOC_FILES = [ "README.md", "CONTRIBUTING.md", "DEVELOPMENT.md", + "init.md", "plugins/README.md", ]; diff --git a/packages/cli/script/check-patches.ts b/packages/cli/script/check-patches.ts index c263c0ba0..db54e4d01 100644 --- a/packages/cli/script/check-patches.ts +++ b/packages/cli/script/check-patches.ts @@ -81,6 +81,8 @@ function parsePatchedDependencies(source: string): Record { return patches; } +const require_ = createRequire(import.meta.url); + /** * Resolve an installed package file to the copy THIS package actually uses. * @@ -105,14 +107,7 @@ function parsePatchedDependencies(source: string): Record { * @param subpath - Package subpath, e.g. `@sentry/core/build/cjs/index.js`. * @returns Absolute path to the resolved file, or null if unresolvable. */ -const require_ = createRequire(import.meta.url); -const mastraRequire = createRequire(require_.resolve("@mastra/client-js")); -const uiUtilsRequire = createRequire(mastraRequire.resolve("@ai-sdk/ui-utils")); - -function resolvePackageFile( - subpath: string, - resolver: NodeJS.Require = require_, -): string | null { +function resolvePackageFile(subpath: string): string | null { const parts = subpath.split("/"); const pkgName = subpath.startsWith("@") ? `${parts[0]}/${parts[1]}` @@ -120,7 +115,7 @@ function resolvePackageFile( const inner = subpath.slice(pkgName.length + 1); try { // Resolve the package's main entry to locate the exact installed copy. - const mainEntry = resolver.resolve(pkgName); + const mainEntry = require_.resolve(pkgName); // The install dir is "<...>/node_modules/". Anchor on the last // "node_modules/" boundary, then take the first "/" after it so a // self-vendored nested copy can't shift the truncation point. @@ -180,9 +175,7 @@ for (const [key, patchPath] of Object.entries(patches)) { const patchVersion = versionMatch[1]; // Resolve installed version - const resolver = - key === "@ai-sdk/provider-utils@2.2.8" ? uiUtilsRequire : require_; - const pkgJsonPath = resolvePackageFile(`${name}/package.json`, resolver); + const pkgJsonPath = resolvePackageFile(`${name}/package.json`); try { if (!pkgJsonPath) { throw new Error("unresolved"); @@ -328,53 +321,6 @@ for (const assertion of CONTENT_ASSERTIONS) { } } -try { - const { createStatusCodeErrorResponseHandler } = uiUtilsRequire( - "@ai-sdk/provider-utils", - ) as { - createStatusCodeErrorResponseHandler: () => (options: { - response: Response; - url: string; - requestBodyValues: unknown; - }) => Promise; - }; - let cancelled = false; - const response = new Response( - new ReadableStream({ - cancel() { - cancelled = true; - }, - }), - { - status: 500, - headers: { "content-length": String(2 * 1024 * 1024 * 1024 + 1) }, - }, - ); - - await createStatusCodeErrorResponseHandler()({ - response, - url: "https://example.invalid/oversized-response", - requestBodyValues: {}, - }).then( - () => { - errors.push( - " @ai-sdk/provider-utils: oversized response was accepted (resource-limit patch not applied)", - ); - }, - () => { - if (!cancelled) { - errors.push( - " @ai-sdk/provider-utils: oversized response body was not cancelled", - ); - } - }, - ); -} catch (error) { - errors.push( - ` @ai-sdk/provider-utils: could not verify response-size limit (${String(error)})`, - ); -} - // Emit GitHub Actions annotations for CI visibility const isCI = !!process.env.CI; for (const w of warnings) { diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index a6345a015..e58bfb094 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -39,6 +39,7 @@ const DOCS_OUTPUTS = [ "src/generated/skill-content.ts", "src/generated/completion-targets.json", "src/generated/top-level-routes.json", + "src/generated/init-guide.ts", ]; const STUBS = new Map(ROUTE_TREE_STUBS); diff --git a/packages/cli/script/generate-init-guide.ts b/packages/cli/script/generate-init-guide.ts new file mode 100644 index 000000000..a7bf3f01f --- /dev/null +++ b/packages/cli/script/generate-init-guide.ts @@ -0,0 +1,36 @@ +/** + * Embed the Agent Setup Guide + * + * `init.md` is the guide a coding agent follows to add Sentry to a project. + * `sentry init` prints it, and the docs site serves the same file at + * https://cli.sentry.dev/get-started-with-sentry.md (a symlink in + * `apps/cli-docs/public`). The single-file binary can't read the file from + * disk at runtime, so this step writes it into a module. + * + * Usage: tsx script/generate.ts init-guide + * Output: src/generated/init-guide.ts + */ + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const SOURCE_PATH = join(PACKAGE_ROOT, "init.md"); +const OUT_PATH = join(PACKAGE_ROOT, "src", "generated", "init-guide.ts"); + +/** Write `src/generated/init-guide.ts` from `init.md`. */ +export async function emit(): Promise { + const guide = readFileSync(SOURCE_PATH, "utf-8"); + mkdirSync(dirname(OUT_PATH), { recursive: true }); + writeFileSync( + OUT_PATH, + [ + "// Generated by script/generate-init-guide.ts from init.md. Do not edit.", + "", + "/** The agent setup guide that `sentry init` prints. */", + `export const INIT_GUIDE = ${JSON.stringify(guide)};`, + "", + ].join("\n"), + ); +} diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts index 1cbb7014d..7841e922d 100644 --- a/packages/cli/script/generate.ts +++ b/packages/cli/script/generate.ts @@ -8,7 +8,8 @@ * once and runs every requested step in two phases: * * 1. Steps the route tree depends on: the API schema, the grammar parsers, - * the output-schema field data that commands import, and the banner. + * the output-schema field data that commands import, the banner, and + * the `sentry init` guide. * They are independent of each other and run concurrently; the grammar * compiles in a child process. Nothing in this phase may load the route * tree. A step that lists its `files` is skipped when their content @@ -140,6 +141,13 @@ function bannerFiles(): StepFiles { }; } +function initGuideFiles(): StepFiles { + return { + inputs: ["init.md", "script/generate-init-guide.ts"], + outputs: ["src/generated/init-guide.ts"], + }; +} + /** * Phase 1: steps whose outputs the route tree imports. `output-schemas` * has no `files`: it loads `src/output-schemas/` and everything that @@ -165,6 +173,11 @@ const PRE_TREE_STEPS: readonly Step[] = [ load: () => import("./generate-banner-image.js"), files: bannerFiles, }, + { + name: "init-guide", + load: () => import("./generate-init-guide.js"), + files: initGuideFiles, + }, ]; /** Phase 2: steps that read the route tree. */ diff --git a/packages/cli/script/lib/zod-v3-stub-plugin.ts b/packages/cli/script/lib/zod-v3-stub-plugin.ts index 91fe579d6..b62721f17 100644 --- a/packages/cli/script/lib/zod-v3-stub-plugin.ts +++ b/packages/cli/script/lib/zod-v3-stub-plugin.ts @@ -2,20 +2,15 @@ * esbuild plugin that replaces `zod/v3` with a small stub. * * Everything we bundle builds schemas with Zod 4, so Zod 3 code never runs. - * Two dependencies still import `zod/v3`, and only for Zod 3 inputs: + * `@ai-sdk/provider-utils` still imports `zod/v3`, but only for Zod 3 inputs: + * `zodSchema()` sends Zod 4 schemas (`_zod`) to `zod4Schema()`, and only + * `zod3Schema()` and its bundled zod-to-json-schema read + * `ZodFirstPartyTypeKind`. * - * - `@ai-sdk/provider-utils`: `zodSchema()` sends Zod 4 schemas (`_zod`) to - * `zod4Schema()`. Only `zod3Schema()` and its bundled zod-to-json-schema - * read `ZodFirstPartyTypeKind`. - * - `@mastra/schema-compat` (via `@mastra/client-js`, used by `sentry init`): - * its Zod 3 to JSON Schema converter reads `ZodFirstPartyTypeKind`, and - * `isZodV3()` ends with `schema instanceof z3.ZodType`. `sentry init` only - * uses the workflow API, which converts no schemas. - * - * The stub keeps `ZodType` a class, so that `instanceof` check is `false` - * instead of a `TypeError`. `ZodFirstPartyTypeKind` throws on access, so a - * Zod 3 schema that does reach a converter fails with a clear error rather - * than producing a wrong JSON Schema. + * The stub's `ZodFirstPartyTypeKind` throws on access, so a Zod 3 schema + * that does reach a converter fails with a clear error rather than producing + * a wrong JSON Schema. A dependency that imports anything else from `zod/v3` + * fails the build. * * Shared by the CLI bundles (`script/bundle.ts`, `script/build.ts`) and the * `@sentry/mcp-server` bundle. @@ -26,14 +21,13 @@ import type { Plugin } from "esbuild"; const ZOD_V3_NS = "zod-v3-stub"; const ZOD_V3_STUB = ` -export class ZodType {} export const ZodFirstPartyTypeKind = new Proxy({}, { get(_target, key) { if (typeof key === "symbol" || key === "then") return undefined; throw new Error("Zod 3 schemas are not supported in this build (zod/v3 is not bundled)."); }, }); -export default { ZodType, ZodFirstPartyTypeKind }; +export default { ZodFirstPartyTypeKind }; `; export const zodV3StubPlugin: Plugin = { diff --git a/packages/cli/script/preview-selectors.ts b/packages/cli/script/preview-selectors.ts index 1bc632b51..e58ef5afe 100644 --- a/packages/cli/script/preview-selectors.ts +++ b/packages/cli/script/preview-selectors.ts @@ -1,33 +1,20 @@ /** - * Preview the real entity pickers with local fixtures, without authentication - * or project changes. Run `pnpm tsx script/preview-selectors.ts`; add `--compact` - * for the Org/Project picker used outside the init wizard. + * Preview the org and project pickers with local fixtures, without + * authentication or project changes. Run `pnpm tsx script/preview-selectors.ts`. */ -import { createInkUI, selectWithFilter } from "../src/lib/init/ui/ink-ui.js"; -import { isCancelled, type SelectOptions } from "../src/lib/init/ui/types.js"; +import { selectWithFilter } from "../src/lib/select-ui/ink-ui.js"; +import type { SelectOptions } from "../src/lib/select-ui/types.js"; const organization: SelectOptions = { - message: "Which organization should the project be created in?", - filterable: true, + message: "Select an organization:", options: [ { value: "acme", label: "Acme", hint: "acme" }, { value: "frontend-labs", label: "Frontend Labs", hint: "frontend-labs" }, { value: "sandbox", label: "Sandbox", hint: "sandbox" }, ], }; -const team: SelectOptions = { - message: "Which team should own this project?", - filterable: true, - options: [ - { value: "backend", label: "backend", hint: "Backend Engineering" }, - { value: "frontend", label: "frontend", hint: "Web Engineering" }, - { value: "mobile", label: "mobile", hint: "Mobile Engineering" }, - { value: "platform", label: "platform", hint: "Developer Platform" }, - ], -}; const project: SelectOptions = { message: "Select a project:", - filterable: true, options: [ "api-gateway", "backend-workers", @@ -44,29 +31,12 @@ async function preview(): Promise { throw new Error("Run this preview in an interactive terminal."); } const chosen: string[] = []; - if (process.argv.includes("--compact")) { - for (const prompt of [organization, project]) { - const value = await selectWithFilter(prompt); - if (value === null) { - return; - } - chosen.push(value); - } - } else { - await using ui = await createInkUI(); - ui.setIntroMode(true); - for (const prompt of [ - organization, - team, - { ...project, message: "Which app would you like to configure?" }, - ]) { - const value = await ui.select(prompt); - if (isCancelled(value)) { - return; - } - chosen.push(value); + for (const prompt of [organization, project]) { + const value = await selectWithFilter(prompt); + if (value === null) { + return; } - ui.outro("Selector preview complete"); + chosen.push(value); } process.stdout.write(`Selected: ${chosen.join(" / ")}\n`); } diff --git a/packages/cli/script/text-import-plugin.ts b/packages/cli/script/text-import-plugin.ts index dc7f61343..add15a590 100644 --- a/packages/cli/script/text-import-plugin.ts +++ b/packages/cli/script/text-import-plugin.ts @@ -1,30 +1,21 @@ /** - * esbuild plugin that polyfills Bun's `with { type: "text" }` and + * esbuild plugin for the `with { type: "text" }` and * `with { type: "file" }` import attributes (esbuild only supports * `json`). * * - `text` — intercepts the import, reads the file, and default- - * exports its contents as a string. Runtime behavior matches Bun's - * native handling. + * exports its contents as a string (e.g. the grep worker source). * - `file` — pre-bundles the source file (TypeScript/TSX → JS) into - * a self-contained module and writes it into the esbuild output - * directory, then marks the import external so the - * `with { type: "file" }` clause survives in the bundled JS. - * Bun.compile downstream understands the attribute natively, - * embeds the file as a binary asset, and resolves the import to a - * virtual-filesystem path string at runtime. + * a self-contained ESM module in the esbuild output directory, and + * replaces the import with a module that default-exports its + * relative filename (`"./ink-app.js"`). `src/lib/select-ui/ink-ui.ts` + * resolves that against `import.meta.url` in the npm bundle; the + * single-file executable ships the file as a SEA asset instead. * - * The pre-bundle step is critical for two reasons: - * - * 1. Bun's `/$bunfs/` virtual filesystem parses embedded files - * with its JavaScript parser, not its TypeScript parser, so - * raw `.tsx` files fail with `SyntaxError` on - * `import { type Foo }` and similar TS-only syntax. - * 2. The embedded file runs from `/$bunfs/root/` at runtime, - * where neither `node_modules` nor sibling source files - * exist. Bundling inlines ALL dependencies (local modules - * like `ink-frame.tsx` and third-party packages like `ink`, - * `react`) so the file is fully self-contained. + * Bundling inlines ALL dependencies (local modules like + * `select-filter.ts` and packages like `ink` and `react`), because + * the sidecar loads where neither `node_modules` nor sibling + * source files exist. * * Some of the inlined packages (e.g. `signal-exit`, used by Ink) * are CJS modules that call `require("assert")` etc. esbuild @@ -36,10 +27,7 @@ * Non-TypeScript files (plain `.js`) are copied verbatim. * * Used by `script/build.ts` (single-file executable) and - * `script/bundle.ts` (CJS library bundle). The `text` branch - * handles `with { type: "text" }` imports (e.g. worker source). - * The `file` branch handles the Ink app component embedded via - * `with { type: "file" }` in `src/lib/init/ui/ink-ui.ts`. + * `script/bundle.ts` (CJS library bundle). */ import { copyFileSync, mkdirSync, readFileSync } from "node:fs"; @@ -67,8 +55,7 @@ const REQUIRE_BANNER = /** * Pre-bundle a TypeScript/TSX source file into self-contained JS. * All dependencies (local modules AND npm packages) are inlined; - * only `node:*` builtins are external since Bun resolves them - * natively inside `/$bunfs/`. + * only `node:*` builtins stay external. */ async function prebundleTs(sourcePath: string, outPath: string): Promise { await esbuildBuild({ diff --git a/packages/cli/src/commands/cli/setup.ts b/packages/cli/src/commands/cli/setup.ts index 2b5bb675e..cac464876 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -838,7 +838,6 @@ export const setupCommand = buildCommand({ flags.method !== "curl" || flags.quiet || flags["ensure-auth-scopes"] || - getEnv().SENTRY_INIT === "1" || Boolean(getEnv().SENTRY_SETUP_CODE) || !(interactivePromptsAllowed() && isatty(0) && isatty(1) && isatty(2)) ) { diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index b6da5e57f..0514f8e51 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -180,7 +180,7 @@ export const infoCommand = buildCommand({ return { hint: failed ? "Not authenticated. Run `sentry auth login`." - : "No default organization/project. Run `sentry init` or set defaults.", + : "No default organization/project. Set them with `sentry cli defaults org ` and `sentry cli defaults project `.", }; } return { hint: "Configuration verified." }; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 229308860..bd70634d3 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,517 +1,73 @@ /** * sentry init * - * Initialize Sentry in a project using the remote wizard workflow. - * Communicates with the Mastra API via suspend/resume to perform - * local filesystem operations and interactive prompts. + * Print the agent setup guide (`init.md`): the steps a coding agent follows + * to add Sentry to a project — install the CLI, log in, pick a Sentry + * project, choose features, install the SDKs with help from `sentry docs`, + * and check that a test error reaches Sentry. The same guide is published at + * https://cli.sentry.dev/get-started-with-sentry.md. * - * Supports two optional positionals with smart disambiguation: - * sentry init — auto-detect everything, dir = cwd - * sentry init . — dir = cwd, auto-detect org - * sentry init ./subdir — dir = subdir, auto-detect org - * sentry init acme/ — explicit org, dir = cwd - * sentry init acme/my-app — explicit org + project, dir = cwd - * sentry init my-app — use existing or create new project - * sentry init acme/ ./subdir — explicit org, dir = subdir - * sentry init acme/my-app ./subdir — explicit org + project, dir = subdir - * sentry init ./subdir acme/ — swapped, auto-correct with warning + * The command makes no requests, so it runs without a login and ignores a + * `.sentryclirc` URL. */ -import path from "node:path"; -import { setTag } from "@sentry/node"; import type { SentryContext } from "../context.js"; -import { - looksLikePath, - type ParsedOrgProject, - parseOrgProjectInput, -} from "../lib/arg-parsing.js"; +import { INIT_GUIDE } from "../generated/init-guide.js"; +import { InitOutputSchema } from "../generated/output-schemas/init.js"; import { buildCommand } from "../lib/command.js"; -import { - choiceListParser, - ORG_PROJECT_OR_DIRECTORY_TARGET, - parseTeam, - parseText, -} from "../lib/completion-target.js"; -import { refreshToken } from "../lib/db/auth.js"; -import { ContextError, ValidationError } from "../lib/errors.js"; -import { warmOrgDetection } from "../lib/init/org-prefetch.js"; -import { runWizard } from "../lib/init/wizard-runner.js"; -import { validateResourceId } from "../lib/input-validation.js"; -import { logger } from "../lib/logger.js"; -import { - DRY_RUN_ALIASES, - DRY_RUN_FLAG, - YES_ALIASES, - YES_FLAG, -} from "../lib/mutate-command.js"; -import { - type BoundArguments, - boundArguments, -} from "../lib/positional-parse.js"; -import { classifyProjectSearchTarget } from "../lib/resolve-target.js"; -import { applySentryUrlContext } from "../lib/sentry-url-parser.js"; +import { detectAgent } from "../lib/detect-agent.js"; +import { isPlainOutput, renderMarkdown } from "../lib/formatters/markdown.js"; +import { CommandOutput } from "../lib/formatters/output.js"; +import type { InitOutput } from "../output-schemas/init.js"; import { topLevelBrief } from "../top-level-briefs.js"; -const log = logger.withTag("init"); - -const FEATURE_DELIMITER = /[,+ ]+/; -const NON_INTERACTIVE_USAGE_HINT = - "sentry init --yes --features errors,tracing,replay [target] [directory]"; - -// Only features backed by a Sentry SDK selector product are accepted here. -// Non-selector products (source maps, metrics, attachments) are intentionally -// not exposed via --features yet: their setup isn't fully automated — e.g. -// source-map upload needs an auth token this wizard does not provision — so -// accepting them would leave a half-configured integration. Re-add an alias -// (and its SUPPORTED_FEATURE_NAMES entry) once that flow is complete. -const FEATURE_ALIASES = { - errors: "errorMonitoring", - errorMonitoring: "errorMonitoring", - tracing: "performanceMonitoring", - performanceMonitoring: "performanceMonitoring", - logs: "logs", - replay: "sessionReplay", - sessionReplay: "sessionReplay", - profiling: "profiling", - crons: "crons", - aiMonitoring: "aiMonitoring", - "agent-tracing": "aiMonitoring", - agentTracing: "aiMonitoring", - "mcp-observability": "mcpObservability", - mcpObservability: "mcpObservability", -} as const; - -const SUPPORTED_FEATURE_NAMES = [ - "errors", - "tracing", - "logs", - "replay", - "profiling", - "crons", - "agent-tracing", - "mcp-observability", -] as const; - -const SUPPORTED_FEATURE_TEXT = SUPPORTED_FEATURE_NAMES.join(", "); - type InitFlags = { - readonly yes: boolean; - readonly "dry-run": boolean; - readonly features?: string[]; - readonly team?: string; - readonly app?: string; - /** - * Default `true` — Ink is the default UI on both the Bun binary - * and the npm/Node distribution. Stricli auto-generates a negated - * `--no-tui` flag that flips this to `false` — that's the escape - * hatch users invoke when the Ink path misbehaves (e.g. on unusual - * terminal emulators). - */ - readonly tui: boolean; -}; - -/** - * Classify and separate two optional positional args into a target and a directory. - * - * Uses {@link looksLikePath} to distinguish filesystem paths from org/project targets. - * Detects swapped arguments and returns a warning when auto-correcting. - * - * @returns Target string (or undefined), directory string (or undefined), - * and the warning for swapped arguments, if any - */ -function classifyArgs( - first?: string, - second?: string, -): { - target: string | undefined; - directory: string | undefined; - warning?: string; -} { - // No args — auto-detect everything - if (!first) { - return { target: undefined, directory: undefined }; - } - - const firstIsPath = looksLikePath(first); - - // Single arg - if (!second) { - return firstIsPath - ? { target: undefined, directory: first } - : { target: first, directory: undefined }; - } - - const secondIsPath = looksLikePath(second); - - // Two paths → error - if (firstIsPath && secondIsPath) { - throw new ValidationError( - [ - `"${first}" and "${second}" are both directory paths — only one directory is allowed.`, - "", - "Provide a single directory:", - ` sentry init [/] ${first}`, - ].join("\n"), - ); - } - - // Two targets → error - if (!(firstIsPath || secondIsPath)) { - throw new ValidationError( - [ - `"${first}" and "${second}" are both treated as targets — only one is allowed.`, - "", - "Pair a target with a directory path:", - ` sentry init ${first} ./my-project`, - ].join("\n"), - ); - } - - // (TARGET, PATH) — correct order - if (!firstIsPath && secondIsPath) { - return { target: first, directory: second }; - } - - // (PATH, TARGET) — swapped, auto-correct with warning - return { - target: second, - directory: first, - warning: `Arguments appear reversed. Interpreting as: ${second} ${first}`, - }; -} - -/** `init`'s positionals, parsed: the target and the directory. */ -type InitInput = { - readonly target: ParsedOrgProject; - /** The directory as typed, relative to the cwd; `undefined` for the cwd */ - readonly directory?: string; - /** Server of a pasted Sentry URL, for `applySentryUrlContext` */ - readonly baseUrl?: string; - /** Set when the arguments were swapped; `func` logs it */ - readonly warning?: string; + readonly json: boolean; + readonly fields?: string[]; }; -/** `init`'s target, resolved to explicit org and project values. */ -type ResolvedInitTarget = { - readonly org: string | undefined; - readonly project: string | undefined; -}; - -/** `init`'s `[] []` arguments. */ -export type InitArguments = BoundArguments; - /** - * Parse `init`'s positionals without side effects: tell the target from - * the directory ({@link classifyArgs}), then parse and validate the target. - * - * @throws {ValidationError} For more than two arguments, two directories, - * two targets, or an invalid org or project slug + * Agents get the Markdown source as written: they read it through a pipe, + * or through a pseudo-terminal that `detectAgent` recognizes. A person in a + * terminal gets it rendered. */ -function parseInitInput(args: readonly string[]): InitInput { - if (args.length > 2) { - throw new ValidationError( - `Too many arguments: ${args.join(" ")}. Expected: sentry init [] []`, - ); - } - const { target, directory, warning } = classifyArgs(args[0], args[1]); - const { parsed, baseUrl } = parseOrgProjectInput(target); - // Validate user-provided slugs before they reach API calls - if (parsed.type === "explicit") { - validateResourceId(parsed.org, "organization slug"); - validateResourceId(parsed.project, "project name"); - } else if (parsed.type === "org-all") { - validateResourceId(parsed.org, "organization slug"); - } - return { target: parsed, directory, baseUrl, warning }; -} - -function parseFeatures( - features: readonly string[] | undefined, -): string[] | undefined { - const requested = features - ?.flatMap((feature) => feature.split(FEATURE_DELIMITER)) - .map((feature) => feature.trim()) - .filter(Boolean); - - if (!requested || requested.length === 0) { - return; - } - - return requested.map(normalizeFeature); -} - -function normalizeFeature(feature: string): string { - const normalized = FEATURE_ALIASES[feature as keyof typeof FEATURE_ALIASES]; - if (!normalized) { - throw new ValidationError( - `Unknown init feature "${feature}". Supported features: ${SUPPORTED_FEATURE_TEXT}`, - "features", - ); - } - return normalized; -} - -function isNonInteractiveContext(context: unknown): boolean { - const { stdin, stdout } = context as { - stdin?: { isTTY?: boolean }; - stdout?: { isTTY?: boolean }; - }; - return stdin?.isTTY !== true || stdout?.isTTY !== true; +function formatGuide(data: InitOutput): string { + return isPlainOutput() || detectAgent() + ? data.guide.trimEnd() + : renderMarkdown(data.guide); } -function validateNonInteractiveInit( - context: unknown, - flags: InitFlags, - features: readonly string[] | undefined, -): void { - if (!isNonInteractiveContext(context)) { - return; - } - - // --dry-run implies non-interactive mode and forces yes=true in the - // wizard runner, so it doesn't need explicit --yes or --features. - if (flags["dry-run"]) { - return; - } - - if (flags.yes && features && features.length > 0) { - return; - } - - throw new ContextError("Yes flag and features", NON_INTERACTIVE_USAGE_HINT, [ - "Agent/CI mode cannot ask interactive setup questions.", - "Pass --yes to accept non-interactive prompts.", - `Pass --features with one or more supported features: ${SUPPORTED_FEATURE_TEXT}.`, - "Run sentry init from an interactive terminal to use the wizard UI.", - ]); -} - -/** - * Resolve the parsed org/project target into explicit org and project values, - * after applying a pasted URL's server. - * - * For `project-search` (bare slug), searches for an existing project first. - * If not found, treats the slug as a **new project name** to create — - * org will be resolved later by init preflight before the workflow starts. - * If the slug matches an org name, treats it as org-only (like `slug/`). - */ -async function resolveTarget(input: InitInput): Promise { - if (input.baseUrl) { - applySentryUrlContext(input.baseUrl); - } - const parsed = input.target; - - switch (parsed.type) { - case "explicit": - return { org: parsed.org, project: parsed.project }; - case "org-all": - return { org: parsed.org, project: undefined }; - case "project-search": { - const resolution = await classifyProjectSearchTarget(parsed, { - // A miss is a new project name for init, not a fuzzy recovery. - fuzzy: false, - }); - - // Multiple matches — disambiguation error - if (resolution.kind === "projects" && resolution.projects.length > 1) { - const first = resolution.projects[0]; - const orgList = resolution.projects - .map((p) => ` ${p.orgSlug}/${p.slug}`) - .join("\n"); - throw new ValidationError( - `Project "${parsed.projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry init ${first.orgSlug}/${parsed.projectSlug}`, - ); - } - - // Exactly one match — use it (wizard handles existing-project flow) - if (resolution.kind === "projects") { - const match = resolution.projects[0]; - return { org: match.orgSlug, project: match.slug }; - } - - // No project found — is the slug an org name? - if (resolution.kind === "organization") { - return { org: resolution.org, project: undefined }; - } - - // Truly not found — treat as the name for a new project to create. - // Org will be resolved later by init preflight before the workflow starts. - log.info( - `No existing project "${parsed.projectSlug}" found — will create a new project with this name.`, - ); - return { org: undefined, project: parsed.projectSlug }; - } - case "auto-detect": - return { org: undefined, project: undefined }; - default: { - const _exhaustive: never = parsed; - throw new ContextError("Target", String(_exhaustive), []); - } - } -} - -export const initCommand = buildCommand< - InitFlags, - [InitArguments], - SentryContext ->({ +export const initCommand = buildCommand({ + auth: false, + skipRcUrlCheck: true, docs: { brief: topLevelBrief("init"), - customUsage: ["[] []"], fullDescription: - "EXPERIMENTAL: This command may modify your source files.\n\n" + - "Runs the Sentry setup wizard to detect your project's framework, " + - "install the SDK, and configure Sentry.\n\n" + - "Supports org/project syntax and a directory positional. Path-like\n" + - "arguments (starting with . / ~) are treated as the directory;\n" + - "everything else is treated as the target.", + "Print a Markdown guide that a coding agent follows to add Sentry to " + + "your project: install the CLI, log in, pick or create a Sentry " + + "project, choose features, install and configure the SDKs using " + + "`sentry docs`, and verify that a test error reaches Sentry. If the " + + "project already uses Sentry, the guide covers upgrading the SDKs and " + + "turning on more features instead.\n\n" + + "The command changes nothing and needs no login. The same guide is " + + "published at https://cli.sentry.dev/get-started-with-sentry.md.", examples: [ - { description: "Interactive setup", command: "sentry init" }, { - description: "Non-interactive agent/CI setup", - command: "sentry init --yes --features errors,tracing,replay", + description: "Print the setup guide", + command: "sentry init", }, { - description: "Dry run to preview changes", - command: "sentry init --dry-run", - }, - { description: "Target a subdirectory", command: "sentry init ./my-app" }, - { - description: "Use a specific organization (auto-detect project)", - command: "sentry init acme/", - }, - { - description: "Use a specific organization and project", - command: "sentry init acme/my-app", - }, - { - description: "Search for a project across organizations", - command: "sentry init my-app", - }, - { - description: "Use a specific project in a subdirectory", - command: "sentry init acme/my-app ./my-project", - }, - { - description: "Assign a team when creating a new project", - command: "sentry init acme/ --team backend", - }, - { - description: "Enable specific features", - command: "sentry init --features profiling,replay", + description: "Print the guide as JSON", + command: "sentry init --json", }, ], }, + output: { human: formatGuide, schema: InitOutputSchema }, parameters: { - positional: boundArguments({ - placeholder: "target/directory", - kind: ORG_PROJECT_OR_DIRECTORY_TARGET, - brief: - "Optional target (/, /, or ) and directory (default: current directory)", - parse: parseInitInput, - resolve: resolveTarget, - }), - flags: { - yes: { - ...YES_FLAG, - brief: - "Accept non-interactive defaults (requires --features outside a TTY)", - }, - "dry-run": DRY_RUN_FLAG, - features: { - kind: "parsed", - parse: choiceListParser(SUPPORTED_FEATURE_NAMES), - brief: - "Features to enable: errors,tracing,logs,replay,profiling,crons,agent-tracing,mcp-observability", - variadic: true, - optional: true, - }, - team: { - kind: "parsed", - parse: parseTeam, - brief: "Team slug to create the project under", - optional: true, - }, - app: { - kind: "parsed", - parse: parseText, - brief: - "App to initialize in a monorepo (required with --yes when multiple apps are detected)", - optional: true, - }, - tui: { - kind: "boolean", - brief: - "Use the Ink-based interactive UI (default). Pass --no-tui to fall back to plain log output.", - default: true, - }, - }, - aliases: { - ...DRY_RUN_ALIASES, - ...YES_ALIASES, - t: "team", - }, + flags: {}, }, - // oxlint-disable-next-line require-yield -- init renders through WizardUI instead of command output - async *func(this: SentryContext, flags: InitFlags, args: InitArguments) { - // 1. Positionals were classified into target vs directory before func - // ran; report a swap now, as before - const { directory: dirArg, warning } = args.parsed; - if (warning) { - log.warn(warning); - } - - // 2. Resolve directory - const targetDir = dirArg ? path.resolve(this.cwd, dirArg) : this.cwd; - - // 3. Parse and validate features before any network or wizard work. - const featuresList = parseFeatures(flags.features); - - // Non-TTY callers (CI/agents) must provide every interactive choice - // needed to start setup. Fail before project lookup, org prefetch, or - // UI creation so the error is deterministic and actionable. - try { - validateNonInteractiveInit(this, flags, featuresList); - } catch (err) { - setTag("wizard.outcome", "context_error"); - throw err; - } - - // 4. Resolve target → org + project - // User-provided slugs were validated when the target was parsed. - // For bare slugs, if no existing project is found, the slug becomes - // the name for a new project (org resolved later by the wizard). - const { org: explicitOrg, project: explicitProject } = await args.resolved; - - // Mastra requires an explicit bearer, unlike regular Sentry API requests - // that refresh OAuth lazily. Resolve it before opening the wizard so an - // AuthError can trigger auto-login without leaving a stale screen behind. - await refreshToken(); - - // 5. Start background org detection when org is not yet known. - // The prefetch runs concurrently with the preamble, the wizard startup, - // and all early suspend/resume rounds — by the time the wizard needs the - // org (inside createSentryProject), the result is already cached. - if (!explicitOrg) { - warmOrgDetection(targetDir); - } - - // 6. Run the wizard. - await runWizard({ - directory: targetDir, - yes: flags.yes, - dryRun: flags["dry-run"], - features: featuresList, - team: flags.team, - app: flags.app, - org: explicitOrg, - project: explicitProject, - // `flags.tui` defaults to `true`. `--no-tui` (auto-generated - // by stricli's flag negation) flips it to `false` — that's the - // signal we forward to the factory as `forceLegacyUi`. - forceLegacyUi: flags.tui === false, - }); + // Stricli requires an AsyncGenerator; the guide is static + async *func(this: SentryContext, _flags: InitFlags) { + yield new CommandOutput({ guide: INIT_GUIDE }); }, }); diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index 09762f320..ad7654034 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -41,7 +41,6 @@ import { printLocalViewerHint, } from "../../lib/formatters/local.js"; import { logger, printJsonLine, printLine } from "../../lib/logger.js"; -import { DEFAULT_MAX_ENVELOPES } from "./retention-limits.js"; import { injectWranglerSpotlightBinding } from "../../lib/wrangler.js"; import { createLocalDaemonSession, @@ -83,9 +82,6 @@ type RunFlags = { readonly "max-envelopes"?: number; }; -/** Buffer size for the auto-started background server. */ -export const BUFFER_SIZE = DEFAULT_MAX_ENVELOPES; - /** * Client-side env var prefixes that frameworks inline into browser bundles * at build time. We inject `SENTRY_SPOTLIGHT` for every variant so the @@ -860,7 +856,7 @@ async function* runWithVerify( ); throw new CliError( `Verification timed out after ${verifyTimeout}s`, - EXIT.WIZARD_VERIFY, + EXIT.VERIFY_FAILED, ); } case "exited": { @@ -868,7 +864,7 @@ async function* runWithVerify( logger.warn("Process exited before sending any events"); throw new CliError( "Process exited before sending any events", - EXIT.WIZARD_VERIFY, + EXIT.VERIFY_FAILED, ); } logger.warn(`Process crashed with code ${outcome.code}`); diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index 7fd2c49dd..a86a3e656 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -58,7 +58,7 @@ import { import { type ResolvedOrg, resolveOrg } from "../../lib/resolve-target.js"; import { buildOrgNotFoundError, - type ResolvedConcreteTeam, + type ResolvedTeam, resolveOrCreateTeam, } from "../../lib/resolve-team.js"; import { applySentryUrlContext } from "../../lib/sentry-url-parser.js"; @@ -222,7 +222,7 @@ async function resolveDryRunTeam( detectedFrom?: string; autoCreateSlug: string; }, -): Promise { +): Promise { try { return await resolveOrCreateTeam(orgSlug, { team: opts.team, @@ -275,7 +275,7 @@ async function createProjectWithAutoTeamFallback( ): Promise< CreatedProjectDetails & { teamSlug: string; - teamSource: ResolvedConcreteTeam["source"]; + teamSource: ResolvedTeam["source"]; } > { const { orgSlug, name, platform } = opts; @@ -612,11 +612,11 @@ async function createOneProject(opts: { } let teamSlug: string; - let teamSource: ResolvedConcreteTeam["source"]; + let teamSource: ResolvedTeam["source"]; let projectDetails: CreatedProjectDetails; try { - const team: ResolvedConcreteTeam = await resolveOrCreateTeam(orgSlug, { + const team: ResolvedTeam = await resolveOrCreateTeam(orgSlug, { team: flags.team, detectedFrom, usageHint: USAGE_HINT, diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 9acb86d95..056041347 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -132,7 +132,7 @@ function enrich403Detail(rawDetail: string | undefined): string { * * @see https://github.com/getsentry/sentry/blob/934f1473f198a62f9268d7140b80cd9ca1e59bb9/src/sentry/api/authentication.py#L536-L539 */ -export function enrich401Detail(rawDetail: string | undefined): string { +function enrich401Detail(rawDetail: string | undefined): string { // Seat-limit lockout, not an auth failure. Sentry returns 401 with // `code: member-disabled-over-limit` when the org is over its member limit // and the caller's seat is disabled — re-authenticating cannot fix this. @@ -141,7 +141,7 @@ export function enrich401Detail(rawDetail: string | undefined): string { "Your account is disabled in this organization because it is over its member limit.", "This is a billing/seat-limit issue, not an auth problem — re-authenticating won't help.", "Ask an org owner to upgrade the plan or free up a seat, then retry.", - "Or target a different org, e.g.: sentry init my-other-org/", + "Or target a different org, e.g.: sentry issue list my-other-org/", ].join("\n "); } diff --git a/packages/cli/src/lib/api/projects.ts b/packages/cli/src/lib/api/projects.ts index 7a0727298..d393a94ac 100644 --- a/packages/cli/src/lib/api/projects.ts +++ b/packages/cli/src/lib/api/projects.ts @@ -299,8 +299,8 @@ function extractPublicKeyFromDsn(dsn: string): string | null { /** * Create a project, fetch its DSN, and build its dashboard URL. * - * Shared core used by both `sentry project create` and `sentry init`. - * Callers handle their own error wrapping and team resolution. + * Used by `sentry project create`, which handles error wrapping and team + * resolution. * * After creation, seeds the project cache and DSN-based project cache * so subsequent commands skip redundant API lookups. diff --git a/packages/cli/src/lib/application.ts b/packages/cli/src/lib/application.ts index e0fe6655d..dec73a735 100644 --- a/packages/cli/src/lib/application.ts +++ b/packages/cli/src/lib/application.ts @@ -34,7 +34,6 @@ import { formatError, getExitCode, OutputError, - WizardError, } from "./errors.js"; import { error as errorColor, warning } from "./formatters/colors.js"; import { stripAnsi } from "./formatters/plain-detect.js"; @@ -292,11 +291,6 @@ function buildApplicationText(options: ArgumentHintOptions): ApplicationText { reportCliError(exc); if (exc instanceof CliError) { - // WizardError with rendered=true: clack already displayed the error. - // Return empty string to avoid double output, exit code flows through. - if (exc instanceof WizardError && exc.rendered) { - return ""; - } const prefix = ansiColor ? errorColor("Error:") : "Error:"; return `${prefix} ${formatError(exc)}`; } diff --git a/packages/cli/src/lib/arg-parsing.ts b/packages/cli/src/lib/arg-parsing.ts index ceaed39ce..5e7cd3d1f 100644 --- a/packages/cli/src/lib/arg-parsing.ts +++ b/packages/cli/src/lib/arg-parsing.ts @@ -316,53 +316,6 @@ function isAlphanumericSegment(segment: string): boolean { return HAS_DIGIT_RE.test(segment) && HAS_LETTER_ASCII_RE.test(segment); } -// --------------------------------------------------------------------------- -// Path detection -// --------------------------------------------------------------------------- - -/** - * Check if a string looks like a filesystem path rather than a slug/identifier. - * - * Uses purely syntactic checks — no filesystem I/O. Detects: - * - `.` (current directory) - * - `./foo`, `../foo` (relative paths) - * - `/foo` (absolute paths) - * - `~` or `~/foo` (home directory paths) - * - * Bare names like `my-org` or `my-project` never match, which is what makes - * this useful for disambiguating positional arguments that could be either - * a filesystem path or an org/project target. - * - * Note: `~` is only matched as `~` alone or `~/...`, not `~foo`. This avoids - * false positives on slugs that happen to start with tilde (valid in Sentry slugs). - * Shell expansion of `~/foo` happens before the CLI sees the argument, so a literal - * `~/foo` reaching this function means the shell didn't expand it (e.g., it was quoted). - * - * @param arg - CLI argument string to check - * @returns true if the string looks like a filesystem path - * - * @example - * looksLikePath(".") // true - * looksLikePath("./subdir") // true - * looksLikePath("../parent") // true - * looksLikePath("/absolute") // true - * looksLikePath("~/home") // true - * looksLikePath("~") // true - * looksLikePath("~foo") // false (could be a slug) - * looksLikePath("my-project") // false - * looksLikePath("acme/app") // false - */ -export function looksLikePath(arg: string): boolean { - return ( - arg === "." || - arg === "~" || - arg.startsWith("./") || - arg.startsWith("../") || - arg.startsWith("/") || - arg.startsWith("~/") - ); -} - // Argument swap detection for view commands // --------------------------------------------------------------------------- diff --git a/packages/cli/src/lib/banner.ts b/packages/cli/src/lib/banner.ts index 26da1d7a1..7882bd077 100644 --- a/packages/cli/src/lib/banner.ts +++ b/packages/cli/src/lib/banner.ts @@ -1,8 +1,7 @@ /** * Banner Formatting * - * Standalone module for the Sentry ASCII banner. - * Extracted to avoid circular imports (wizard-runner → help → app → init → wizard-runner). + * The Sentry ASCII banner that `sentry help` prints. * * The banner is responsive: it renders the widest variant that fits the terminal * so it never wraps (which looks broken on narrow/split-pane terminals). @@ -78,38 +77,6 @@ function colorize(rows: readonly string[]): BannerLine[] { })); } -/** - * The full (widest) banner variant with gradient colors applied. Consumers that - * re-fit on resize (the Ink wizard) should seed with this so they can grow back - * to the full banner as the terminal widens, not just shrink. - */ -export const FULL_BANNER_LINES: BannerLine[] = colorize(BANNER_ROWS_FULL); - -/** - * Widest display width (in code points) among a set of banner lines. - * Used to decide whether a pre-built banner fits the current terminal. - * - * Known limitation: width is counted in code points, which assumes each Block - * Elements glyph (U+2580–U+259F) occupies one terminal cell. Those glyphs have - * East Asian *Ambiguous* width, so a terminal configured to render ambiguous - * characters as wide (some CJK setups) draws them double-width and the banner - * can wrap. This is not portably detectable — and width libraries such as - * `string-width` also treat ambiguous characters as narrow by default, so they - * would report the same value — so it is an accepted limitation of the block-art - * banner. The plain ASCII text mark is unaffected. - */ -export function bannerLinesWidth(lines: readonly BannerLine[]): number { - let max = 0; - for (const { content } of lines) { - // oxlint-disable-next-line typescript/no-misused-spread -- width is measured in code points - const width = [...content].length; - if (width > max) { - max = width; - } - } - return max; -} - /** * Select the widest banner variant that fits within `columns`: * - `>= 66` cols → full arch mark + wordmark @@ -118,8 +85,13 @@ export function bannerLinesWidth(lines: readonly BannerLine[]): number { * - otherwise → no banner (empty) * * This guarantees the banner never exceeds the terminal width, so it never wraps - * into a broken layout on narrow or split-pane terminals (assuming single-width - * block glyphs — see the note on {@link bannerLinesWidth}). + * into a broken layout on narrow or split-pane terminals. + * + * Known limitation: widths assume each Block Elements glyph (U+2580–U+259F) + * occupies one terminal cell. Those glyphs have East Asian *Ambiguous* width, so + * a terminal that renders ambiguous characters as wide (some CJK setups) draws + * them double-width and the banner can wrap. This is not portably detectable, + * and the plain ASCII text mark is unaffected. */ export function bannerLinesForWidth(columns: number): BannerLine[] { if (columns >= FULL_WIDTH) { diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 40464d57b..1edb64535 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -210,7 +210,8 @@ type LocalCommandBuilderArguments< * * Set to `true` for commands that establish or tear down host trust * (`auth login`, `auth logout`) — they must run even when a - * repo-local `.sentryclirc` URL mismatches the current token. + * repo-local `.sentryclirc` URL mismatches the current token — and + * for commands that send nothing to Sentry (`init`). */ readonly skipRcUrlCheck?: boolean; /** @@ -814,7 +815,7 @@ export function buildCommand< // Suppress prompts when parsed command flags require unattended or // machine-readable execution. Using parsed flags avoids confusing aliases - // such as list command `-n` (limit) with init's `-n` (dry-run). + // such as list command `-n` (limit) with `sentry api`'s `-n` (dry-run). setInteractivePromptsAllowed( !( cleanFlags.json === true || diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 4457b14a0..57f095a69 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -368,34 +368,6 @@ const COMPLETERS = Object.freeze({ file: (partial) => completePaths(partial, { directoriesOnly: false }), /** Directory paths only. */ directory: (partial) => completePaths(partial, { directoriesOnly: true }), - /** - * `sentry init`'s `[] []`: what `org/project` does, or - * directories once the word looks like a path (`./`, `../`, `/`, `~/`). - * At most one of each, in either order. - */ - "org-project-or-directory": async (partial, slot) => { - const { looksLikePath } = await import("./arg-parsing.js"); - const [previous, ...rest] = slot.positionals; - if (rest.length > 0) { - return []; - } - const afterTarget = previous !== undefined && !looksLikePath(previous); - if (!(afterTarget || looksLikePath(partial))) { - return completeOrgSlashProject(partial); - } - if (previous !== undefined && !afterTarget) { - return []; - } - // Every suggestion must still look like a path: `.` and `~` alone - // list inside themselves, and a bare word after a target becomes `./…` - const path = - partial === "." || partial === "~" - ? `${partial}/` - : looksLikePath(partial) - ? partial - : `./${partial}`; - return completePaths(path, { directoriesOnly: true }); - }, /** A CLI command path, one route segment per word (`help issue list`). */ "command-path": (partial, slot) => completeSubcommands(slot.positionals.join(" "), partial), diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index 288416d35..698a54203 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -74,10 +74,6 @@ export const FILE_TARGET = "file" satisfies CompletionTarget; /** Directory paths only. */ export const DIRECTORY_TARGET = "directory" satisfies CompletionTarget; -/** `sentry init`'s `[] []`. */ -export const ORG_PROJECT_OR_DIRECTORY_TARGET = - "org-project-or-directory" satisfies CompletionTarget; - /** A CLI command path, one route segment per word. */ export const COMMAND_PATH_TARGET = "command-path" satisfies CompletionTarget; diff --git a/packages/cli/src/lib/constants.ts b/packages/cli/src/lib/constants.ts index 18895cf51..3ff7306b3 100644 --- a/packages/cli/src/lib/constants.ts +++ b/packages/cli/src/lib/constants.ts @@ -16,9 +16,9 @@ export const DEFAULT_SENTRY_URL = `https://${DEFAULT_SENTRY_HOST}`; /** * Name of the JavaScript package directory — used as both a skip target - * when walking project trees (DSN scanner, sourcemap discovery, init - * wizard) and as a path segment when detecting how the CLI itself was - * installed (upgrade detection). + * when walking project trees (DSN scanner, sourcemap discovery) and as a + * path segment when detecting how the CLI itself was installed (upgrade + * detection). */ export const NODE_MODULES_DIRNAME = "node_modules"; diff --git a/packages/cli/src/lib/docs-service.ts b/packages/cli/src/lib/docs-service.ts index aa9bf22cf..e55dff0f8 100644 --- a/packages/cli/src/lib/docs-service.ts +++ b/packages/cli/src/lib/docs-service.ts @@ -1,13 +1,33 @@ +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { formatAuthHeader } from "./auth-header.js"; import { customFetch } from "./custom-ca.js"; import { refreshToken } from "./db/auth.js"; import type { DocsProjectContext } from "./docs-context.js"; -import { ApiError, CliError, EXIT } from "./errors.js"; -import { MASTRA_API_URL } from "./init/constants.js"; -import { assertHostedInitServiceAcceptsTokenHost } from "./init/init-service-auth.js"; +import { ApiError, CliError, EXIT, HostScopeError } from "./errors.js"; +import { getActiveTokenHost } from "./token-host.js"; + +/** The hosted docs service, which only accepts sentry.io credentials. */ +const DOCS_SERVICE_URL = "https://sentry-init-agent.getsentry.workers.dev"; const DOCS_REQUEST_TIMEOUT_MS = 120_000; +/** + * Refuse to send a self-hosted token to the docs service, which validates + * bearers against sentry.io. + * + * @throws {HostScopeError} When the active token belongs to a non-SaaS host. + */ +function assertDocsServiceAcceptsTokenHost(): void { + const tokenHost = getActiveTokenHost(); + if (tokenHost && !isSaaSTrustOrigin(tokenHost)) { + throw new HostScopeError( + "Hosted Sentry docs service", + "https://sentry.io", + tokenHost, + ); + } +} + export type DocsQueryResponse = { answer: string; sources: string[]; @@ -21,9 +41,9 @@ async function postDocs( path: "/api/docs/query" | "/api/docs/list", body: unknown, ): Promise { - assertHostedInitServiceAcceptsTokenHost(); + assertDocsServiceAcceptsTokenHost(); const { token } = await refreshToken(); - const response = await customFetch(`${MASTRA_API_URL}${path}`, { + const response = await customFetch(`${DOCS_SERVICE_URL}${path}`, { body: JSON.stringify(body), headers: { Authorization: formatAuthHeader(token), diff --git a/packages/cli/src/lib/dsn/scan-options.ts b/packages/cli/src/lib/dsn/scan-options.ts index 4676a017e..13a803621 100644 --- a/packages/cli/src/lib/dsn/scan-options.ts +++ b/packages/cli/src/lib/dsn/scan-options.ts @@ -6,8 +6,7 @@ * depth reset, and the `TEXT_EXTENSIONS` allowlist. * * Lives in `dsn/` rather than `scan/` so the core scanner module - * stays policy-free. Other callers (the init wizard, future features) - * bring their own presets. + * stays policy-free. */ import type { WalkOptions } from "../scan/index.js"; diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 2d5a1e4df..50d57a40f 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -184,7 +184,7 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ { name: "SENTRY_INIT", description: - "Used with the install script. When set to `1`, the installer runs `sentry init` after installing the binary to guide you through project setup.", + "Used with the install script. When set to `1`, the installer runs `sentry init` after installing the binary, which prints the guide a coding agent follows to add Sentry to your project.", example: "1", installOnly: true, }, @@ -195,13 +195,6 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ example: "1", installOnly: true, }, - // -- Init wizard -- - { - name: "SENTRY_INIT_TUI", - description: - "Control the TUI (terminal user interface) for `sentry init`. Set to `0` to disable the interactive TUI and use plain text logging output instead. Useful in CI/CD pipelines or environments without full terminal support.", - example: "0", - }, // -- Games -- { name: "SENTRY_GAMES_API_URL", diff --git a/packages/cli/src/lib/error-reporting.ts b/packages/cli/src/lib/error-reporting.ts index fecd59d31..1d4883361 100644 --- a/packages/cli/src/lib/error-reporting.ts +++ b/packages/cli/src/lib/error-reporting.ts @@ -54,7 +54,6 @@ import { TimeoutError, UpgradeError, ValidationError, - WizardError, } from "./errors.js"; import { isSaaS } from "./sentry-web-urls.js"; @@ -357,9 +356,6 @@ function deriveErrorKind(error: Error): string | undefined { if (error instanceof HostScopeError) { return "host_scope"; } - if (error instanceof WizardError) { - return "wizard"; - } // Catch-all for bare CliError — must be checked AFTER all subclasses // because instanceof matches the entire prototype chain. // ConfigError and OutputError intentionally fall through here: @@ -566,7 +562,7 @@ export function enrichEventWithGroupingTags( event.tags["cli_error.class"] = exc.type; // Set kind from exception message prefix so server-side rules can group - // non-CliError exceptions (TypeError, Error, WizardCancelledError, etc.) + // non-CliError exceptions (TypeError, Error, etc.) // that bypass reportCliError (uncaught exceptions, unhandled rejections). if (exc.value) { event.tags["cli_error.kind"] = extractMessagePrefix(exc.value, 4); diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 02d6861a6..ef1dab498 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -93,14 +93,8 @@ export const EXIT = { // 60–69: Command-specific /** Command produced output but should exit non-zero */ OUTPUT_ERROR: 60, - /** Init wizard error (generic) */ - WIZARD: 61, - /** Init wizard: dependency installation failed */ - WIZARD_DEPS: 62, - /** Init wizard: codemod plan or apply failed */ - WIZARD_CODEMOD: 63, - /** Init wizard: user stopped after verification */ - WIZARD_VERIFY: 64, + /** `sentry local run --verify` received no events from the SDK */ + VERIFY_FAILED: 64, } as const; /** @@ -794,24 +788,6 @@ export class TimeoutError extends CliError { } } -/** - * Error thrown by the init wizard when it has already displayed - * the error via clack UI. The `rendered` flag tells the framework - * error handler to skip its own formatting. - */ -export class WizardError extends CliError { - readonly rendered: boolean; - - constructor( - message: string, - options?: { rendered?: boolean; exitCode?: number }, - ) { - super(message, options?.exitCode ?? EXIT.WIZARD); - this.name = "WizardError"; - this.rendered = options?.rendered ?? true; - } -} - // Error Utilities /** @@ -1060,8 +1036,7 @@ export function isUserError(error: unknown): boolean { error instanceof ValidationError || error instanceof DeviceFlowError || error instanceof SeerError || - error instanceof OutputError || - error instanceof WizardError + error instanceof OutputError ) { return true; } diff --git a/packages/cli/src/lib/git.ts b/packages/cli/src/lib/git.ts index cd0a3e8f4..3208223d7 100644 --- a/packages/cli/src/lib/git.ts +++ b/packages/cli/src/lib/git.ts @@ -1,8 +1,8 @@ /** * Centralized git helpers * - * Low-level git primitives used across the CLI: release management, - * init wizard pre-flight checks, and version detection. + * Low-level git primitives used across the CLI: release management + * and version detection. * * Uses `execFileSync` (no shell) from `node:child_process` instead of * `Bun.spawnSync` because this module is also used by the npm/Node @@ -100,31 +100,6 @@ export function isInsideGitWorkTree(cwd?: string): boolean { } } -/** - * Get the list of uncommitted or untracked files. - * - * Returns each line from `git status --porcelain=v1` prefixed with `- `. - * Empty array if the working tree is clean or not a git repo. - * - * @param cwd - Working directory - * @returns Array of formatted status lines (e.g., `["- M src/index.ts", "- ?? new-file.ts"]`) - */ -export function getUncommittedFiles(cwd?: string): string[] { - // 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 raw = git(["status", "--porcelain=v1"], cwd); - if (!raw) { - return []; - } - return raw - .split("\n") - .filter((line) => line.length > 0) - .map((line) => `- ${line}`); - } catch { - return []; - } -} - // --------------------------------------------------------------------------- // Commit info // --------------------------------------------------------------------------- diff --git a/packages/cli/src/lib/init/clack-plain.ts b/packages/cli/src/lib/init/clack-plain.ts deleted file mode 100644 index b5ac270fc..000000000 --- a/packages/cli/src/lib/init/clack-plain.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Plain-output adapter for @clack/prompts. - * - * When stdout is not a TTY (piped, redirected, CI), intercepts clack's - * output functions and replaces them with clean plain-text alternatives. - * Interactive prompts (select, confirm, multiselect) pass through to - * real clack since they're only reached in TTY mode. - * - * The namespace import is load-bearing: existing tests spy on the - * `@clack/prompts` module object via `spyOn(clack, 'cancel')`. Using - * a namespace import here means `clack.cancel` resolves the same live - * binding, so spies transparently intercept calls made through this - * adapter. All re-exports delegate through the namespace object (not - * captured bindings) so that `spyOn` replacements take effect. - */ - -// oxlint-disable-next-line sentry-cli/no-namespace-import -- live binding required for test spyOn compatibility -import * as clack from "@clack/prompts"; -import { isPlainOutput, stripAnsi } from "../formatters/plain-detect.js"; - -function plainWrite(message: string): void { - process.stdout.write(`${stripAnsi(message)}\n`); -} - -/** Plain-text log object matching clack's log interface. */ -const plainLog: typeof clack.log = { - info(message: string) { - plainWrite(message); - }, - warn(message: string) { - plainWrite(`⚠ ${message}`); - }, - warning(message: string) { - plainWrite(`⚠ ${message}`); - }, - error(message: string) { - plainWrite(`✗ ${message}`); - }, - success(message: string) { - plainWrite(`✓ ${message}`); - }, - message(message?: string) { - if (message) { - plainWrite(message); - } - }, - step(message: string) { - plainWrite(message); - }, -}; - -function plainIntro(title?: string): void { - if (title) { - plainWrite(title); - } -} - -function plainOutro(message?: string): void { - if (message) { - plainWrite(message); - } -} - -function plainCancel(message?: string): void { - if (message) { - plainWrite(message); - } -} - -// Re-export with plain-output interception for output functions. -// Each wrapper checks isPlainOutput() at call time so env var changes -// (e.g. in tests) take effect immediately. - -export const intro: typeof clack.intro = (...args) => - isPlainOutput() ? plainIntro(...args) : clack.intro(...args); - -export const outro: typeof clack.outro = (...args) => - isPlainOutput() ? plainOutro(...args) : clack.outro(...args); - -export const cancel: typeof clack.cancel = (...args) => - isPlainOutput() ? plainCancel(...args) : clack.cancel(...args); - -/** - * Log object that delegates to plain-text writers when output is plain, - * or to real clack.log methods otherwise. - * - * Implemented as a Proxy so that every property access evaluates - * `isPlainOutput()` fresh — no stale closure captures. - */ -export const log: typeof clack.log = new Proxy(clack.log, { - get(target, prop: string) { - if (isPlainOutput() && prop in plainLog) { - return plainLog[prop as keyof typeof plainLog]; - } - return target[prop as keyof typeof target]; - }, -}); - -// Pass through interactive functions via thin wrappers that delegate -// through the namespace object. This preserves test spyOn compatibility: -// spyOn(clack, 'select') replaces the property on the module namespace, -// and these wrappers read from that namespace at call time. - -export const select: typeof clack.select = (...args) => clack.select(...args); - -export const confirm: typeof clack.confirm = (...args) => - clack.confirm(...args); - -export const multiselect: typeof clack.multiselect = (...args) => - clack.multiselect(...args); - -export const isCancel: typeof clack.isCancel = ( - value: unknown, -): value is symbol => clack.isCancel(value); diff --git a/packages/cli/src/lib/init/clack-utils.ts b/packages/cli/src/lib/init/clack-utils.ts deleted file mode 100644 index a644c70fc..000000000 --- a/packages/cli/src/lib/init/clack-utils.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * Wizard Utilities - * - * Shared cancellation helpers and feature labels for the init wizard. - * - * The file name is preserved (vs. renaming to `wizard-utils.ts`) to - * keep the diff in PR 4 focused on the clack removal — the next - * cleanup PR can do the rename. Despite the historical name nothing - * here references clack any more. - */ - -import { isCancelled } from "./ui/types.js"; - -export class WizardCancelledError extends Error { - constructor() { - super("Setup cancelled."); - this.name = "WizardCancelledError"; - } -} - -/** - * Coerce a possibly-cancelled prompt result into the resolved value, or - * throw `WizardCancelledError` on cancellation. - * - * The return type uses `Exclude` so callers passing a union - * that includes a symbol member (e.g. `string[] | typeof CANCELLED`) - * receive the narrowed non-symbol type back — TypeScript otherwise - * widens `T` to the full union and refuses to call array methods on it. - */ -export function abortIfCancelled(value: T): Exclude { - if (isCancelled(value)) { - throw new WizardCancelledError(); - } - return value as Exclude; -} - -const FEATURE_INFO: Record = { - errorMonitoring: { - label: "Error Monitoring", - description: "Automatically capture exceptions and stack traces", - }, - performanceMonitoring: { - label: "Tracing", - description: - "Find bottlenecks, broken requests, and understand application flow end-to-end", - }, - sessionReplay: { - label: "Session Replay", - description: "Watch real user sessions to see what went wrong", - }, - profiling: { - label: "Profiling", - description: - "Pinpoint the functions and lines of code responsible for performance issues", - }, - logs: { - label: "Logs", - description: "See logs in context with errors and performance issues", - }, - metrics: { - label: "Application Metrics", - description: - "Track application performance and usage over time with custom metrics", - }, - sourceMaps: { - label: "Source Maps", - description: - "Turn minified production stack traces back into your original source code", - }, - crons: { - label: "Crons & Uptime Monitors", - description: "Detect failed, missed, or delayed scheduled jobs", - }, - attachments: { - label: "Attachments", - description: "Link user-supplied data to captured events", - }, - aiMonitoring: { - label: "Agent Tracing", - description: - "Understand AI calls, latency, token usage, cost, and failures", - }, - mcpObservability: { - label: "MCP Observability", - description: - "Trace MCP tool calls and understand failures across agent workflows", - }, - userFeedback: { - label: "User Feedback", - description: - "Collect user reports with the error and session context needed to investigate", - }, - reactFeatures: { - label: "React Features", - description: - "Capture React-specific errors with component and rendering context", - }, -}; - -export function featureLabel(id: string): string { - return FEATURE_INFO[id]?.label ?? id; -} - -/** Returns product-oriented supporting copy for a known feature. */ -export function featureDescription(id: string): string | undefined { - return FEATURE_INFO[id]?.description; -} - -const FEATURE_DISPLAY_ORDER = [ - "errorMonitoring", - "logs", - "sessionReplay", - "performanceMonitoring", - "aiMonitoring", - "attachments", - "crons", - "metrics", - "mcpObservability", - "profiling", - "reactFeatures", - "sourceMaps", - "userFeedback", -]; - -/** Sort features into the canonical order used by summaries and final output. */ -export function sortFeatures(features: string[]): string[] { - return [...features].sort((a, b) => { - const ai = FEATURE_DISPLAY_ORDER.indexOf(a); - const bi = FEATURE_DISPLAY_ORDER.indexOf(b); - return ( - (ai === -1 ? Number.MAX_SAFE_INTEGER : ai) - - (bi === -1 ? Number.MAX_SAFE_INTEGER : bi) - ); - }); -} - -export const STEP_LABELS: Record = { - "discover-context": "Analyzing project structure", - "select-target-app": "Selecting target application", - "resolve-dir": "Resolving project directory", - "check-existing-sentry": "Checking for existing Sentry installation", - "detect-platform": "Analyzing project and Sentry features", - "ensure-sentry-project": "Setting up Sentry project", - "select-features": "Selecting features", - "plan-codemods": "Planning code modifications", - "apply-codemods": "Applying code modifications and installing dependencies", - "verify-changes": "Verifying changes", - "open-sentry-ui": "Finishing up", -}; - -/** - * Canonical execution order of the wizard's workflow steps. - * - * Used by the Ink sidebar's progress checklist as the static - * pre-rendered list. The wizard advertises step transitions via - * `WizardUI.setStep(...)`; the store back-fills any earlier - * `pending` rows as `skipped` when a later step starts (the workflow - * can only move forward, so a later transition implies any earlier - * pending step was bypassed by an `if`-branch in the workflow). - * - * Order must match the actual Mastra workflow order or the back-fill - * logic will mis-mark steps as skipped. - */ -export const CANONICAL_STEP_ORDER: readonly string[] = [ - "discover-context", - "select-target-app", - "resolve-dir", - "check-existing-sentry", - "detect-platform", - "ensure-sentry-project", - "select-features", - "plan-codemods", - "apply-codemods", - "verify-changes", - "open-sentry-ui", -]; - -/** - * Subset of {@link CANONICAL_STEP_ORDER} surfaced in the progress - * checklist. The Ink sidebar is 36 cols wide and shares vertical - * space with the tip card and the files-read panel, so showing all - * 11 step rows would push the files panel off-screen on shorter - * terminals. - * - * The hidden steps (`select-target-app`, `resolve-dir`, - * `check-existing-sentry`) are plumbing — users care that "Setting up - * Sentry project" happened, not that we resolved their working - * directory along the way. Dependency installation is part of - * `apply-codemods`; the server removed the separate `install-deps` step in - * getsentry/cli-init-api#140. - */ -export const CHECKLIST_VISIBLE_STEPS: readonly string[] = [ - "discover-context", - "detect-platform", - "ensure-sentry-project", - "select-features", - "plan-codemods", - "apply-codemods", - "verify-changes", - "open-sentry-ui", -]; - -/** - * Active-voice step descriptions shown as spinner messages while - * each step runs. More descriptive than the sidebar labels. - */ -export const STEP_ACTIVE_LABELS: Record = { - "discover-context": "Scanning project structure...", - "select-target-app": "Selecting target application...", - "resolve-dir": "Resolving project directory...", - "check-existing-sentry": "Checking for existing Sentry setup...", - "detect-platform": "Analyzing project and Sentry support...", - "ensure-sentry-project": "Configuring Sentry project...", - "select-features": "Preparing feature selection...", - "plan-codemods": "Planning code changes...", - "apply-codemods": "Applying code changes and installing dependencies...", - "verify-changes": "Verifying setup...", - "open-sentry-ui": "Finishing up...", -}; - -/** - * Sidebar-friendly abbreviations of {@link STEP_LABELS}. The full - * labels stay the source-of-truth for the spinner message in the main - * column; only the 36-col sidebar checklist uses these. - * - * Falls back to the full label if a step isn't listed here. - */ -export const STEP_LABELS_SHORT: Record = { - "discover-context": "Discovering project", - "detect-platform": "Checking Sentry support", - "ensure-sentry-project": "Setting up project", - "select-features": "Selecting features", - "plan-codemods": "Planning changes", - "apply-codemods": "Applying changes + deps", - "verify-changes": "Verifying changes", - "open-sentry-ui": "Finishing up", -}; - -/** Resolve a step id to its sidebar checklist label. */ -export function shortStepLabel(stepId: string): string { - return STEP_LABELS_SHORT[stepId] ?? STEP_LABELS[stepId] ?? stepId; -} - -/** - * Rotating progress messages shown while the CLI waits on a long-running - * server-side phase that doesn't emit intermediate suspends. The messages - * cycle on a timer so the spinner text changes and the UI doesn't look - * frozen. - * - * Only steps with known long waits need entries here. Steps that suspend - * frequently (read-files, apply-codemods) already update the spinner via - * the suspend payload's `detail` field. - */ -export const STEP_PROGRESS_MESSAGES: Record = { - "plan-codemods": [ - "Fetching SDK documentation...", - "Analyzing integration requirements...", - "Generating code modification plan...", - "Reviewing planned changes for correctness...", - "Finalizing integration plan...", - ], - "detect-platform": [ - "Scanning project files...", - "Identifying framework and runtime...", - "Matching the Sentry SDK...", - "Searching official Sentry docs...", - "Checking feature support...", - "Validating project recommendations...", - ], -}; - -/** Interval between rotating progress messages (ms). */ -export const PROGRESS_ROTATE_INTERVAL_MS = 12_000; diff --git a/packages/cli/src/lib/init/constants.ts b/packages/cli/src/lib/init/constants.ts deleted file mode 100644 index 4c3e27e0c..000000000 --- a/packages/cli/src/lib/init/constants.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const DEFAULT_MASTRA_API_URL = - "https://sentry-init-agent.getsentry.workers.dev"; - -export const MASTRA_API_URL = - process.env.MASTRA_API_URL ?? DEFAULT_MASTRA_API_URL; - -export const WORKFLOW_ID = "sentry-wizard"; - -export const INIT_PROTOCOL_VERSION = 1 as const; -export const INIT_REQUEST_CONFLICT_CODE = "init_request_conflict" as const; - -export const SENTRY_DOCS_URL = "https://docs.sentry.io/platforms/"; - -/** Command that installs the Sentry coding-agent plugin (skills) into detected agents. */ -export const SENTRY_AGENT_INSTALL_COMMAND = "npx @sentry/ai install"; - -export const MAX_FILE_BYTES = 262_144; // 256KB per file -export const MAX_OUTPUT_BYTES = 65_536; // 64KB stdout/stderr truncation -export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000; // 2 minutes -export const API_TIMEOUT_MS = 210_000; // 3.5 minutes timeout for Mastra API calls - -// Exit codes returned by the remote workflow. -// These are internal to the workflow protocol — they're mapped to EXIT.* -// constants (from src/lib/errors.ts) before reaching process exit. -export const EXIT_PLATFORM_NOT_DETECTED = 20; -export const EXIT_DEPENDENCY_INSTALL_FAILED = 30; -export const EXIT_VERIFICATION_FAILED = 50; - -// Step ID used in dry-run special-case logic -export const VERIFY_CHANGES_STEP = "verify-changes"; - -// The feature that is always included in every setup -export const REQUIRED_FEATURE = "errorMonitoring"; diff --git a/packages/cli/src/lib/init/existing-project.ts b/packages/cli/src/lib/init/existing-project.ts deleted file mode 100644 index 13d565df7..000000000 --- a/packages/cli/src/lib/init/existing-project.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getProject, tryGetPrimaryDsn } from "../api-client.js"; -import { ApiError } from "../errors.js"; -import { buildProjectUrl } from "../sentry-web-urls.js"; -import type { ExistingProjectData } from "./types.js"; - -/** - * Fetch Sentry metadata for an existing project. - * - * Returns `null` when the project does not exist, while allowing other API - * errors to propagate so callers can decide whether the lookup is best-effort - * or should fail the current operation. - */ -export async function tryGetExistingProjectData( - orgSlug: string, - projectSlug: string, -): Promise { - try { - const project = await getProject(orgSlug, projectSlug); - const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); - return { - orgSlug, - projectSlug: project.slug, - projectId: project.id, - dsn: dsn ?? "", - url: buildProjectUrl(orgSlug, project.slug), - ...(project.platform ? { platform: project.platform } : {}), - }; - } catch (error) { - if (error instanceof ApiError && error.status === 404) { - return null; - } - throw error; - } -} diff --git a/packages/cli/src/lib/init/feedback.ts b/packages/cli/src/lib/init/feedback.ts deleted file mode 100644 index 5e7cff226..000000000 --- a/packages/cli/src/lib/init/feedback.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type InitFeedbackOutcome = "success" | "cancelled" | "failed"; - -const FEEDBACK_COMMANDS: Record = { - success: '$ sentry cli feedback "sentry init worked well"', - cancelled: '$ sentry cli feedback "sentry init was cancelled"', - failed: '$ sentry cli feedback "sentry init failed"', -}; - -const FEEDBACK_COPY: Record = { - success: ["Tell us what felt great or rough:"], - cancelled: [ - "Sad to see setup stop. Was something going sideways?", - "Tell us so we can fix it:", - ], - failed: ["Setup hit a wall.", "Tell us what happened so we can fix it:"], -}; - -export function formatFeedbackHint(outcome: InitFeedbackOutcome): string { - return [...FEEDBACK_COPY[outcome], FEEDBACK_COMMANDS[outcome]].join("\n"); -} diff --git a/packages/cli/src/lib/init/formatters.ts b/packages/cli/src/lib/init/formatters.ts deleted file mode 100644 index 6b55d6100..000000000 --- a/packages/cli/src/lib/init/formatters.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Output Formatters - * - * Translate the raw workflow result into the structured `WizardSummary` - * the UI implementations render. The previous version assembled - * terminal-flavored markdown (color tags, an aligned key/value table, - * a tree of changed files) and pushed it through `ui.log.message`. - * That worked for `LoggingUI` (which calls `renderMarkdown`) but the - * earlier TUI showed literal markup like `~` and - * pipe-cells because the underlying text primitive couldn't parse - * markdown — only strip ANSI. - * - * Now `formatResult` calls `ui.summary(structuredData)` and lets each - * implementation decide how to lay it out. `formatError` still uses - * `ui.log.*` because errors are short enough to live as plain text. - */ - -import { basename } from "node:path"; -import { terminalLink } from "../formatters/colors.js"; -import { stripAnsi } from "../formatters/plain-detect.js"; -import { parseOrgProjectFromSettingsUrl } from "../sentry-urls.js"; -import { - buildEventSearchUrl, - buildProjectIssuesUrl, -} from "../sentry-web-urls.js"; -import { featureLabel, sortFeatures } from "./clack-utils.js"; -import { - EXIT_DEPENDENCY_INSTALL_FAILED, - EXIT_PLATFORM_NOT_DETECTED, - EXIT_VERIFICATION_FAILED, - SENTRY_AGENT_INSTALL_COMMAND, -} from "./constants.js"; -import type { - SentryProjectIdentity, - WizardOutput, - WorkflowRunResult, -} from "./types.js"; -import type { WizardCompletion, WizardSummary, WizardUI } from "./ui/types.js"; -import type { VerifyResult } from "./verify-setup.js"; - -/** Package managers whose dev script we can name for the "start your app" step. */ -const KNOWN_PACKAGE_MANAGERS = ["pnpm", "yarn", "bun", "npm"] as const; - -/** A genuine Sentry project id is all digits — anything else (e.g. the - * "(dry-run)" stub) must not be used to scope the Issues stream. */ -const NUMERIC_PROJECT_ID = /^\d+$/; - -/** Best-effort human name for the project, for the completion header. */ -function deriveProjectName(output: WizardOutput): string { - if (output.projectSlug) { - return output.projectSlug; - } - if (output.projectDir) { - const base = basename(output.projectDir); - if (base && base !== "." && base !== "/") { - return base; - } - } - return "your project"; -} - -/** Derive a "start your app" command from the install command's package manager. */ -function deriveStartCommand( - commands: string[] | undefined, -): string | undefined { - const first = commands?.[0]?.trim(); - if (!first) { - return; - } - const pm = KNOWN_PACKAGE_MANAGERS.find((p) => first.startsWith(`${p} `)); - if (!pm) { - return; - } - return pm === "npm" ? "npm run dev" : `${pm} dev`; -} - -/** Assemble the structured data the interactive completion screen renders. */ -function buildCompletion( - output: WizardOutput, - verify: VerifyResult | undefined, - featureBlurbs: { label: string; blurb: string }[], -): WizardCompletion { - // The server may not hand the org slug back directly (older deploys), so - // fall back to recovering it from the settings URL it did send. This keeps - // the Issues link working regardless of server version. - const parsed = output.sentryProjectUrl - ? parseOrgProjectFromSettingsUrl(output.sentryProjectUrl) - : {}; - const orgSlug = output.orgSlug ?? parsed.orgSlug; - // Only a genuine numeric project id can scope the Issues stream. Guard against - // non-numeric placeholders — e.g. the "(dry-run)" sentinel from the dry-run - // project stub — leaking into the URL as `?project=(dry-run)`. A missing id - // just yields the org-wide Issues stream. - const projectId = NUMERIC_PROJECT_ID.test(output.projectId ?? "") - ? output.projectId - : undefined; - // Prefer the project-scoped Issues stream (where errors land); fall back to - // whatever project URL the server sent if we couldn't resolve the org. - const issuesUrl = orgSlug - ? buildProjectIssuesUrl(orgSlug, projectId) - : output.sentryProjectUrl; - const received = verify?.verified ?? false; - const eventUrl = - received && verify?.eventId && orgSlug - ? buildEventSearchUrl(orgSlug, verify.eventId) - : undefined; - - return { - projectName: deriveProjectName(output), - features: sortFeatures(output.features ?? []).map(featureLabel), - featureBlurbs, - changedFileCount: output.changedFiles?.length ?? 0, - issuesUrl, - verification: { received, eventUrl }, - agentInstallCommand: SENTRY_AGENT_INSTALL_COMMAND, - startCommand: deriveStartCommand(output.commands), - }; -} - -/** - * Build the structured summary handed to `ui.summary()`. - * - * Returns `null` when there's nothing useful to display — the caller - * skips the summary call entirely in that case so empty panels don't - * appear. - */ -function buildSummary( - output: WizardOutput, - verify: VerifyResult | undefined, -): WizardSummary | null { - // Resolve blurbs first so the Features row can check the *resolved* length. - // If the agent returns blurbs with wrong IDs they all drop out here, and - // the Features row falls back to showing correctly. - const blurbMap = new Map( - (output.featureBlurbs ?? []).map(({ feature, blurb }) => [ - feature, - stripAnsi(blurb), - ]), - ); - const featureBlurbs = sortFeatures(output.features ?? []) - .map((feature) => { - const blurb = blurbMap.get(feature); - return blurb ? { label: featureLabel(feature), blurb } : null; - }) - .filter((b): b is { label: string; blurb: string } => b !== null); - - const fields: WizardSummary["fields"] = []; - - if (output.platform) { - fields.push({ label: "Platform", value: output.platform }); - } - if (output.projectDir) { - fields.push({ label: "Directory", value: output.projectDir }); - } - if (output.features?.length && !featureBlurbs.length) { - fields.push({ - label: "Features", - value: output.features.map(featureLabel).join(", "), - }); - } - if (output.commands?.length) { - fields.push({ - label: "Commands", - value: output.commands.join("; "), - }); - } - if (output.sentryProjectUrl) { - fields.push({ label: "Project", value: output.sentryProjectUrl }); - } - if (output.docsUrl) { - fields.push({ label: "Docs", value: output.docsUrl }); - } - - const changedFiles = output.changedFiles ?? []; - const completion = buildCompletion(output, verify, featureBlurbs); - - const hasContent = - fields.length > 0 || - changedFiles.length > 0 || - featureBlurbs.length > 0 || - completion.issuesUrl !== undefined || - completion.features.length > 0; - - if (!hasContent) { - return null; - } - - return { - fields, - ...(changedFiles.length > 0 ? { changedFiles } : {}), - ...(featureBlurbs.length > 0 ? { featureBlurbs } : {}), - completion, - }; -} - -/** - * Overlay the locally-captured Sentry project identity onto the workflow output. - * - * The CLI creates the project itself, so its captured org/project/projectId are - * authoritative and take precedence over anything the server echoes back. The - * server no longer sends these fields (see cli-init-api#239), but keeping the - * `output.*` fallback means the CLI still works against any server version. - */ -function mergeSentryProjectIdentity( - output: WizardOutput, - identity: SentryProjectIdentity | undefined, -): WizardOutput { - if (!identity) { - return output; - } - return { - ...output, - orgSlug: identity.orgSlug ?? output.orgSlug, - projectSlug: identity.projectSlug ?? output.projectSlug, - projectId: identity.projectId ?? output.projectId, - sentryProjectUrl: output.sentryProjectUrl ?? identity.url, - }; -} - -export function formatResult( - result: WorkflowRunResult, - ui: WizardUI, - verify?: VerifyResult, - sentryProject?: SentryProjectIdentity, -): void { - const output = mergeSentryProjectIdentity(result.result ?? {}, sentryProject); - const summary = buildSummary(output, verify); - - if (summary) { - ui.summary(summary); - } - - if (output.warnings?.length) { - for (const w of output.warnings) { - ui.log.warn(w); - } - } - - ui.log.info("Please review the changes above before committing."); - - ui.outro( - output.message - ? stripAnsi(output.message) - : "Sentry SDK installed successfully!", - ); - ui.feedback("success"); -} - -export function formatError(result: WorkflowRunResult, ui: WizardUI): void { - const inner = result.result; - const message = - result.error ?? inner?.message ?? "Wizard failed with an unknown error"; - const exitCode = inner?.exitCode ?? 1; - - ui.log.error(String(message)); - - if (exitCode === EXIT_PLATFORM_NOT_DETECTED) { - ui.log.warn( - "Hint: Could not detect your project's platform. Check that the directory contains a valid project.", - ); - } else if (exitCode === EXIT_DEPENDENCY_INSTALL_FAILED) { - const commands = inner?.commands; - if (commands?.length) { - ui.log.warn( - `You can install dependencies manually:\n${commands.map((cmd) => ` $ ${cmd}`).join("\n")}`, - ); - } - } else if (exitCode === EXIT_VERIFICATION_FAILED) { - ui.log.warn( - "Hint: Fix the verification issues and run 'sentry init' again.", - ); - } - - const docsUrl = inner?.docsUrl; - if (docsUrl) { - ui.log.info(`Docs: ${terminalLink(docsUrl)}`); - } - - ui.cancel("Setup failed"); - ui.feedback("failed"); -} diff --git a/packages/cli/src/lib/init/git.ts b/packages/cli/src/lib/init/git.ts deleted file mode 100644 index 31ec99f18..000000000 --- a/packages/cli/src/lib/init/git.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Git Safety Checks for Init Wizard - * - * Pre-flight checks to verify the user is in a git repo with a clean - * working tree before the init wizard starts modifying files. - * - * Low-level git primitives live in `src/lib/git.ts`. This module - * re-exports them for backward compatibility and adds the interactive - * `checkGitStatus` orchestrator. All UI I/O is routed through the - * injected `WizardUI` so the same code drives `InkUI` (interactive) - * and `LoggingUI` (CI / npm) paths. - */ - -import { - getUncommittedFiles, - isInsideGitWorkTree as isInsideWorkTree, -} from "../git.js"; -import type { WizardUI } from "./ui/types.js"; -import { isCancelled } from "./ui/types.js"; - -/** Maximum number of uncommitted files to display before truncating. */ -const MAX_DISPLAYED_FILES = 5; - -/** - * Check if the current directory is inside a git work tree. - * Thin wrapper that adapts the `{cwd}` object signature expected by the init wizard. - */ -export function isInsideGitWorkTree(opts: { cwd: string }): boolean { - return isInsideWorkTree(opts.cwd); -} - -/** - * Get uncommitted or untracked files formatted for display. - * Thin wrapper that adapts the `{cwd}` object signature expected by the init wizard. - */ -export function getUncommittedOrUntrackedFiles(opts: { - cwd: string; -}): string[] { - return getUncommittedFiles(opts.cwd); -} - -/** - * Checks git status and prompts the user if there are concerns. - * Returns `true` to continue, `false` to abort. - */ -export async function checkGitStatus(opts: { - cwd: string; - yes: boolean; - ui: WizardUI; -}): Promise { - const { cwd, yes, ui } = opts; - - if (!isInsideGitWorkTree({ cwd })) { - if (yes) { - ui.log.warn( - "You are not inside a git repository. Unable to revert changes if something goes wrong.", - ); - return true; - } - const proceed = await ui.confirm({ - message: - "You are not inside a git repository. Unable to revert changes if something goes wrong. Continue?", - }); - if (isCancelled(proceed)) { - return false; - } - return Boolean(proceed); - } - - const uncommitted = getUncommittedOrUntrackedFiles({ cwd }); - if (uncommitted.length > 0) { - const displayed = uncommitted.slice(0, MAX_DISPLAYED_FILES); - const remaining = uncommitted.length - displayed.length; - if (remaining > 0) { - displayed.push(` + ${remaining} more uncommitted files`); - } - const fileList = displayed.join("\n"); - if (yes) { - ui.log.warn( - `You have uncommitted or untracked files:\n${fileList}\nProceeding anyway (--yes).`, - ); - return true; - } - ui.log.warn(`You have uncommitted or untracked files:\n${fileList}`); - const proceed = await ui.confirm({ - message: "Continue with uncommitted changes?", - }); - if (isCancelled(proceed)) { - return false; - } - return Boolean(proceed); - } - - return true; -} diff --git a/packages/cli/src/lib/init/init-service-auth.ts b/packages/cli/src/lib/init/init-service-auth.ts deleted file mode 100644 index c08008642..000000000 --- a/packages/cli/src/lib/init/init-service-auth.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { MastraClientError } from "@mastra/client-js"; -import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; -import { enrich401Detail } from "../api/infrastructure.js"; -import { ApiError, HostScopeError } from "../errors.js"; -import { normalizeOrigin } from "../sentry-urls.js"; -import { getActiveTokenHost } from "../token-host.js"; -import { - DEFAULT_MASTRA_API_URL, - MASTRA_API_URL, - WORKFLOW_ID, -} from "./constants.js"; - -const INIT_SERVICE_REJECTED_TOKEN_MESSAGE = - "Sentry Init setup service rejected your authentication token."; -const WORKFLOW_ENDPOINT = `/api/workflows/${WORKFLOW_ID}`; -export const WORKFLOW_CREATE_RUN_ENDPOINT = `${WORKFLOW_ENDPOINT}/create-run`; -export const WORKFLOW_RESUME_ASYNC_ENDPOINT = `${WORKFLOW_ENDPOINT}/resume-async`; -export const WORKFLOW_START_ASYNC_ENDPOINT = `${WORKFLOW_ENDPOINT}/start-async`; -const MASTRA_HTTP_401_RE = /HTTP error!\s*status:\s*401\b/i; -const RETRYABLE_AUTH_FAILURE_CODES = new Set([ - "AUTH_UPSTREAM_TIMEOUT", - "AUTH_UPSTREAM_UNAVAILABLE", -]); - -function classifyInitServiceAuthFailure( - err: unknown, - endpoint: string, -): ApiError | null { - if (!(err instanceof Error && MASTRA_HTTP_401_RE.test(err.message))) { - return null; - } - - return new ApiError( - INIT_SERVICE_REJECTED_TOKEN_MESSAGE, - 401, - enrich401Detail("Unauthorized: invalid token"), - endpoint, - ); -} - -function isRetryableInitServiceAuthFailure(err: unknown): boolean { - if ( - !(err instanceof MastraClientError) || - err.status !== 503 || - typeof err.body !== "object" || - err.body === null - ) { - return false; - } - - const body = err.body as Record; - return ( - body.safeToRetry === true && - typeof body.code === "string" && - RETRYABLE_AUTH_FAILURE_CODES.has(body.code) - ); -} - -export async function withInitServiceAuthClassification( - operation: () => Promise, - endpoint: string, -): Promise { - let retried = false; - - while (true) { - try { - return await operation(); - } catch (err) { - const classified = classifyInitServiceAuthFailure(err, endpoint); - if (classified) { - throw classified; - } - if (!retried && isRetryableInitServiceAuthFailure(err)) { - retried = true; - continue; - } - throw err; - } - } -} - -export function assertHostedInitServiceAcceptsTokenHost(): void { - const tokenHost = getActiveTokenHost(); - const usesHostedInitService = - normalizeOrigin(MASTRA_API_URL) === normalizeOrigin(DEFAULT_MASTRA_API_URL); - - if (tokenHost && usesHostedInitService && !isSaaSTrustOrigin(tokenHost)) { - throw new HostScopeError( - "Hosted Sentry Init setup service", - "https://sentry.io", - tokenHost, - ); - } -} diff --git a/packages/cli/src/lib/init/interactive.ts b/packages/cli/src/lib/init/interactive.ts deleted file mode 100644 index c481990e2..000000000 --- a/packages/cli/src/lib/init/interactive.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Interactive Dispatcher - * - * Handles interactive prompts from the remote workflow. - * Supports select, multi-select, and confirm prompts. - * Respects --yes flag for non-interactive mode. - * - * All UI I/O goes through the injected `WizardUI` so the dispatcher - * works identically against `InkUI` (interactive Bun binary) and - * `LoggingUI` (CI / npm fallback). - */ - -import { setTag } from "@sentry/node"; -import { WizardError } from "../errors.js"; -import { - abortIfCancelled, - featureDescription, - featureLabel, -} from "./clack-utils.js"; -import { REQUIRED_FEATURE } from "./constants.js"; -import type { - ConfirmPayload, - InteractiveContext, - InteractivePayload, - MultiSelectPayload, - SelectPayload, -} from "./types.js"; -import type { PromptDetail, WizardUI } from "./ui/types.js"; - -function prependRequiredFeature(features: string[]): string[] { - if (features.includes(REQUIRED_FEATURE)) { - return features; - } - return [REQUIRED_FEATURE, ...features]; -} - -type FeatureReviewAction = "continue" | "back"; - -const DEFAULT_FEATURE_ORDER = [ - REQUIRED_FEATURE, - "logs", - "sessionReplay", - "performanceMonitoring", -] as const; -const DEFAULT_FEATURES = new Set(DEFAULT_FEATURE_ORDER); -const DEFAULT_FEATURE_RANK = new Map( - DEFAULT_FEATURE_ORDER.map((feature, index) => [feature, index]), -); -// These features need code-level choices this selector cannot configure yet. -const UNSUPPORTED_INIT_FEATURES = new Set([ - "attachments", - "metrics", - "userFeedback", -]); -const FEATURE_SELECTION_CONTEXT = - "Based on your project, these features are available to set up."; - -function sortFeatureOptions(features: string[]): string[] { - return [...features].sort((a, b) => { - const rankDifference = - (DEFAULT_FEATURE_RANK.get(a) ?? Number.MAX_SAFE_INTEGER) - - (DEFAULT_FEATURE_RANK.get(b) ?? Number.MAX_SAFE_INTEGER); - if (rankDifference !== 0) { - return rankDifference; - } - return featureLabel(a).localeCompare(featureLabel(b), "en"); - }); -} - -function normalizeFeatureSelection(features: string[]): string[] { - const normalized = new Set(features); - // The server enforces this dependency too; normalizing before review keeps - // the reviewed, resumed, and restored-on-Back selections identical. - if ( - features.includes("aiMonitoring") || - features.includes("mcpObservability") || - features.includes("profiling") - ) { - normalized.add("performanceMonitoring"); - } - return sortFeatureOptions([...normalized]); -} - -function buildFeatureReviewDetails(features: string[]): PromptDetail[] { - return [ - { text: "We'll add these features:" }, - ...features.map((feature) => ({ - text: `✓ ${featureLabel(feature)}`, - tone: "success" as const, - })), - ]; -} - -export async function handleInteractive( - payload: InteractivePayload, - options: InteractiveContext, - ui: WizardUI, -): Promise> { - switch (payload.kind) { - case "select": - return await handleSelect(payload, options, ui); - case "multi-select": - return await handleMultiSelect(payload, options, ui); - case "confirm": - return await handleConfirm(payload, options, ui); - default: - throw new WizardError( - `Unsupported interactive prompt kind: "${(payload as { kind: string }).kind}"`, - { rendered: false }, - ); - } -} - -type AppEntry = { name: string; path: string; framework?: string }; - -function formatAppList(apps: AppEntry[], items: string[]): string[] { - // Name-based lookup keeps this correct even when payload.options and - // payload.apps arrive with different lengths. - const nameWidth = Math.max(1, ...items.map((n) => n.length)); - return items.map((name) => { - const meta = apps.find((a) => a.name === name); - const fw = meta?.framework ? ` (${meta.framework})` : ""; - const path = meta?.path ? ` ${meta.path}` : ""; - return ` ${name.padEnd(nameWidth)}${fw}${path}`; - }); -} - -function buildMultiAppMessage(apps: AppEntry[], items: string[]): string { - const exampleApp = items[0] ?? ""; - return [ - `This monorepo has ${items.length} apps. Use --app to specify which one to initialize:`, - "", - ` sentry init --yes --features --app ${exampleApp}`, - "", - "Available apps:", - ...formatAppList(apps, items), - "", - "Or run without --yes to pick interactively:", - " sentry init", - ].join("\n"); -} - -function buildAppNotFoundMessage( - requested: string, - apps: AppEntry[], - items: string[], -): string { - const exampleApp = items[0] ?? ""; - return [ - `App "${requested}" not found in this monorepo.`, - "", - "Available apps:", - ...formatAppList(apps, items), - "", - "Re-run with --app , for example:", - ` sentry init --yes --features --app ${exampleApp}`, - ].join("\n"); -} - -async function handleSelect( - payload: SelectPayload, - options: InteractiveContext, - ui: WizardUI, -): Promise> { - const apps = payload.apps ?? []; - const items = payload.options ?? apps.map((a) => a.name); - - if (items.length === 0) { - throw new WizardError("No options available for this selection.", { - rendered: false, - }); - } - - if (options.app && payload.apps && payload.apps.length > 0) { - const match = items.find( - (item) => item.toLowerCase() === options.app?.toLowerCase(), - ); - if (!match) { - const message = buildAppNotFoundMessage(options.app, apps, items); - ui.log.error(message); - throw new WizardError(message, { rendered: true }); - } - ui.log.info(`Using app: ${match}`); - return { selectedApp: match }; - } - - if (options.yes && items.length === 1) { - ui.log.info(`Auto-selected: ${items[0]}`); - return { selectedApp: items[0] }; - } - - if (options.yes && payload.apps && payload.apps.length > 0) { - const message = buildMultiAppMessage(apps, items); - ui.log.error(message); - throw new WizardError(message, { rendered: true }); - } - - const selected = await ui.select({ - message: payload.prompt, - filterable: apps.length > 0, - options: items.map((item) => { - const app = apps.find((a) => a.name === item); - return { - value: item, - label: item, - ...(app?.framework ? { hint: app.framework } : {}), - }; - }), - }); - - return { selectedApp: abortIfCancelled(selected) }; -} - -async function handleMultiSelect( - payload: MultiSelectPayload, - options: InteractiveContext, - ui: WizardUI, -): Promise> { - const available = prependRequiredFeature( - (payload.availableFeatures ?? payload.options ?? []).filter( - (feature) => !UNSUPPORTED_INIT_FEATURES.has(feature), - ), - ); - - if (options.yes) { - ui.log.info( - `Auto-selected all features: ${available.map(featureLabel).join(", ")}`, - ); - return { features: available }; - } - - const sorted = sortFeatureOptions(available); - setTag("wizard.features.offered", available.join(",")); - let initialValues: string[] = sorted.filter((feature) => - DEFAULT_FEATURES.has(feature), - ); - - while (true) { - const selected = await ui.multiselect({ - message: payload.prompt, - details: [{ text: FEATURE_SELECTION_CONTEXT }], - options: sorted.map((feature) => { - const description = featureDescription(feature); - return { - value: feature, - label: featureLabel(feature), - ...(description ? { description } : {}), - ...(feature === REQUIRED_FEATURE ? { locked: true } : {}), - }; - }), - initialValues, - required: false, - }); - - const chosen = abortIfCancelled(selected); - const features = normalizeFeatureSelection(prependRequiredFeature(chosen)); - const review = await ui.select({ - message: "Review your Sentry setup", - details: buildFeatureReviewDetails(features), - footer: { - text: "We'll modify project files for this Sentry setup.", - }, - options: [ - { value: "continue", label: "Continue" }, - { value: "back", label: "Back" }, - ], - initialValue: "continue", - }); - - if (abortIfCancelled(review) === "continue") { - setTag("wizard.features.selected", features.join(",")); - return { features }; - } - initialValues = features; - } -} - -async function handleConfirm( - payload: ConfirmPayload, - options: InteractiveContext, - ui: WizardUI, -): Promise> { - if (options.yes) { - ui.log.info("Auto-confirmed: continuing"); - return { action: "continue" }; - } - - const confirmed = await ui.confirm({ - message: payload.prompt, - initialValue: true, - }); - - const value = abortIfCancelled(confirmed); - return { action: value ? "continue" : "stop" }; -} diff --git a/packages/cli/src/lib/init/org-prefetch.ts b/packages/cli/src/lib/init/org-prefetch.ts deleted file mode 100644 index b0d8fe700..000000000 --- a/packages/cli/src/lib/init/org-prefetch.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Background Org Detection Prefetch - * - * Provides a warm/consume pattern for org resolution during `sentry init`. - * Call {@link warmOrgDetection} early (before the preamble) to start DSN - * scanning in the background. Later, call {@link resolveOrgPrefetched} — - * it returns the cached result instantly if the background work has - * finished, or falls back to a live call if it hasn't been warmed. - * - * `listOrganizations()` does NOT need prefetching because it has its own - * SQLite cache layer (PR #446). After `sentry login`, the org cache is - * pre-populated (PR #490), so subsequent calls return from cache instantly - * without any HTTP requests. Only `resolveOrg()` (DSN scanning) benefits - * from background prefetching since it performs filesystem I/O. - * - * This keeps init preflight free of explicit promise-threading — callers - * just swap in the prefetch-aware functions. - */ - -import type { ResolvedOrg } from "../resolve-target.js"; -import { resolveOrg } from "../resolve-target.js"; - -type OrgResult = ResolvedOrg | null; - -let orgPromise: Promise | undefined; -let warmedCwd: string | undefined; - -/** - * Kick off background DSN scanning + env var / config checks. - * - * Safe to call multiple times — subsequent calls are no-ops. - * Errors are silently swallowed so the foreground path can retry. - */ -export function warmOrgDetection(cwd: string): void { - if (!orgPromise) { - warmedCwd = cwd; - orgPromise = resolveOrg({ cwd }).catch(() => null); - } -} - -/** - * Resolve the org, using the prefetched result if available. - * - * Returns the cached background result only when `cwd` matches the - * directory that was passed to {@link warmOrgDetection}. If `cwd` - * differs (or warming was never called), falls back to a live - * `resolveOrg()` call so callers always get results for the correct - * working directory. - */ -export function resolveOrgPrefetched(cwd: string): Promise { - if (orgPromise && cwd === warmedCwd) { - return orgPromise; - } - return resolveOrg({ cwd }).catch(() => null); -} - -/** - * Reset prefetch state. Used by tests to prevent cross-test leakage. - */ -export function resetPrefetch(): void { - orgPromise = undefined; - warmedCwd = undefined; -} diff --git a/packages/cli/src/lib/init/preflight.ts b/packages/cli/src/lib/init/preflight.ts deleted file mode 100644 index 6b0b71ca5..000000000 --- a/packages/cli/src/lib/init/preflight.ts +++ /dev/null @@ -1,619 +0,0 @@ -import type { SentryTeam } from "../../types/index.js"; -import { - getOrganization, - listOrganizations, - listTeams, -} from "../api-client.js"; -import { getAuthToken } from "../db/auth.js"; -import { ApiError, AuthError, HostScopeError, WizardError } from "../errors.js"; -import { - buildOrgNotFoundError, - canCreateProjectInTeam, - pickDefaultTeam, - resolveOrCreateTeam, -} from "../resolve-team.js"; -import { captureOAuthScopeRecoveryGate } from "../scope-recovery.js"; -import { slugify } from "../utils.js"; -import { WizardCancelledError } from "./clack-utils.js"; -import { tryGetExistingProjectData } from "./existing-project.js"; -import { resolveOrgPrefetched } from "./org-prefetch.js"; -import { formatMemberProjectCreationDisabledError } from "./project-creation-errors.js"; -import type { - ExistingProjectData, - ResolvedInitContext, - WizardOptions, -} from "./types.js"; -import { isCancelled, type WizardUI } from "./ui/types.js"; - -const NUMERIC_ORG_ID_RE = /^\d+$/; - -type ExistingProjectChoice = { - project?: string; - existingProject?: ExistingProjectData; - shouldAbort?: boolean; -}; - -type InitContextSeed = { - org?: string; - project?: string; - existingProject?: ExistingProjectData; -}; - -type ProjectSelection = Pick< - ResolvedInitContext, - "project" | "existingProject" ->; - -/** - * Resolve org, project, team, and auth state before the init workflow starts. - */ -export async function resolveInitContext( - initial: WizardOptions, - ui: WizardUI, -): Promise { - return await withPreflightHandling(ui, async () => { - const seed = await resolveInitContextSeed(initial, ui); - if (!seed) { - return null; - } - - const org = await ensureOrg(seed.org, initial, ui); - const projectSelection = await resolveProjectSelection( - org, - initial, - seed, - ui, - ); - if (!projectSelection) { - return null; - } - - // Team only matters when init creates a project. - const team = projectSelection.existingProject - ? undefined - : await resolveTeam(org, initial, ui); - - return buildResolvedInitContext(initial, org, team, projectSelection); - }); -} - -async function withPreflightHandling( - ui: WizardUI, - action: () => Promise, -): Promise { - try { - return await action(); - } catch (error) { - if (error instanceof WizardCancelledError) { - ui.cancel("Setup cancelled."); - ui.feedback("cancelled"); - process.exitCode = 0; - return null; - } - - if ( - error instanceof AuthError || - error instanceof HostScopeError || - (error instanceof ApiError && - (error.status === 401 || error.status === 403)) - ) { - throw error; - } - - const message = error instanceof Error ? error.message : String(error); - ui.log.error(message); - ui.cancel("Setup failed."); - ui.feedback("failed"); - throw error instanceof WizardError ? error : new WizardError(message); - } -} - -function buildResolvedInitContext( - initial: WizardOptions, - org: string, - team: string | undefined, - selection: ProjectSelection, -): ResolvedInitContext { - return { - directory: initial.directory, - yes: initial.yes, - dryRun: initial.dryRun, - features: initial.features, - org, - team, - isExplicitTeam: Boolean(initial.team), - project: selection.project, - app: initial.app, - authToken: getAuthToken(), - existingProject: selection.existingProject, - }; -} - -async function resolveInitContextSeed( - initial: WizardOptions, - ui: WizardUI, -): Promise { - const detected = await resolveDetectedProject(initial, ui); - if (detected?.shouldAbort) { - return null; - } - - return { - org: detected?.org ?? initial.org, - project: detected?.project ?? initial.project, - existingProject: detected?.existingProject, - }; -} - -async function ensureOrg( - org: string | undefined, - initial: WizardOptions, - ui: WizardUI, -): Promise { - if (org) { - return org; - } - - const orgResult = await resolveOrgSlug(initial.directory, initial.yes, ui); - if (typeof orgResult === "string") { - return orgResult; - } - - throw new WizardError(orgResult.error ?? "Failed to resolve organization."); -} - -async function resolveProjectSelection( - org: string, - initial: WizardOptions, - seed: InitContextSeed, - ui: WizardUI, -): Promise { - if (!seed.project) { - return { - project: seed.project, - existingProject: seed.existingProject, - }; - } - - const resolved = await resolveExistingProjectChoice({ - org, - project: seed.project, - existingProject: seed.existingProject, - yes: initial.yes, - promptOnExisting: Boolean(initial.project && !initial.org), - ui, - }); - if (resolved.shouldAbort) { - return null; - } - - return mergeProjectSelection(seed, resolved); -} - -function mergeProjectSelection( - seed: InitContextSeed, - resolved: ExistingProjectChoice, -): ProjectSelection { - const project = "project" in resolved ? resolved.project : seed.project; - const clearedProject = - "project" in resolved && resolved.project === undefined; - - return { - project, - existingProject: clearedProject - ? undefined - : (resolved.existingProject ?? seed.existingProject), - }; -} - -async function resolveDetectedProject( - initial: WizardOptions, - ui: WizardUI, -): Promise<{ - org?: string; - project?: string; - existingProject?: ExistingProjectData; - shouldAbort?: boolean; -} | null> { - if (initial.org || initial.project) { - return null; - } - - let detectedProject: { orgSlug: string; projectSlug: string } | null = 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 { - detectedProject = await detectExistingProject(initial.directory); - } catch { - return null; - } - if (!detectedProject) { - return null; - } - - const existingProject = await tryGetExistingProjectData( - detectedProject.orgSlug, - detectedProject.projectSlug, - ).catch(() => null); - - if (initial.yes) { - return { - org: detectedProject.orgSlug, - project: detectedProject.projectSlug, - ...(existingProject ? { existingProject } : {}), - }; - } - - const choice = await ui.select<"existing" | "create">({ - message: "Found an existing Sentry project in this codebase.", - options: [ - { - value: "existing", - label: `Use existing project (${detectedProject.orgSlug}/${detectedProject.projectSlug})`, - hint: "Sentry is already configured here", - }, - { - value: "create", - label: "Create a new Sentry project", - }, - ], - }); - if (isCancelled(choice)) { - throw new WizardCancelledError(); - } - if (choice === "existing") { - return { - org: detectedProject.orgSlug, - project: detectedProject.projectSlug, - ...(existingProject ? { existingProject } : {}), - }; - } - - return {}; -} - -async function resolveExistingProjectChoice(opts: { - org: string; - project: string; - existingProject?: ExistingProjectData; - yes: boolean; - promptOnExisting: boolean; - ui: WizardUI; -}): Promise { - const slug = slugify(opts.project); - if (!slug) { - return { project: opts.project }; - } - - const existingProject = - opts.existingProject && - opts.existingProject.orgSlug === opts.org && - opts.existingProject.projectSlug === slug - ? opts.existingProject - : await tryGetExistingProjectData(opts.org, slug).catch(() => null); - if (!existingProject) { - return { project: opts.project }; - } - - if (!opts.promptOnExisting || opts.yes) { - return { - project: existingProject.projectSlug, - existingProject, - }; - } - - const choice = await opts.ui.select<"existing" | "create">({ - message: `Found existing project '${slug}' in ${opts.org}.`, - options: [ - { - value: "existing", - label: `Use existing (${opts.org}/${slug})`, - hint: "Already configured", - }, - { - value: "create", - label: "Create a new project", - hint: "Wizard will detect the project name from your codebase", - }, - ], - }); - if (isCancelled(choice)) { - throw new WizardCancelledError(); - } - if (choice === "create") { - return { project: undefined }; - } - - return { - project: existingProject.projectSlug, - existingProject, - }; -} - -/** - * Normalize a team-resolution failure into a WizardError, preserving an - * ApiError's enriched detail (e.g. 401 `member-disabled-over-limit`) via - * format() instead of collapsing to its bare message + status line. - */ -function toPreflightWizardError(error: unknown): WizardError { - if (error instanceof AuthError || error instanceof HostScopeError) { - throw error; - } - if (error instanceof WizardError) { - return error; - } - if (error instanceof ApiError) { - return new WizardError(error.format()); - } - return new WizardError( - error instanceof Error ? error.message : String(error), - ); -} - -async function resolveTeam( - org: string, - initial: WizardOptions, - ui: WizardUI, -): Promise { - if (!initial.team) { - return await resolveImplicitTeam(org, initial, ui); - } - - const scopeRecovery = captureOAuthScopeRecoveryGate(); - try { - const result = await resolveOrCreateTeam(org, { - team: initial.team, - usageHint: "sentry init", - dryRun: initial.dryRun, - deferAutoCreateOnEmptyOrg: true, - }); - return result.source === "deferred" ? undefined : result.slug; - } catch (error) { - if (error instanceof WizardCancelledError) { - throw error; - } - if ( - error instanceof ApiError && - (error.status === 401 || error.status === 403) && - (await scopeRecovery.shouldDelegate(error, { - unattended: initial.yes || initial.dryRun, - })) - ) { - throw error; - } - if (error instanceof ApiError && error.status === 403) { - return; - } - throw toPreflightWizardError(error); - } -} - -/** - * Whether the user's access scopes indicate they can create projects - * regardless of the org's `allowMemberProjectCreation` flag. - * - * Sentry's role hierarchy (from server.py SENTRY_ROLES): - * - member: project:read only — blocked when flag is disabled - * - admin: project:write, project:admin, team:admin — CAN create projects - * - manager: org:write, project:admin, is_global — CAN create projects - * - owner: org:write, org:admin, is_global — CAN create projects - * - * The previous check only looked for `org:write`, which excluded org admins - * who have `project:write` / `project:admin` but not `org:write`. - */ -function canBypassMemberCreationRestriction(access: unknown): boolean { - if (!Array.isArray(access)) { - return false; - } - return ( - access.includes("org:write") || - access.includes("project:admin") || - access.includes("project:write") - ); -} - -async function assertOrgScopedCreationCanProceed(org: string): Promise { - let organization: Awaited>; - // 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 { - organization = await getOrganization(org); - } catch { - // If org details cannot be fetched, let the actual create endpoint surface - // the precise API error during the project-creation step. - return; - } - - if ( - organization.allowMemberProjectCreation === false && - !canBypassMemberCreationRestriction(organization.access) - ) { - throw new WizardError(formatMemberProjectCreationDisabledError(org)); - } -} - -async function listTeamsForImplicitInit( - org: string, - unattended: boolean, -): Promise { - const scopeRecovery = captureOAuthScopeRecoveryGate(); - try { - return await listTeams(org); - } catch (error) { - // 403 from listTeams means the user cannot inspect team access. Continue - // without a team so init mirrors onboarding's org-scoped auto-team path. - if ( - error instanceof ApiError && - (error.status === 401 || error.status === 403) && - (await scopeRecovery.shouldDelegate(error, { unattended })) - ) { - throw error; - } - if (error instanceof ApiError && error.status === 403) { - await assertOrgScopedCreationCanProceed(org); - return; - } - if (error instanceof ApiError && error.status === 404) { - return await buildOrgNotFoundError(org, "sentry init"); - } - throw toPreflightWizardError(error); - } -} - -async function resolveImplicitTeam( - org: string, - initial: WizardOptions, - ui: WizardUI, -): Promise { - const teams = await listTeamsForImplicitInit( - org, - initial.yes || initial.dryRun, - ); - if (!teams) { - return; - } - - const candidateTeams = teams - .filter(canCreateProjectInTeam) - .sort((left, right) => left.slug.localeCompare(right.slug)); - if (candidateTeams.length === 0) { - await assertOrgScopedCreationCanProceed(org); - return; - } - const defaultTeam = pickDefaultTeam(candidateTeams) as SentryTeam; - if (candidateTeams.length === 1 || initial.yes) { - return defaultTeam.slug; - } - - const selected = await ui.select({ - message: "Which team should own this project?", - filterable: true, - initialValue: defaultTeam.slug, - options: candidateTeams.map((team) => ({ - value: team.slug, - label: team.slug, - ...(team.name !== team.slug ? { hint: team.name } : {}), - })), - }); - if (isCancelled(selected)) { - throw new WizardCancelledError(); - } - return selected; -} - -/** - * Format a 403/401 ApiError from listOrganizations() into a { ok: false } - * result, or re-throw if the error is something else. - * - * 403: token lacks org:read scope — user can bypass by supplying the org slug - * directly. 401: token is invalid/expired — supplying an org won't help, only - * re-authenticating will. - */ -function handleOrgListError(error: unknown): { ok: false; error: string } { - if (error instanceof ApiError && error.status === 403) { - const lines: string[] = ["Could not list organizations (403 Forbidden)."]; - if (error.detail) { - lines.push(error.detail, ""); - } - lines.push( - "Specify the org on the command line: sentry init /", - "Or set an environment variable: SENTRY_ORG= sentry init", - ); - return { ok: false, error: lines.join("\n ") }; - } - if (error instanceof ApiError && error.status === 401) { - const lines: string[] = [ - "Could not list organizations (401 Unauthorized).", - ]; - if (error.detail) { - lines.push(error.detail); - } - return { ok: false, error: lines.join("\n ") }; - } - throw error; -} - -async function resolveOrgSlug( - cwd: string, - yes: boolean, - ui: WizardUI, -): Promise { - const resolved = await resolveOrgPrefetched(cwd); - if (resolved && !NUMERIC_ORG_ID_RE.test(resolved.org)) { - return resolved.org; - } - - let orgs: Awaited>; - const scopeRecovery = captureOAuthScopeRecoveryGate(); - try { - orgs = await listOrganizations(); - } catch (error) { - if (await scopeRecovery.shouldDelegate(error, { unattended: yes })) { - throw error; - } - return handleOrgListError(error); - } - orgs.sort( - (left, right) => - left.name.localeCompare(right.name) || - left.slug.localeCompare(right.slug), - ); - if (orgs.length === 0) { - return { - ok: false, - error: "Not authenticated. Run 'sentry auth login' first.", - }; - } - if (orgs.length === 1 && orgs[0]) { - return orgs[0].slug; - } - - if (yes) { - const slugs = orgs.map((org) => org.slug).join(", "); - return { - ok: false, - error: [ - `Multiple organizations found (${slugs}).`, - "Specify one with: sentry init / [directory]", - " or set SENTRY_ORG=", - ].join("\n"), - }; - } - - const selected = await ui.select({ - message: "Which organization should the project be created in?", - filterable: true, - options: orgs.map((org) => ({ - value: org.slug, - label: org.name, - hint: org.slug, - })), - }); - if (isCancelled(selected)) { - throw new WizardCancelledError(); - } - return selected; -} - -async function detectExistingProject( - cwd: string, -): Promise<{ orgSlug: string; projectSlug: string } | null> { - const { detectDsn } = await import("../dsn/index.js"); - const dsn = await detectDsn(cwd); - if (!dsn?.publicKey) { - return 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 { resolveDsnByPublicKey } = await import("../resolve-target.js"); - const resolved = await resolveDsnByPublicKey(dsn); - if (!resolved) { - return null; - } - return { - orgSlug: resolved.org, - projectSlug: resolved.project, - }; - } catch { - return null; - } -} diff --git a/packages/cli/src/lib/init/project-creation-errors.ts b/packages/cli/src/lib/init/project-creation-errors.ts deleted file mode 100644 index d1b499c8e..000000000 --- a/packages/cli/src/lib/init/project-creation-errors.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function formatMemberProjectCreationDisabledError(org: string): string { - return ( - `Project creation is disabled for members in "${org}".\n` + - "Ask an org owner to either enable project creation for members\n" + - "or create the project for you. Once the project exists, run:\n" + - ` sentry init ${org}/` - ); -} diff --git a/packages/cli/src/lib/init/readiness.ts b/packages/cli/src/lib/init/readiness.ts deleted file mode 100644 index 0b9e2097f..000000000 --- a/packages/cli/src/lib/init/readiness.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Setup Service Readiness Check - * - * Checks setup-service availability before entering the remote workflow and - * warns when it may be slow or unreachable. Authentication is handled before - * the wizard UI is created. - */ - -import { customFetch } from "../custom-ca.js"; -import { logger } from "../logger.js"; -import { MASTRA_API_URL } from "./constants.js"; -import type { WizardUI } from "./ui/types.js"; - -/** Timeout for the health check fetch (5 seconds). */ -const HEALTH_CHECK_TIMEOUT_MS = 5000; - -/** - * Check whether the setup service is reachable before starting the workflow. - * Authentication is resolved before the wizard UI is created so recoverable - * OAuth failures can use the CLI's global auto-auth flow. - */ -export async function checkReadiness(ui: WizardUI): Promise { - const spin = ui.spinner(); - spin.start("Checking prerequisites..."); - - const apiOk = await checkMastraApi(); - - if (apiOk) { - spin.stop(""); - } else { - spin.stop("Warning", 2); - ui.log.warn( - "Setup service may be slow or unreachable. The wizard will retry if needed.", - ); - } -} - -async function checkMastraApi(): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); - try { - const resp = await customFetch(`${MASTRA_API_URL}/health`, { - signal: controller.signal, - method: "GET", - }); - return resp.ok; - } catch (error) { - logger.withTag("readiness").debug("Mastra API health check failed", error); - return false; - } finally { - clearTimeout(timer); - } -} diff --git a/packages/cli/src/lib/init/spinner.ts b/packages/cli/src/lib/init/spinner.ts deleted file mode 100644 index 424b97634..000000000 --- a/packages/cli/src/lib/init/spinner.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { clearScreenDown, cursorTo, moveCursor } from "node:readline"; -import stringWidth from "string-width"; -import { - colorTag, - renderInlineMarkdown, - stripColorTags, -} from "../formatters/markdown.js"; - -const HIDE_CURSOR = "\u001B[?25l"; -const SHOW_CURSOR = "\u001B[?25h"; -const SPINNER_FRAMES = process.platform.startsWith("win") - ? ["●", "o", "O", "0"] - : ["◒", "◐", "◓", "◑"]; -const SPINNER_INTERVAL_MS = process.platform.startsWith("win") ? 80 : 120; - -export type WizardSpinner = { - start: (msg?: string) => void; - stop: (msg?: string, code?: number) => void; - message: (msg?: string) => void; -}; - -type SpinnerOutput = NodeJS.WriteStream & { - columns?: number; -}; - -/** - * Create the init wizard spinner with support for repainting multiline status - * blocks. Clack's public spinner API works well for one-line updates, but the - * file tree view needs to redraw an entire block in place as progress changes. - */ -export function createWizardSpinner( - output: SpinnerOutput = process.stdout, -): WizardSpinner { - let running = false; - let frameIndex = 0; - let renderedRows = 0; - let message = ""; - let timer: ReturnType | undefined; - - function stripForWidth(value: string): string { - return stripColorTags(value).replace(/`/g, ""); - } - - function countRenderedRows(markdown: string): number { - const width = Math.max(1, output.columns || 80); - return markdown.split("\n").reduce((rows, line) => { - const visibleWidth = stringWidth(stripForWidth(line)); - return rows + Math.max(1, Math.ceil(visibleWidth / width)); - }, 0); - } - - function clearRenderedBlock(): void { - if (!output.isTTY || renderedRows === 0) { - renderedRows = 0; - return; - } - cursorTo(output, 0); - if (renderedRows > 1) { - moveCursor(output, 0, -(renderedRows - 1)); - } - clearScreenDown(output); - renderedRows = 0; - } - - function formatSpinnerBlock(frame: string, nextMessage: string): string { - const lines = nextMessage.split("\n"); - const [firstLine = "", ...rest] = lines; - return [ - `${colorTag("magenta", frame)} ${firstLine}`, - ...rest.map((line) => `${colorTag("muted", "│")} ${line}`), - ].join("\n"); - } - - function formatStoppedBlock(nextMessage: string, code: number): string { - let icon: string; - if (code === 0) { - icon = colorTag("green", "◆"); - } else if (code === 1) { - icon = colorTag("red", "■"); - } else { - icon = colorTag("yellow", "▲"); - } - const lines = nextMessage.split("\n"); - const [firstLine = "", ...rest] = lines; - return [ - `${icon} ${firstLine}`, - ...rest.map((line) => `${colorTag("muted", "│")} ${line}`), - ].join("\n"); - } - - function renderCurrentFrame(): void { - if (!running) { - return; - } - const frame = SPINNER_FRAMES[frameIndex] ?? SPINNER_FRAMES[0] ?? "•"; - const markdown = formatSpinnerBlock(frame, message); - clearRenderedBlock(); - output.write(renderInlineMarkdown(markdown)); - renderedRows = countRenderedRows(markdown); - } - - function start(nextMessage = ""): void { - if (running) { - message = nextMessage; - renderCurrentFrame(); - return; - } - running = true; - frameIndex = 0; - message = nextMessage; - if (output.isTTY) { - output.write(HIDE_CURSOR); - } - renderCurrentFrame(); - if (output.isTTY) { - timer = setInterval(() => { - frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length; - renderCurrentFrame(); - }, SPINNER_INTERVAL_MS); - } - } - - function stop(nextMessage?: string, code = 0): void { - if (timer) { - clearInterval(timer); - timer = undefined; - } - if (!running) { - return; - } - running = false; - if (nextMessage !== undefined) { - message = nextMessage; - } - clearRenderedBlock(); - if (message) { - output.write( - `${renderInlineMarkdown(formatStoppedBlock(message, code))}\n`, - ); - } - if (output.isTTY) { - output.write(SHOW_CURSOR); - } - } - - function updateMessage(nextMessage = ""): void { - message = nextMessage; - if (running) { - renderCurrentFrame(); - } - } - - return { - start, - stop, - message: updateMessage, - }; -} diff --git a/packages/cli/src/lib/init/stdin-reopen.ts b/packages/cli/src/lib/init/stdin-reopen.ts deleted file mode 100644 index ada70899d..000000000 --- a/packages/cli/src/lib/init/stdin-reopen.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * Workaround for a Bun single-file-binary issue where TTY fds inherited via - * shell redirection (e.g. `curl | bash` → `exec "$sentry_bin" init NodeJS.ReadStream; - pause: () => NodeJS.ReadStream; - resume: () => NodeJS.ReadStream; - _read: (size: number) => void; -}; - -/** - * State captured when the init wizard installs fresh `/dev/tty` forwarding. - * Stored so teardown can release the temporary TTY handle and restore - * `process.stdin` to its original behavior. - */ -type InstalledState = { - fresh: ReadStream; - dataListener: (chunk: Buffer) => void; - original: { - setRawMode: StdinHandle["setRawMode"]; - pause: StdinHandle["pause"]; - resume: StdinHandle["resume"]; - read: StdinHandle["_read"]; - }; - /** - * Value of `process.stdin.isTTY` before we touched it. Teardown restores - * exactly this value rather than hardcoding `undefined`, so a concurrent - * writer (e.g. another library that also backfills isTTY) doesn't get - * silently stomped on. - */ - previousIsTty: boolean | undefined; - /** True when we wrote to `process.stdin.isTTY` at install time. */ - backfilledIsTty: boolean; -}; - -let installedState: InstalledState | null = null; - -/** - * Factory that returns a `/dev/tty` file descriptor. Overridable for tests - * so we can exercise the install→teardown state transitions without depending - * on the host's actual TTY. - */ -export type OpenTtyFactory = () => number; - -/** - * Predicate that reports whether fd 0 is a TTY. Overridable for tests - * because `isatty(0)` reads real kernel state we can't mock easily — and - * `bun test` runs with piped stdin where the predicate is always false. - */ -export type IsTtyPredicate = () => boolean; - -/** Bundle of host primitives that tests can override. */ -export type TtyDeps = { - openTty?: OpenTtyFactory; - isTty?: IsTtyPredicate; -}; - -const defaultOpenTty: OpenTtyFactory = () => openSync("/dev/tty", "r"); -const defaultIsTty: IsTtyPredicate = () => isatty(0); - -/** - * Disposable returned by {@link forwardFreshTtyToStdin}. Calling - * `[Symbol.dispose]()` — or equivalently letting a `using` declaration go - * out of scope — releases the temporary TTY handle and restores - * `process.stdin`. Always returned (never null) so callers don't need to - * null-check inside `using` blocks. - */ -export type TtyForwardingHandle = Disposable; - -/** Shared no-op disposable for the secondary-caller / already-installed case. */ -const NOOP_HANDLE: TtyForwardingHandle = { - [Symbol.dispose]: (): void => { - // intentionally empty — primary caller owns teardown - }, -}; - -/** - * Build a handle that routes disposal through - * {@link closeFreshTtyForwarding}. Using the module-level function (rather - * than a captured reference) preserves test observability — tests can spy - * on `closeFreshTtyForwarding` and see it fire even on branches that didn't - * install forwarding, matching the semantics of the pre-`using` - * try/finally pattern. The underlying function is a no-op when - * `installedState` is null, so extra calls are safe. - */ -function makeHandle(): TtyForwardingHandle { - return { - [Symbol.dispose]: (): void => { - closeFreshTtyForwarding(); - }, - }; -} - -/** - * Open a fresh `/dev/tty` fd and wire it up to feed `process.stdin`'s event - * listeners. - * - * Always returns a {@link TtyForwardingHandle} (a `Disposable`) so callers - * can use `using tty = forwardFreshTtyToStdin()` to guarantee teardown on - * every exit path without null-checking. When no TTY is available or - * `/dev/tty` can't be opened the disposable is a no-op by virtue of - * `closeFreshTtyForwarding` short-circuiting on un-installed state — the - * wizard still runs; non-interactive (`--yes`, piped stdin) flows stay as-is. - * - * Idempotent: repeated calls after the first successful install return a - * pure no-op `Disposable` (the first caller owns teardown). Secondary - * callers don't duplicate the data listener (which would cause clack to - * receive each keystroke twice) or leak additional `/dev/tty` fds. - * - * @param deps - Optional dependency injection for tests. `openTty` overrides - * the `/dev/tty` factory; `isTty` overrides the `isatty(0)` predicate. - * Production callers pass no args — the defaults do the right thing. - */ -export function forwardFreshTtyToStdin( - deps: TtyDeps = {}, -): TtyForwardingHandle { - const { openTty = defaultOpenTty, isTty = defaultIsTty } = deps; - - if (installedState) { - // Another caller already installed forwarding and owns teardown. Hand - // back a pure no-op so disposing the secondary handle does NOT call - // `closeFreshTtyForwarding` — which would tear down the primary's - // install before the primary's disposable fires. - return NOOP_HANDLE; - } - if (!isTty()) { - return makeHandle(); - } - - let fd: number; - // 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 { - fd = openTty(); - } catch { - return makeHandle(); - } - - const fresh = new ReadStream(fd); - const stdinHandle = process.stdin as unknown as StdinHandle; - const original = { - setRawMode: stdinHandle.setRawMode, - pause: stdinHandle.pause, - resume: stdinHandle.resume, - read: stdinHandle._read, - }; - - // Capture the current `isTTY` value before touching it so teardown can - // restore it verbatim. Bun's compiled binary can leave - // `process.stdin.isTTY === undefined` on inherited-via-redirect fds even - // when `isatty(0)` is true. Clack gates its internal `setRawMode(true)` - // call on `input.isTTY`, so without this backfill the patched setRawMode - // below is never invoked and the fresh fd stays in canonical mode - // (line-buffered, no keypresses). - // - // Use `Object.defineProperty` rather than plain assignment because on - // some Node/Bun runtimes `process.stdin.isTTY` is defined as a - // non-writable property (notably when stdin is not a TTY) — bare - // `stdin.isTTY = …` throws a TypeError in strict mode in that case. - const previousIsTty = process.stdin.isTTY; - let backfilledIsTty = false; - if (process.stdin.isTTY === undefined) { - Object.defineProperty(process.stdin, "isTTY", { - value: true, - writable: true, - configurable: true, - }); - backfilledIsTty = true; - } - - // Forward keystrokes from the working fd onto process.stdin so any - // listeners clack attaches (readline's 'data', emitKeypressEvents' - // 'keypress') fire as expected. - const dataListener = (chunk: Buffer): void => { - process.stdin.emit("data", chunk); - }; - fresh.on("data", dataListener); - - // A ReadStream without an `error` listener crashes the process when it - // emits (e.g. terminal disconnected, SSH dropped). The wizard can't - // recover from a dead TTY, so silently drop — the next operation that - // actually needs input will fail with a more meaningful error. The - // listener stays attached across teardown intentionally: Bun can - // asynchronously emit `'error'` (EBADF) after `destroy()` closes the - // underlying fd, and an unhandled error on the stream crashes the - // process. Keeping the listener attached absorbs any late emission. - fresh.on("error", (): void => { - // intentionally empty - }); - - // setRawMode issues a TCSETS ioctl on the underlying TTY device. The device - // is shared between the broken fd 0 and the fresh fd, but the broken fd's - // ioctl path may be the root cause — so route raw-mode toggles through the - // fresh fd, which we know works. - stdinHandle.setRawMode = (mode: boolean): NodeJS.ReadStream => { - fresh.setRawMode(mode); - return process.stdin; - }; - - // Prevent the stream machinery from touching the broken fd. Clack closes - // each prompt with `input.unpipe()` and opens the next one by attaching - // new listeners — that triggers Readable's `pause()`/`resume()` hooks, - // which on Bun call kqueue against fd 0 and fail with EINVAL on the - // second transition. We deliver bytes via `emit('data', …)` from the - // fresh fd, so the fd-level flow machinery is dead weight here. - const noop = (): NodeJS.ReadStream => process.stdin; - stdinHandle.pause = noop; - stdinHandle.resume = noop; - stdinHandle._read = (_size: number): void => { - // intentionally empty — see comment above - }; - - // Put the fresh stream into flowing mode so the OS delivers bytes to it and - // our 'data' handler fires. - fresh.resume(); - - installedState = { - fresh, - dataListener, - original, - previousIsTty, - backfilledIsTty, - }; - - return makeHandle(); -} - -/** - * Tear down the fresh `/dev/tty` forwarding installed by - * {@link forwardFreshTtyToStdin}. - * - * Must be safe on every wizard exit path, including when forwarding was never - * installed. Destroying the temporary `ReadStream` releases the TTY handle so - * the process can exit naturally once the wizard is done. - * - * Callers who opt into the {@link TtyForwardingHandle} `Disposable` API (via - * `using tty = forwardFreshTtyToStdin()`) get this teardown for free — this - * function exists for the imperative API and for explicit cleanup in tests. - */ -export function closeFreshTtyForwarding(): void { - if (!installedState) { - return; - } - - const { fresh, dataListener, original, previousIsTty, backfilledIsTty } = - installedState; - installedState = null; - - fresh.off("data", dataListener); - - // Restore termios before destroying the fresh stream. If the wizard threw - // mid-prompt (between clack's `setRawMode(true)` and its matching - // `setRawMode(false)`), the TTY is still in raw mode — leaving it there - // produces a shell with no echo after a crash. Best-effort: the fresh fd - // may already be destroyed from a prior error, so swallow any throw. - // 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 { - fresh.setRawMode(false); - } catch { - // intentionally empty — stream already torn down - } - - // Pause before destroy so no queued read callback tries to deliver bytes - // after the stream has been torn down. The error listener installed at - // setup time stays attached across destroy — see the comment at the - // install site for why. - fresh.pause(); - fresh.destroy(); - - const stdinHandle = process.stdin as unknown as StdinHandle; - stdinHandle.setRawMode = original.setRawMode; - stdinHandle.pause = original.pause; - stdinHandle.resume = original.resume; - stdinHandle._read = original.read; - - // Release the libuv handle on fd 0. Clack's prompt lifecycle relies on - // `rl.close() → rl.pause() → this.input.pause()` to pause stdin, but we - // replaced `process.stdin.pause` with a no-op at install time (needed to - // dodge Bun's fd-0 EINVAL on pause/resume transitions — see the comment - // at the install site). So by the time we get here, stdin is still in - // flowing/ref'd mode from `readline.createInterface()`'s internal - // `input.resume()` — which keeps the libuv event loop alive indefinitely - // after the wizard returns, manifesting as a post-wizard hang until the - // user presses a key. Now that the original `.pause()` is restored, - // invoke it directly so stock Node/Bun cleanup can finish. Idempotent: - // safe when stdin was already paused. - // 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 { - original.pause.call(process.stdin); - } catch { - // Defensive: swallow errors from runtimes that throw if stdin is - // already destroyed. This is end-of-lifecycle cleanup; nothing - // downstream needs stdin. - } - - if (backfilledIsTty) { - Object.defineProperty(process.stdin, "isTTY", { - value: previousIsTty, - writable: true, - configurable: true, - }); - } -} diff --git a/packages/cli/src/lib/init/tools/agent-checkpoint.ts b/packages/cli/src/lib/init/tools/agent-checkpoint.ts deleted file mode 100644 index b97ddca4b..000000000 --- a/packages/cli/src/lib/init/tools/agent-checkpoint.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * No-op init tool for acknowledging durable server-side agent checkpoints. - */ - -import type { AgentCheckpointData, ToolResult } from "../types.js"; -import type { InitToolDefinition } from "./types.js"; - -/** Resume a persisted agent session without performing local work. */ -export const agentCheckpointTool: InitToolDefinition<"agent-checkpoint"> = { - operation: "agent-checkpoint", - describe: (payload) => payload.detail ?? "Continuing repository analysis...", - execute: async (): Promise => ({ - data: { acknowledged: true } satisfies AgentCheckpointData, - ok: true, - }), -}; diff --git a/packages/cli/src/lib/init/tools/apply-patchset.ts b/packages/cli/src/lib/init/tools/apply-patchset.ts deleted file mode 100644 index bd9271120..000000000 --- a/packages/cli/src/lib/init/tools/apply-patchset.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Compatibility adapter for the deployed `apply-patchset` wire operation. - * All validation and filesystem policy belong to the file-change engine. - */ - -import type { ToolResult } from "../types.js"; -import { applyPreparedFileChanges } from "./file-changes/apply.js"; -import { parseFileChangesRequest } from "./file-changes/contract.js"; -import { prepareFileChanges } from "./file-changes/prepare.js"; -import type { FileChangeFailure } from "./file-changes/result.js"; -import type { FileChange } from "./file-changes/types.js"; -import type { InitToolDefinition, ToolContext } from "./types.js"; - -const PATH_SEGMENT_RE = /[/\\]/u; - -function failedPreparation(failure: FileChangeFailure): ToolResult { - return { - data: { applied: [], failed: failure }, - error: failure.message, - ok: false, - }; -} - -/** - * Adapt the legacy `apply-patchset` wire request to the local file-change - * engine. The engine always validates and prepares the entire batch before it - * performs the first write. - */ -export async function applyPatchset( - input: unknown, - context: Pick, -): Promise { - const parsed = parseFileChangesRequest(input); - if (!parsed.ok) { - return { error: parsed.error, ok: false }; - } - const prepared = await prepareFileChanges( - parsed.cwd, - parsed.changes, - context.authToken, - ); - if (!prepared.ok) { - return failedPreparation(prepared.failure); - } - return await applyPreparedFileChanges(prepared.changes, context.dryRun); -} - -/** - * Tool definition for file patch application. - */ -export const applyPatchsetTool: InitToolDefinition<"apply-patchset"> = { - operation: "apply-patchset", - describe: (payload) => { - const parsed = parseFileChangesRequest(payload); - if (!parsed.ok) { - return "Applying file changes..."; - } - const [first] = parsed.changes; - if (parsed.changes.length === 1 && first) { - const verb = patchActionVerb(first.action); - const fileName = first.path.split(PATH_SEGMENT_RE).at(-1) ?? first.path; - return `${verb} \`${fileName}\`...`; - } - return `Applying ${parsed.changes.length} file changes...`; - }, - execute: applyPatchset, -}; - -function patchActionVerb(action: FileChange["action"]): string { - switch (action) { - case "create": - return "Creating"; - case "modify": - return "Modifying"; - case "delete": - return "Deleting"; - default: - return "Updating"; - } -} diff --git a/packages/cli/src/lib/init/tools/command-utils.ts b/packages/cli/src/lib/init/tools/command-utils.ts deleted file mode 100644 index acd40a902..000000000 --- a/packages/cli/src/lib/init/tools/command-utils.ts +++ /dev/null @@ -1,444 +0,0 @@ -import path from "node:path"; -import type { ReadableStream } from "node:stream/web"; -import { MAX_OUTPUT_BYTES } from "../constants.js"; - -/** Characters treated as command token separators. */ -const WHITESPACE_CHAR_RE = /\s/u; -const WINDOWS_EXECUTABLE_EXTENSION_RE = /\.(?:cmd|exe|bat|ps1)$/u; -const PATH_SEPARATOR_RE = /\\/g; - -/** - * Patterns that indicate shell injection. Windows package-manager shims require - * shell execution, so workflow commands must reject shell syntax before spawn. - */ -const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = - [ - { pattern: ";", label: "command chaining (;)" }, - { pattern: "&&", label: "command chaining (&&)" }, - { pattern: "||", label: "command chaining (||)" }, - { pattern: "|", label: "piping (|)" }, - { pattern: "&", label: "background execution (&)" }, - { pattern: "`", label: "command substitution (`)" }, - { pattern: "$(", label: "command substitution ($()" }, - { pattern: "\n", label: "newline" }, - { pattern: "\r", label: "carriage return" }, - { pattern: ">", label: "redirection (>)" }, - { pattern: "<", label: "redirection (<)" }, - ]; - -const WINDOWS_SHELL_METACHARACTER_PATTERNS: Array<{ - pattern: string; - label: string; -}> = [ - { pattern: "%", label: "Windows environment variable expansion (%)" }, - { pattern: "!", label: "Windows delayed environment expansion (!)" }, -]; - -/** - * Executables that should never appear in a workflow-provided command. - */ -const BLOCKED_EXECUTABLES = new Set([ - "rm", - "rmdir", - "del", - "curl", - "wget", - "nc", - "ncat", - "netcat", - "socat", - "telnet", - "ftp", - "sudo", - "su", - "doas", - "chmod", - "chown", - "chgrp", - "kill", - "killall", - "pkill", - "shutdown", - "reboot", - "halt", - "poweroff", - "dd", - "mkfs", - "fdisk", - "mount", - "umount", - "ssh", - "scp", - "sftp", - "cd", - "pushd", - "popd", - "cmd", - "powershell", - "pwsh", - "bash", - "sh", - "zsh", - "fish", - "csh", - "dash", - "eval", - "exec", - "env", - "xargs", -]); - -type CommandQuote = '"' | "'"; - -type TokenizeState = { - tokens: string[]; - current: string; - tokenStarted: boolean; - quote?: CommandQuote; -}; - -type SpawnOutputStream = - | NodeJS.ReadableStream - | ReadableStream - | null - | undefined; - -export type ParsedCommand = { - original: string; - executable: string; - args: string[]; -}; - -function normalizeExecutableName(executable: string): string { - return path.posix - .basename(executable.replace(PATH_SEPARATOR_RE, "/")) - .toLowerCase() - .replace(WINDOWS_EXECUTABLE_EXTENSION_RE, ""); -} - -function hasInitArgAfter(tokens: string[], index: number): boolean { - return tokens.slice(index + 1).some((arg) => arg.toLowerCase() === "init"); -} - -function isSentryCliPackageSpec(token: string): boolean { - const lower = token.toLowerCase(); - return lower === "@sentry/cli" || lower.startsWith("@sentry/cli@"); -} - -function isSentryWizardPackageSpec(token: string): boolean { - const lower = token.toLowerCase(); - return lower === "@sentry/wizard" || lower.startsWith("@sentry/wizard@"); -} - -function isExecutablePackageSpec(executable: string, name: string): boolean { - return executable === name || executable.startsWith(`${name}@`); -} - -function findBlockedExecutable(tokens: string[]): string | undefined { - const executable = normalizeExecutableName(tokens[0] ?? ""); - if (BLOCKED_EXECUTABLES.has(executable)) { - return executable; - } - - return; -} - -function isRecursiveSentrySetupToken( - token: string, - tokens: string[], - index: number, -): boolean { - const executable = normalizeExecutableName(token); - if ( - isSentryWizardPackageSpec(token) || - isExecutablePackageSpec(executable, "sentry-wizard") - ) { - return true; - } - if (isSentryCliPackageSpec(token)) { - return hasInitArgAfter(tokens, index); - } - if ( - !( - isExecutablePackageSpec(executable, "sentry") || - isExecutablePackageSpec(executable, "sentry-cli") - ) - ) { - return false; - } - return hasInitArgAfter(tokens, index); -} - -function isRecursiveSentrySetup(tokens: string[]): boolean { - const [firstToken = ""] = tokens; - return isRecursiveSentrySetupToken(firstToken, tokens, 0); -} - -function findShellMetacharacterLabel(command: string): string | undefined { - const patterns = - process.platform === "win32" - ? [ - ...SHELL_METACHARACTER_PATTERNS, - ...WINDOWS_SHELL_METACHARACTER_PATTERNS, - ] - : SHELL_METACHARACTER_PATTERNS; - - return patterns.find(({ pattern }) => command.includes(pattern))?.label; -} - -function isCommandWhitespace(char: string): boolean { - return WHITESPACE_CHAR_RE.test(char); -} - -function pushCurrentToken(state: TokenizeState): void { - if (!state.tokenStarted) { - return; - } - - state.tokens.push(state.current); - state.current = ""; - state.tokenStarted = false; -} - -function appendEscapedUnquotedChar( - state: TokenizeState, - command: string, - index: number, -): number | undefined { - const next = command[index + 1]; - if ( - next && - (isCommandWhitespace(next) || next === "'" || next === '"' || next === "\\") - ) { - state.current += next; - state.tokenStarted = true; - return index + 1; - } - - return; -} - -function handleUnquotedChar( - state: TokenizeState, - command: string, - index: number, -): number { - const char = command[index]; - if (!char) { - return index; - } - - if (isCommandWhitespace(char)) { - pushCurrentToken(state); - return index; - } - - if (char === "'" || char === '"') { - state.quote = char; - state.tokenStarted = true; - return index; - } - - if (char === "\\") { - const escapedIndex = appendEscapedUnquotedChar(state, command, index); - if (escapedIndex !== undefined) { - return escapedIndex; - } - } - - state.current += char; - state.tokenStarted = true; - return index; -} - -function handleSingleQuotedChar(state: TokenizeState, char: string): void { - if (char === "'") { - state.quote = undefined; - return; - } - - state.current += char; -} - -function handleDoubleQuotedChar( - state: TokenizeState, - command: string, - index: number, -): number { - const char = command[index]; - if (!char) { - return index; - } - - if (char === '"') { - state.quote = undefined; - return index; - } - - if (char === "\\") { - const next = command[index + 1]; - if (next && (next === '"' || next === "\\" || next === "$")) { - state.current += next; - return index + 1; - } - } - - state.current += char; - return index; -} - -/** - * Tokenize a command string into an argv-compatible array without invoking a shell. - */ -export function tokenizeCommand(command: string): string[] { - const state: TokenizeState = { - tokens: [], - current: "", - tokenStarted: false, - }; - - for (let i = 0; i < command.length; i += 1) { - const char = command[i]; - if (!char) { - continue; - } - - if (!state.quote) { - i = handleUnquotedChar(state, command, i); - continue; - } - - if (state.quote === "'") { - handleSingleQuotedChar(state, char); - continue; - } - - i = handleDoubleQuotedChar(state, command, i); - } - - if (state.quote) { - throw new Error( - `Invalid command: unterminated ${state.quote === '"' ? "double" : "single"} quote — "${command}"`, - ); - } - - pushCurrentToken(state); - return state.tokens; -} - -/** - * Parse a command string into an executable plus argv-style arguments. - */ -export function parseCommand(command: string): ParsedCommand { - const [executable = "", ...args] = tokenizeCommand(command); - return { - original: command, - executable, - args, - }; -} - -/** - * Validate a command before execution. - */ -export function validateCommand(command: string): string | undefined { - const metacharacterLabel = findShellMetacharacterLabel(command); - if (metacharacterLabel) { - return `Blocked command: contains ${metacharacterLabel} — "${command}"`; - } - - let tokens: string[]; - try { - tokens = tokenizeCommand(command); - } catch (error) { - return error instanceof Error ? error.message : String(error); - } - - const [firstToken = ""] = tokens; - if (!firstToken) { - return "Blocked command: empty command"; - } - - if (firstToken.includes("=")) { - return `Blocked command: contains environment variable assignment — "${command}"`; - } - - if (isRecursiveSentrySetup(tokens)) { - return `Blocked command: invokes Sentry setup recursively — "${command}"`; - } - - const executable = findBlockedExecutable(tokens); - if (executable) { - return `Blocked command: disallowed executable "${executable}" — "${command}"`; - } - - return; -} - -async function readWebStream( - stream: ReadableStream, -): Promise { - const reader = stream.getReader(); - const chunks: Buffer[] = []; - let totalBytes = 0; - - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (!value || totalBytes >= MAX_OUTPUT_BYTES) { - continue; - } - - const buffer = Buffer.from(value); - const remaining = MAX_OUTPUT_BYTES - totalBytes; - chunks.push(buffer.subarray(0, remaining)); - totalBytes += Math.min(buffer.length, remaining); - } - } finally { - reader.releaseLock(); - } - - return Buffer.concat(chunks).toString("utf-8"); -} - -async function readNodeStream(stream: NodeJS.ReadableStream): Promise { - return await new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - let totalBytes = 0; - - stream.on("data", (chunk: Buffer | string) => { - if (totalBytes >= MAX_OUTPUT_BYTES) { - return; - } - - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - const remaining = MAX_OUTPUT_BYTES - totalBytes; - chunks.push(buffer.subarray(0, remaining)); - totalBytes += Math.min(buffer.length, remaining); - }); - stream.on("end", () => { - resolve(Buffer.concat(chunks).toString("utf-8")); - }); - stream.on("error", reject); - }); -} - -/** - * Drain a spawned stdout/stderr stream while enforcing output truncation. - */ -export async function readSpawnOutput( - stream: SpawnOutputStream, -): Promise { - if (!stream) { - return ""; - } - - if (typeof (stream as ReadableStream).getReader === "function") { - return await readWebStream(stream as ReadableStream); - } - - return await readNodeStream(stream as NodeJS.ReadableStream); -} diff --git a/packages/cli/src/lib/init/tools/create-sentry-project.ts b/packages/cli/src/lib/init/tools/create-sentry-project.ts deleted file mode 100644 index 37680bae6..000000000 --- a/packages/cli/src/lib/init/tools/create-sentry-project.ts +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Sentry project creation tool for the init wizard. - * - * Implements the `create-sentry-project` and `ensure-sentry-project` wizard - * operations. Uses the team-scoped endpoint for explicit or Team Admin teams; - * otherwise uses POST /organizations/{org}/projects/, the onboarding endpoint - * that auto-creates a personal team for eligible members. - */ - -import { - createProjectWithAutoTeam, - createProjectWithDsn, - MEMBER_PROJECT_CREATION_DISABLED_DETAIL, -} from "../../api-client.js"; -import { ApiError } from "../../errors.js"; -import { resolveOrCreateTeam } from "../../resolve-team.js"; -import { captureOAuthScopeRecoveryGate } from "../../scope-recovery.js"; -import { slugify } from "../../utils.js"; -import { tryGetExistingProjectData } from "../existing-project.js"; -import { formatMemberProjectCreationDisabledError } from "../project-creation-errors.js"; -import type { - CreateSentryProjectPayload, - EnsureSentryProjectPayload, - ToolResult, -} from "../types.js"; -import { formatToolError } from "./shared.js"; -import type { InitToolDefinition, ToolContext } from "./types.js"; -import { reportCliError } from "../../error-reporting.js"; - -type ProjectData = { - projectSlug: string; - projectId: string; - dsn: string; - url: string; -}; - -type ProjectCreationResponse = { - project: { - id: string; - slug: string; - }; - dsn?: string | null; - url: string; -}; - -function toProjectData(response: ProjectCreationResponse): ProjectData { - return { - projectSlug: response.project.slug, - projectId: response.project.id, - dsn: response.dsn ?? "", - url: response.url, - }; -} - -/** - * Resolve project creation using the frontend onboarding policy. - * - * @param opts.org - Organization slug - * @param opts.name - Project display name - * @param opts.platform - Platform identifier (null/undefined → omitted from request) - * @param opts.team - Pre-resolved team slug (explicit or auto-selected by preflight). - * When undefined, use the org-scoped onboarding endpoint directly. - * @param opts.suppressFallback - When true, a 403 from the team-scoped flow is - * surfaced directly rather than triggering the org-scoped fallback. Set only - * when the team was explicitly named via `--team` — a 403 there is meaningful - * user feedback, not a permission gap. - * @returns Resolved project identifiers and DSN - */ -async function resolveProjectCreation(opts: { - org: string; - name: string; - platform: string | null | undefined; - team: string | undefined; - suppressFallback: boolean; -}): Promise { - const { org, name, team, suppressFallback } = opts; - // Coerce null → undefined: CreateProjectBody.platform is string | undefined. - const platform = opts.platform ?? undefined; - - const withPlatformFallback = async ( - fn: (p: string | undefined) => Promise, - ): Promise => { - try { - return await fn(platform); - } catch (err) { - // The registry may include SDK keys whose derived platform slug (e.g. - // "javascript-hono") is not yet in the Sentry API's allowed platform - // list. Retry without a platform so the project is still created, and - // capture to track which slugs need to be added to the API allowlist. - if ( - err instanceof ApiError && - err.status === 400 && - platform && - err.detail?.includes("Invalid platform") - ) { - reportCliError(err, { - extra: { - attemptedPlatform: platform, - projectName: name, - apiResponseDetail: err.detail, - apiStatus: err.status, - }, - }); - return await fn(undefined); - } - throw err; - } - }; - - if (!team) { - return await withPlatformFallback(async (p) => { - const result = await createProjectWithAutoTeam(org, { - name, - platform: p, - }); - return toProjectData(result); - }); - } - - try { - return await withPlatformFallback(async (p) => { - const result = await createProjectWithDsn(org, team, { - name, - platform: p, - }); - return toProjectData(result); - }); - } catch (innerError) { - // Fall back to org-scoped endpoint on 403, unless the fallback is suppressed - // (explicit --team means the 403 is meaningful feedback, not a permission gap). - // Note: a 403 can originate from either the initial createProjectWithDsn call - // or from the platform-less retry inside withPlatformFallback — both mean the - // caller lacks team:write, so the org-scoped fallback is correct in either case. - if ( - !(innerError instanceof ApiError && innerError.status === 403) || - suppressFallback - ) { - throw innerError; - } - // Policy 403: org has disabled member project creation. The org-scoped - // endpoint enforces the same flag — re-throw immediately so the outer - // catch surfaces the friendly disabled-policy message without a wasted round-trip. - if (innerError.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL)) { - throw innerError; - } - return await withPlatformFallback(async (p) => { - const result = await createProjectWithAutoTeam(org, { - name, - platform: p, - }); - return toProjectData(result); - }); - } -} - -/** - * Validate explicit team access for a dry-run, mirroring preflight.ts:resolveTeam. - * - * When `team` is undefined, preflight intentionally chose the org-scoped - * onboarding endpoint, so there is no local team path to validate. - * - * @throws Non-403 errors from resolveOrCreateTeam (org not found, network, etc.) - */ -async function validateTeamForDryRun( - org: string, - team: string | undefined, - autoCreateSlug: string, -): Promise { - if (!team) { - return; - } - - try { - await resolveOrCreateTeam(org, { - team, - autoCreateSlug, - usageHint: "sentry init", - dryRun: true, - deferAutoCreateOnEmptyOrg: true, - }); - } catch (teamErr) { - if (!(teamErr instanceof ApiError && teamErr.status === 403)) { - throw teamErr; - } - } -} - -/** - * Create a new Sentry project using the org that preflight already resolved. - * When preflight does not resolve a Team Admin team, creation uses the same - * org-scoped auto-team endpoint as Sentry onboarding. - * - * New Sentry orgs have member project creation disabled by default - * (Organization.flags.disable_member_project_creation = true). When the org - * restricts project creation for members, we surface a clear error with an - * escape hatch: the user can pass `sentry init /` once an - * admin creates the project, which resolves to an existing project and skips - * creation entirely (preflight.ts:261). - */ -export async function createSentryProject( - payload: CreateSentryProjectPayload | EnsureSentryProjectPayload, - context: Pick< - ToolContext, - | "dryRun" - | "existingProject" - | "isExplicitTeam" - | "org" - | "team" - | "project" - | "yes" - >, -): Promise { - const name = context.project ?? payload.params.name; - const slug = slugify(name); - if (!slug) { - return { - ok: false, - error: `Invalid project name: "${name}" produces an empty slug.`, - }; - } - - if (context.existingProject) { - return { - ok: true, - message: `Using existing project "${context.existingProject.projectSlug}" in ${context.existingProject.orgSlug}`, - data: context.existingProject, - }; - } - - const scopeRecovery = captureOAuthScopeRecoveryGate(); - try { - const existingProject = await tryGetExistingProjectData(context.org, slug); - if (existingProject) { - return { - ok: true, - message: `Using existing project "${existingProject.projectSlug}" in ${existingProject.orgSlug}`, - data: existingProject, - }; - } - - if (context.dryRun) { - // Validate team access in dry-run — mirrors preflight.ts:resolveTeam. - // Not needed in real runs: resolveProjectCreation handles its own resolution. - await validateTeamForDryRun(context.org, context.team, slug); - return { - ok: true, - data: { - orgSlug: context.org, - projectSlug: slug, - projectId: "(dry-run)", - dsn: "https://key@o0.ingest.sentry.io/0", - url: "https://sentry.io/dry-run", - }, - }; - } - - // Use the Team Admin path when preflight found one; otherwise use the - // org-scoped onboarding path, which auto-creates a personal team for - // eligible members. - const projectData = await resolveProjectCreation({ - org: context.org, - name, - platform: payload.params.platform, - team: context.team, - suppressFallback: Boolean(context.isExplicitTeam), - }); - - return { - ok: true, - data: { - orgSlug: context.org, - projectSlug: projectData.projectSlug, - projectId: projectData.projectId, - dsn: projectData.dsn, - url: projectData.url, - }, - }; - } catch (error) { - // Org-level policy: member project creation is disabled on this org. - // Surface a clear message with the escape hatch. - if ( - error instanceof ApiError && - error.status === 403 && - error.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL) - ) { - return { - ok: false, - error: formatMemberProjectCreationDisabledError(context.org), - }; - } - if ( - await scopeRecovery.shouldDelegate(error, { - unattended: context.yes || context.dryRun, - }) - ) { - throw error; - } - // 409: project already exists (from either the team-scoped or org-scoped - // endpoint — both propagate here). Surface a friendly message with a view - // hint rather than the raw API error text. - if (error instanceof ApiError && error.status === 409) { - return { - ok: false, - error: - `A project named "${name}" already exists in "${context.org}".\n` + - `View it: sentry project view ${context.org}/${slugify(name)}`, - }; - } - return { ok: false, error: formatToolError(error) }; - } -} - -/** - * Tool definition for creating or ensuring a Sentry project exists for init. - */ -const describeCreateSentryProject = ( - payload: CreateSentryProjectPayload | EnsureSentryProjectPayload, -): string => - payload.detail ?? - `Ensuring project \`${payload.params.name}\` (${payload.params.platform})...`; - -export const createSentryProjectTool: InitToolDefinition<"create-sentry-project"> = - { - operation: "create-sentry-project", - describe: describeCreateSentryProject, - execute: createSentryProject, - }; - -export const ensureSentryProjectTool: InitToolDefinition<"ensure-sentry-project"> = - { - operation: "ensure-sentry-project", - describe: describeCreateSentryProject, - execute: createSentryProject, - }; diff --git a/packages/cli/src/lib/init/tools/detect-sentry.ts b/packages/cli/src/lib/init/tools/detect-sentry.ts deleted file mode 100644 index 8713b41df..000000000 --- a/packages/cli/src/lib/init/tools/detect-sentry.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { DetectSentryPayload, ToolResult } from "../types.js"; -import type { InitToolDefinition } from "./types.js"; - -/** - * Detect existing Sentry signals in the local project directory. - */ -export async function detectSentry(cwd: string): Promise { - const { detectDsn } = await import("../../dsn/index.js"); - const dsn = await detectDsn(cwd); - - if (!dsn) { - return { ok: true, data: { status: "none", signals: [] } }; - } - - const signals = [ - `dsn: ${dsn.source}${dsn.sourcePath ? ` (${dsn.sourcePath})` : ""}`, - ]; - - return { - ok: true, - data: { status: "installed", signals, dsn: dsn.raw }, - }; -} - -/** - * Tool definition for Sentry install detection. - */ -export const detectSentryTool: InitToolDefinition<"detect-sentry"> = { - operation: "detect-sentry", - describe: () => "Checking for existing Sentry setup...", - execute: async (payload: DetectSentryPayload) => - await detectSentry(payload.cwd), -}; diff --git a/packages/cli/src/lib/init/tools/file-changes/apply.ts b/packages/cli/src/lib/init/tools/file-changes/apply.ts deleted file mode 100644 index babb8e529..000000000 --- a/packages/cli/src/lib/init/tools/file-changes/apply.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Apply phase for fully prepared file changes. - * It revalidates captured path/content evidence and reports partial writes; - * it intentionally does not attempt rollback. - */ - -import { mkdir, unlink, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { safeReadFile } from "../../../safe-read.js"; -import type { ToolResult } from "../../types.js"; -import { safePath } from "../shared.js"; -import { - resolveCanonicalDestination, - resolveCanonicalRoot, - resolvePathIdentity, -} from "./paths.js"; -import { deleteSnapshotMatches, type PreparedFileChange } from "./prepare.js"; -import type { FileChangeFailure, FileChangeFailureCode } from "./result.js"; - -type AppliedFileChange = { - action: PreparedFileChange["action"]; - path: string; -}; - -class FileChangeApplyError extends Error { - readonly code: FileChangeFailureCode; - - constructor(code: FileChangeFailureCode, message: string) { - super(message); - this.code = code; - } -} - -async function assertApplyPath(change: PreparedFileChange): Promise { - try { - const currentRoot = await resolveCanonicalRoot(change.root); - const currentDestination = await resolveCanonicalDestination( - change.absolutePath, - ); - const currentIdentity = await resolvePathIdentity(change.absolutePath); - if ( - currentRoot !== change.rootRealPath || - currentDestination !== change.canonicalPath || - currentIdentity !== change.pathIdentity || - safePath(change.root, change.path) !== change.absolutePath - ) { - throw new Error("resolved path changed"); - } - } catch { - throw new FileChangeApplyError( - "stale_content", - `Cannot ${change.action} "${change.path}": path changed after validation`, - ); - } -} - -async function applyPreparedFileChange( - change: PreparedFileChange, -): Promise { - await assertApplyPath(change); - if (change.action === "create") { - await mkdir(path.dirname(change.absolutePath), { recursive: true }); - await assertApplyPath(change); - try { - await writeFile(change.absolutePath, change.content, { - encoding: "utf-8", - flag: "wx", - }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") { - throw new FileChangeApplyError( - "stale_content", - `Cannot create "${change.path}": target appeared after validation`, - ); - } - throw error; - } - return; - } - - if (change.action === "modify") { - const current = await safeReadFile( - change.absolutePath, - "apply-file-changes.apply", - ); - if (current === null) { - throw new FileChangeApplyError( - "stale_content", - `Cannot modify "${change.path}": target changed after validation`, - ); - } - if (current !== change.expectedContent) { - throw new FileChangeApplyError( - "stale_content", - `Cannot modify "${change.path}": file changed after validation`, - ); - } - await assertApplyPath(change); - await writeFile(change.absolutePath, change.content, "utf-8"); - return; - } - - if (!(await deleteSnapshotMatches(change.absolutePath, change.expected))) { - throw new FileChangeApplyError( - "stale_content", - `Cannot delete "${change.path}": target changed after validation`, - ); - } - await assertApplyPath(change); - if (change.expected.kind !== "missing") { - await unlink(change.absolutePath); - } -} - -function failureFromError( - change: PreparedFileChange, - error: unknown, -): FileChangeFailure { - return { - action: change.action, - code: error instanceof FileChangeApplyError ? error.code : "write_failed", - message: - error instanceof Error - ? error.message - : `Could not ${change.action} "${change.path}"`, - path: change.path, - }; -} - -function failedResult( - failure: FileChangeFailure, - applied: AppliedFileChange[], -): ToolResult { - return { - data: { applied, failed: failure }, - error: failure.message, - ok: false, - }; -} - -/** - * Apply a fully prepared batch, reporting any unavoidable write-time partial - * result without attempting filesystem rollback. - */ -export async function applyPreparedFileChanges( - changes: PreparedFileChange[], - dryRun: boolean, -): Promise { - const applied = changes.map(({ action, path: filePath }) => ({ - action, - path: filePath, - })); - if (dryRun) { - return { data: { applied, dryRun: true }, ok: true }; - } - - const completed: AppliedFileChange[] = []; - for (const change of changes) { - try { - await applyPreparedFileChange(change); - completed.push({ action: change.action, path: change.path }); - } catch (error) { - return failedResult(failureFromError(change, error), completed); - } - } - return { data: { applied: completed }, ok: true }; -} diff --git a/packages/cli/src/lib/init/tools/file-changes/contract.ts b/packages/cli/src/lib/init/tools/file-changes/contract.ts deleted file mode 100644 index 22e678bd3..000000000 --- a/packages/cli/src/lib/init/tools/file-changes/contract.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Runtime boundary for legacy file-change requests from the remote workflow. - * Successful parses normalize wire-specific patch names into domain changes. - */ - -import { - array, - literal, - minLength, - optional, - pipe, - safeParse, - strictObject, - string, - variant, -} from "valibot"; -import type { FileChange } from "./types.js"; - -const editSchema = strictObject({ - oldString: pipe(string(), minLength(1, "oldString must not be empty")), - newString: string(), -}); - -const fileChangeSchema = variant("action", [ - strictObject({ - action: literal("create"), - patch: string(), - path: pipe(string(), minLength(1, "path must not be empty")), - }), - strictObject({ - action: literal("modify"), - edits: pipe(array(editSchema), minLength(1, "edits must not be empty")), - path: pipe(string(), minLength(1, "path must not be empty")), - }), - strictObject({ - action: literal("delete"), - patch: optional(string()), - path: pipe(string(), minLength(1, "path must not be empty")), - }), -]); - -const legacyApplyPatchsetPayloadSchema = strictObject({ - cwd: pipe(string(), minLength(1, "cwd must not be empty")), - operation: literal("apply-patchset"), - params: strictObject({ - patches: array(fileChangeSchema), - }), - type: literal("tool"), -}); - -type FileChangesContractResult = - | { ok: true; cwd: string; changes: FileChange[] } - | { ok: false; error: string }; - -/** - * Validate the legacy wire request before it reaches the filesystem. - * - * The remote operation remains `apply-patchset` for compatibility, while the - * local implementation treats its payload as a batch of file changes. - */ -export function parseFileChangesRequest( - input: unknown, -): FileChangesContractResult { - const result = safeParse(legacyApplyPatchsetPayloadSchema, input); - if (!result.success) { - const details = [...new Set(result.issues.map((issue) => issue.message))]; - return { - error: `Invalid file changes request: ${details.join("; ")}`, - ok: false, - }; - } - return { - changes: result.output.params.patches.map((change): FileChange => { - if (change.action === "create") { - return { - action: change.action, - content: change.patch, - path: change.path, - }; - } - if (change.action === "modify") { - return change; - } - return { action: change.action, path: change.path }; - }), - cwd: result.output.cwd, - ok: true, - }; -} diff --git a/packages/cli/src/lib/init/tools/file-changes/paths.ts b/packages/cli/src/lib/init/tools/file-changes/paths.ts deleted file mode 100644 index 56c31b882..000000000 --- a/packages/cli/src/lib/init/tools/file-changes/paths.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Canonical path snapshots for the file-change engine. - * - * Preparation records the real root and destination. Apply recomputes both so - * a retargeted project or ancestor symlink is treated as stale evidence. - */ - -import { lstat, realpath } from "node:fs/promises"; -import path from "node:path"; - -/** Return a stable device/inode identity for an existing path when available. */ -export async function resolvePathIdentity( - absolutePath: string, -): Promise { - try { - const stats = await lstat(absolutePath, { bigint: true }); - return stats.ino === 0n ? undefined : `${stats.dev}:${stats.ino}`; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR") { - return; - } - throw error; - } -} - -/** Resolve an existing project root to the identity used during preparation. */ -export function resolveCanonicalRoot(root: string): Promise { - return realpath(root); -} - -/** - * Resolve a destination even when its final components do not exist yet. - * The returned path follows the nearest existing ancestor and appends the - * missing suffix without mutating disk. - */ -export async function resolveCanonicalDestination( - absolutePath: string, -): Promise { - let existingPath = absolutePath; - const missingSegments: string[] = []; - for (;;) { - try { - return path.join(await realpath(existingPath), ...missingSegments); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT" && code !== "ENOTDIR") { - throw error; - } - const parent = path.dirname(existingPath); - if (parent === existingPath) { - throw error; - } - missingSegments.unshift(path.basename(existingPath)); - existingPath = parent; - } - } -} - -/** Return true when a canonical destination remains inside its captured root. */ -export function isCanonicalChild(root: string, destination: string): boolean { - return destination === root || destination.startsWith(`${root}${path.sep}`); -} diff --git a/packages/cli/src/lib/init/tools/file-changes/prepare.ts b/packages/cli/src/lib/init/tools/file-changes/prepare.ts deleted file mode 100644 index 40800444a..000000000 --- a/packages/cli/src/lib/init/tools/file-changes/prepare.ts +++ /dev/null @@ -1,590 +0,0 @@ -/** - * Read-only preparation phase for file changes. - * Every change, edit, destination identity, and conflict is validated before - * the caller is allowed to perform the first write. - */ - -import { lstat, readFile, readlink } from "node:fs/promises"; -import path from "node:path"; -import { safeReadFile } from "../../../safe-read.js"; -import { safePath } from "../shared.js"; -import { - isCanonicalChild, - resolveCanonicalDestination, - resolveCanonicalRoot, - resolvePathIdentity, -} from "./paths.js"; -import type { FileChangeFailure } from "./result.js"; -import type { FileChange } from "./types.js"; - -const EMPTY_AUTH_TOKEN_RE = - /^(SENTRY_AUTH_TOKEN[ \t]*=[ \t]*)(?:['"]?[ \t]*['"]?)?[ \t]*$/m; -const PATH_SEGMENT_RE = /[/\\]/u; -const WINDOWS_DRIVE_RE = /^[A-Za-z]:/; -const LINE_ENDING_RE = /\r\n|\r|\n/g; - -type PreparedPath = { - absolutePath: string; - canonicalPath: string; - pathIdentity?: string; - path: string; - root: string; - rootRealPath: string; -}; - -type PreparedCreate = PreparedPath & { - action: "create"; - content: string; -}; - -type PreparedModify = PreparedPath & { - action: "modify"; - content: string; - expectedContent: string; -}; - -type DeleteSnapshot = - | { kind: "missing" } - | { content: Buffer; kind: "file" } - | { kind: "symlink"; target: string }; - -type PreparedDelete = PreparedPath & { - action: "delete"; - expected: DeleteSnapshot; -}; - -/** Evidence and final content carried from preparation into the apply phase. */ -export type PreparedFileChange = - | PreparedCreate - | PreparedDelete - | PreparedModify; - -/** Complete prepared batch, or the first source-free validation failure. */ -export type PrepareFileChangesResult = - | { ok: true; changes: PreparedFileChange[] } - | { ok: false; failure: FileChangeFailure }; - -type MatchResult = - | { ok: true; content: string } - | { ok: false; code: "edit_ambiguous" | "edit_not_found" }; - -type ApplyEditsResult = - | { ok: true; content: string } - | { ok: false; failure: FileChangeFailure }; - -type NormalizeFileChangesResult = - | { ok: true; changes: FileChange[] } - | { ok: false; failure: FileChangeFailure }; - -function validateFileChangePath(filePath: string): string | undefined { - if (filePath.includes("\\")) { - return `Invalid file change path "${filePath}": use project-relative POSIX paths`; - } - if (WINDOWS_DRIVE_RE.test(filePath) || path.posix.isAbsolute(filePath)) { - return `Invalid file change path "${filePath}": absolute paths are not allowed`; - } - const invalidSegment = filePath - .split("/") - .some( - (segment) => segment.length === 0 || segment === "." || segment === "..", - ); - return invalidSegment - ? `Invalid file change path "${filePath}": path segments must not be empty, "." or ".."` - : undefined; -} - -function detectLineEnding(content: string): "\n" | "\r\n" { - const firstNewline = content.indexOf("\n"); - return firstNewline > 0 && content[firstNewline - 1] === "\r" ? "\r\n" : "\n"; -} - -function convertLineEndings(content: string, lineEnding: "\n" | "\r\n") { - return content.replace(LINE_ENDING_RE, lineEnding); -} - -function replaceUnique( - content: string, - oldString: string, - newString: string, -): MatchResult { - const first = content.indexOf(oldString); - if (first === -1) { - return { code: "edit_not_found", ok: false }; - } - if (content.indexOf(oldString, first + 1) !== -1) { - return { code: "edit_ambiguous", ok: false }; - } - if (oldString === newString) { - return { content, ok: true }; - } - return { - content: `${content.slice(0, first)}${newString}${content.slice(first + oldString.length)}`, - ok: true, - }; -} - -function applyEdits( - initialContent: string, - filePath: string, - edits: Extract["edits"], -): ApplyEditsResult { - const hasBom = initialContent.startsWith("\uFEFF"); - let content = hasBom ? initialContent.slice(1) : initialContent; - const lineEnding = detectLineEnding(content); - - for (const [index, edit] of edits.entries()) { - const oldString = convertLineEndings(edit.oldString, lineEnding); - const newString = convertLineEndings(edit.newString, lineEnding); - const result = replaceUnique(content, oldString, newString); - if (!result.ok) { - const reason = - result.code === "edit_ambiguous" - ? "matched more than once; provide more surrounding context" - : "was not found exactly in the current file"; - return { - failure: { - action: "modify", - code: result.code, - message: `Edit #${index + 1} on "${filePath}" ${reason}`, - path: filePath, - }, - ok: false, - }; - } - content = result.content; - } - - return { content: hasBom ? `\uFEFF${content}` : content, ok: true }; -} - -function resolveCreateContent( - change: Extract, - authToken?: string, -): string { - let content = change.path.endsWith(".json") - ? prettyPrintJson(change.content) - : change.content; - if ( - authToken && - isEnvFile(change.path) && - EMPTY_AUTH_TOKEN_RE.test(content) - ) { - content = content.replace( - EMPTY_AUTH_TOKEN_RE, - (_, prefix) => `${prefix}${authToken}`, - ); - } - return content; -} - -function prettyPrintJson(content: string): string { - // 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 { - return `${JSON.stringify(JSON.parse(content), null, 2)}\n`; - } catch { - return content; - } -} - -function isEnvFile(filePath: string): boolean { - const name = filePath.split(PATH_SEGMENT_RE).at(-1) ?? ""; - return name === ".env" || name.startsWith(".env."); -} - -async function readDeleteSnapshot( - absolutePath: string, -): Promise { - try { - const stats = await lstat(absolutePath); - if (stats.isSymbolicLink()) { - return { kind: "symlink", target: await readlink(absolutePath) }; - } - if (!stats.isFile()) { - return; - } - return { content: await readFile(absolutePath), kind: "file" }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { kind: "missing" }; - } - throw error; - } -} - -async function prepareFileChange( - cwd: string, - rootRealPath: string, - change: FileChange, - authToken?: string, -): Promise { - const pathError = validateFileChangePath(change.path); - if (pathError) { - return { - failure: { - action: change.action, - code: "invalid_path", - message: pathError, - path: change.path, - }, - ok: false, - }; - } - - let absolutePath: string; - try { - absolutePath = safePath(cwd, change.path); - } catch (error) { - return { - failure: { - action: change.action, - code: "invalid_path", - message: error instanceof Error ? error.message : String(error), - path: change.path, - }, - ok: false, - }; - } - - let canonicalPath: string; - let pathIdentity: string | undefined; - // 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 { - canonicalPath = await resolveCanonicalDestination(absolutePath); - if (!isCanonicalChild(rootRealPath, canonicalPath)) { - throw new Error("canonical destination is outside the project root"); - } - pathIdentity = await resolvePathIdentity(absolutePath); - } catch { - return { - failure: { - action: change.action, - code: "invalid_path", - message: `Cannot resolve "${change.path}" inside the project root`, - path: change.path, - }, - ok: false, - }; - } - - const preparedPath: PreparedPath = { - absolutePath, - canonicalPath, - pathIdentity, - path: change.path, - root: cwd, - rootRealPath, - }; - - if (change.action === "create") { - return await prepareCreate(preparedPath, change, authToken); - } - if (change.action === "delete") { - return await prepareDelete(preparedPath, change); - } - return await prepareModify(preparedPath, change); -} - -async function prepareCreate( - preparedPath: PreparedPath, - change: Extract, - authToken?: string, -): Promise { - try { - await lstat(preparedPath.absolutePath); - return { - failure: { - action: change.action, - code: "already_exists", - message: `Cannot create "${change.path}": target already exists`, - path: change.path, - }, - ok: false, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - return { - failure: { - action: change.action, - code: "read_failed", - message: `Cannot inspect "${change.path}" before creating it`, - path: change.path, - }, - ok: false, - }; - } - } - return { - changes: [ - { - ...preparedPath, - action: change.action, - content: resolveCreateContent(change, authToken), - }, - ], - ok: true, - }; -} - -async function prepareDelete( - preparedPath: PreparedPath, - change: Extract, -): Promise { - // 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 expected = await readDeleteSnapshot(preparedPath.absolutePath); - if (!expected) { - return { - failure: { - action: change.action, - code: "not_regular_file", - message: `Cannot delete "${change.path}": target is not a regular file or symlink`, - path: change.path, - }, - ok: false, - }; - } - return { - changes: [ - { - ...preparedPath, - action: change.action, - expected, - }, - ], - ok: true, - }; - } catch { - return { - failure: { - action: change.action, - code: "read_failed", - message: `Cannot inspect "${change.path}" before deleting it`, - path: change.path, - }, - ok: false, - }; - } -} - -async function prepareModify( - preparedPath: PreparedPath, - change: Extract, -): Promise { - let initialContent: string; - try { - const stats = await lstat(preparedPath.absolutePath); - if (!(stats.isFile() || stats.isSymbolicLink())) { - return { - failure: { - action: change.action, - code: "not_regular_file", - message: `Cannot modify "${change.path}": target is not a regular file`, - path: change.path, - }, - ok: false, - }; - } - const content = await safeReadFile( - preparedPath.absolutePath, - "apply-file-changes.prepare", - ); - if (content === null) { - return { - failure: { - action: change.action, - code: "not_regular_file", - message: `Cannot modify "${change.path}": target is not a readable regular file`, - path: change.path, - }, - ok: false, - }; - } - initialContent = content; - } catch (error) { - const missing = (error as NodeJS.ErrnoException).code === "ENOENT"; - return { - failure: { - action: change.action, - code: missing ? "missing_file" : "read_failed", - message: missing - ? `Cannot modify "${change.path}": file does not exist` - : `Cannot read "${change.path}" before modifying it`, - path: change.path, - }, - ok: false, - }; - } - - const edited = applyEdits(initialContent, change.path, change.edits); - if (!edited.ok) { - return edited; - } - return { - changes: [ - { - ...preparedPath, - action: change.action, - content: edited.content, - expectedContent: initialContent, - }, - ], - ok: true, - }; -} - -function normalizeFileChanges( - changes: FileChange[], -): NormalizeFileChangesResult { - const normalized: FileChange[] = []; - const targetIndexes = new Map(); - for (const change of changes) { - const existingIndex = targetIndexes.get(change.path); - if (existingIndex === undefined) { - targetIndexes.set(change.path, normalized.length); - normalized.push(structuredClone(change)); - continue; - } - - const existing = normalized[existingIndex]; - if (existing?.action === "modify" && change.action === "modify") { - existing.edits.push(...change.edits); - continue; - } - return { - failure: { - action: change.action, - code: "duplicate_target", - message: `File change batch contains more than one operation for "${change.path}"`, - path: change.path, - }, - ok: false, - }; - } - - for (const change of normalized) { - const conflict = normalized.find( - (candidate) => - candidate.path !== change.path && - (candidate.path.startsWith(`${change.path}/`) || - change.path.startsWith(`${candidate.path}/`)), - ); - if (conflict) { - return { - failure: { - action: change.action, - code: "path_conflict", - message: `File change targets "${change.path}" and "${conflict.path}" overlap`, - path: change.path, - }, - ok: false, - }; - } - } - return { changes: normalized, ok: true }; -} - -function comparableCanonicalPath(filePath: string): string { - return process.platform === "win32" || process.platform === "darwin" - ? filePath.toLocaleLowerCase("en-US") - : filePath; -} - -function canonicalTargetsOverlap( - first: PreparedFileChange, - second: PreparedFileChange, -): boolean { - const firstPath = comparableCanonicalPath(first.canonicalPath); - const secondPath = comparableCanonicalPath(second.canonicalPath); - return ( - (first.pathIdentity !== undefined && - first.pathIdentity === second.pathIdentity) || - firstPath === secondPath || - firstPath.startsWith(`${secondPath}${path.sep}`) || - secondPath.startsWith(`${firstPath}${path.sep}`) - ); -} - -/** - * Validate and materialize an entire file-change batch without mutating disk. - */ -export async function prepareFileChanges( - cwd: string, - changes: FileChange[], - authToken?: string, -): Promise { - const prepared: PreparedFileChange[] = []; - const [firstChange] = changes; - if (!firstChange) { - return { changes: prepared, ok: true }; - } - let rootRealPath: string; - // 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 { - rootRealPath = await resolveCanonicalRoot(cwd); - } catch { - return { - failure: { - action: firstChange.action, - code: "invalid_path", - message: "Cannot resolve the project root before applying file changes", - path: firstChange.path, - }, - ok: false, - }; - } - const normalized = normalizeFileChanges(changes); - if (!normalized.ok) { - return normalized; - } - - for (const change of normalized.changes) { - const result = await prepareFileChange( - cwd, - rootRealPath, - change, - authToken, - ); - if (!result.ok) { - return result; - } - const [next] = result.changes; - const conflict = next - ? prepared.find((candidate) => canonicalTargetsOverlap(candidate, next)) - : undefined; - if (next && conflict) { - return { - failure: { - action: next.action, - code: "path_conflict", - message: `File change targets "${next.path}" and "${conflict.path}" resolve to overlapping filesystem paths`, - path: next.path, - }, - ok: false, - }; - } - prepared.push(...result.changes); - } - return { changes: prepared, ok: true }; -} - -/** Compare a prepared delete snapshot with the target's current state. */ -export async function deleteSnapshotMatches( - absolutePath: string, - expected: DeleteSnapshot, -): Promise { - const current = await readDeleteSnapshot(absolutePath); - if (!current || current.kind !== expected.kind) { - return false; - } - if (current.kind === "missing" && expected.kind === "missing") { - return true; - } - if (current.kind === "symlink" && expected.kind === "symlink") { - return current.target === expected.target; - } - return ( - current.kind === "file" && - expected.kind === "file" && - current.content.equals(expected.content) - ); -} diff --git a/packages/cli/src/lib/init/tools/file-changes/result.ts b/packages/cli/src/lib/init/tools/file-changes/result.ts deleted file mode 100644 index 7fc0a4939..000000000 --- a/packages/cli/src/lib/init/tools/file-changes/result.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** Structured, source-free failure results returned by file changes. */ - -import type { FileChange } from "./types.js"; - -/** Stable failure categories returned to the remote workflow. */ -export type FileChangeFailureCode = - | "already_exists" - | "duplicate_target" - | "edit_ambiguous" - | "edit_not_found" - | "invalid_path" - | "missing_file" - | "not_regular_file" - | "path_conflict" - | "read_failed" - | "stale_content" - | "write_failed"; - -/** A failed file operation with enough context for agent recovery. */ -export type FileChangeFailure = { - /** File operation that failed. */ - action: FileChange["action"]; - /** Stable category suitable for recovery logic. */ - code: FileChangeFailureCode; - /** Human-readable explanation without file contents or credentials. */ - message: string; - /** Project-relative target path. */ - path: string; -}; diff --git a/packages/cli/src/lib/init/tools/file-changes/types.ts b/packages/cli/src/lib/init/tools/file-changes/types.ts deleted file mode 100644 index ed369425f..000000000 --- a/packages/cli/src/lib/init/tools/file-changes/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Domain inputs owned by the local file-change engine. */ - -/** One exact text replacement within an existing file. */ -export type FileEdit = { - /** Replacement text, converted to the target file's line endings. */ - newString: string; - /** Unique exact text required in the current staged content. */ - oldString: string; -}; - -/** One prepared-batch input, normalized away from the legacy wire format. */ -export type FileChange = - | { action: "create"; content: string; path: string } - | { action: "modify"; edits: FileEdit[]; path: string } - | { action: "delete"; path: string }; diff --git a/packages/cli/src/lib/init/tools/file-exists-batch.ts b/packages/cli/src/lib/init/tools/file-exists-batch.ts deleted file mode 100644 index b1fbe33a7..000000000 --- a/packages/cli/src/lib/init/tools/file-exists-batch.ts +++ /dev/null @@ -1,59 +0,0 @@ -import fs from "node:fs"; -import type { FileExistsBatchPayload, ToolResult } from "../types.js"; -import { safePath } from "./shared.js"; -import type { InitToolDefinition } from "./types.js"; - -const PATH_SEGMENT_RE = /[/\\]/u; - -/** - * Check whether a batch of paths exists inside the sandbox. - */ -export async function fileExistsBatch( - payload: FileExistsBatchPayload, -): Promise { - const results = await Promise.all( - payload.params.paths.map(async (filePath) => { - // 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 absPath = safePath(payload.cwd, filePath); - await fs.promises.access(absPath); - return [filePath, true] as const; - } catch { - return [filePath, false] as const; - } - }), - ); - - const exists: Record = {}; - for (const [filePath, found] of results) { - exists[filePath] = found; - } - - return { ok: true, data: { exists } }; -} - -/** - * Tool definition for batched existence checks. - */ -export const fileExistsBatchTool: InitToolDefinition<"file-exists-batch"> = { - operation: "file-exists-batch", - describe: (payload) => { - const [first, second] = payload.params.paths; - if (!first) { - return "Checking files..."; - } - if (!second && payload.params.paths.length === 1) { - return `Checking \`${pathBase(first)}\`...`; - } - if (payload.params.paths.length === 2 && second) { - return `Checking \`${pathBase(first)}\`, \`${pathBase(second)}\`...`; - } - return `Checking ${payload.params.paths.length} files (\`${pathBase(first)}\`${second ? `, \`${pathBase(second)}\`` : ""}, ...)...`; - }, - execute: fileExistsBatch, -}; - -function pathBase(filePath: string): string { - const parts = filePath.split(PATH_SEGMENT_RE); - return parts.at(-1) ?? filePath; -} diff --git a/packages/cli/src/lib/init/tools/glob.ts b/packages/cli/src/lib/init/tools/glob.ts deleted file mode 100644 index cde97878b..000000000 --- a/packages/cli/src/lib/init/tools/glob.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Init-wizard `glob` tool adapter. Thin wrapper over `collectGlob`: - * sandboxes `params.path`, runs each pattern as a separate call - * (the wire contract returns one row per pattern with its own - * `truncated` flag), and passes `files` + `truncated` through. - */ - -import { collectGlob } from "../../scan/index.js"; -import type { GlobPayload, ToolResult } from "../types.js"; -import { safePath } from "./shared.js"; -import type { InitToolDefinition } from "./types.js"; - -const MAX_GLOB_RESULTS = 100; - -type PatternResult = { - pattern: string; - files: string[]; - truncated: boolean; -}; - -/** Find files matching one or more glob patterns in parallel. */ -export async function glob(payload: GlobPayload): Promise { - const maxResults = payload.params.maxResults ?? MAX_GLOB_RESULTS; - - // Validate once before spawning per-pattern calls — a single - // throw aborts the whole payload, matching the registry's - // sandbox-reject contract. - if (payload.params.path !== undefined) { - safePath(payload.cwd, payload.params.path); - } - - const results: PatternResult[] = await Promise.all( - payload.params.patterns.map(async (pattern) => { - const { files, truncated } = await collectGlob({ - cwd: payload.cwd, - patterns: pattern, - path: payload.params.path, - maxResults, - }); - return { pattern, files, truncated }; - }), - ); - return { ok: true, data: { results } }; -} - -/** - * Tool definition for glob-based file discovery. - */ -export const globTool: InitToolDefinition<"glob"> = { - operation: "glob", - describe: (payload) => { - const [first] = payload.params.patterns; - if (payload.params.patterns.length === 1 && first) { - return `Finding files matching \`${first}\`...`; - } - return `Finding files (${payload.params.patterns.length} patterns)...`; - }, - execute: glob, -}; diff --git a/packages/cli/src/lib/init/tools/grep.ts b/packages/cli/src/lib/init/tools/grep.ts deleted file mode 100644 index 7fa653211..000000000 --- a/packages/cli/src/lib/init/tools/grep.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Init-wizard `grep` tool adapter. Thin wrapper over `collectGrep`: - * sandboxes `search.path`, forwards the wire-level constants, - * strips `absolutePath` from each match (not part of the wire - * contract), and catches `ValidationError` from a bad regex so the - * agent can retry without the whole payload aborting. - */ - -import { ValidationError } from "../../errors.js"; -import { collectGrep } from "../../scan/index.js"; -import type { GrepPayload, GrepSearch, ToolResult } from "../types.js"; -import { safePath } from "./shared.js"; -import type { InitToolDefinition } from "./types.js"; - -const MAX_GREP_RESULTS_PER_SEARCH = 100; -const MAX_GREP_LINE_LENGTH = 2000; - -/** Per-match shape on the wire — no `absolutePath`, by contract. */ -type WireGrepMatch = { path: string; lineNum: number; line: string }; - -type SearchResult = { - pattern: string; - matches: WireGrepMatch[]; - truncated: boolean; -}; - -async function runOneSearch( - cwd: string, - search: GrepSearch, - maxResults: number, -): Promise { - // The scan engine trusts its `path` input (see - // `GrepOptions.path`) — sandbox enforcement lives here. We only - // need the validation side effect; `collectGrep` takes a - // cwd-relative subpath, so we forward `search.path` unchanged. - if (search.path !== undefined) { - safePath(cwd, search.path); - } - - try { - const { matches, stats } = await collectGrep({ - cwd, - pattern: search.pattern, - include: search.include, - path: search.path, - // Wire shape uses `caseInsensitive`; scan engine uses the - // inverse. Leave undefined when unset so the engine's default - // (case-sensitive, rg-like) applies. - caseSensitive: search.caseInsensitive === true ? false : undefined, - multiline: search.multiline, - maxResults, - maxLineLength: MAX_GREP_LINE_LENGTH, - }); - return { - pattern: search.pattern, - matches: matches.map((m) => ({ - path: m.path, - lineNum: m.lineNum, - line: m.line, - })), - truncated: stats.truncated, - }; - } catch (error) { - if (error instanceof ValidationError) { - // Malformed regex from the agent. Empty row lets the agent - // retry with a fix instead of aborting the whole payload. - return { pattern: search.pattern, matches: [], truncated: false }; - } - throw error; - } -} - -/** Search project files for one or more regex patterns in parallel. */ -export async function grep(payload: GrepPayload): Promise { - const maxResults = - payload.params.maxResultsPerSearch ?? MAX_GREP_RESULTS_PER_SEARCH; - const results = await Promise.all( - payload.params.searches.map((search) => - runOneSearch(payload.cwd, search, maxResults), - ), - ); - return { ok: true, data: { results } }; -} - -/** - * Tool definition for grep-like project searches. - */ -export const grepTool: InitToolDefinition<"grep"> = { - operation: "grep", - describe: (payload) => { - const [first] = payload.params.searches; - if (payload.params.searches.length === 1 && first) { - return `Searching for \`${first.pattern}\`...`; - } - return `Running ${payload.params.searches.length} searches...`; - }, - execute: grep, -}; diff --git a/packages/cli/src/lib/init/tools/list-dir.ts b/packages/cli/src/lib/init/tools/list-dir.ts deleted file mode 100644 index a34befa1d..000000000 --- a/packages/cli/src/lib/init/tools/list-dir.ts +++ /dev/null @@ -1,178 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { DEFAULT_SKIP_DIRS, normalizePath } from "../../scan/index.js"; -import type { DirEntry, ListDirPayload, ToolResult } from "../types.js"; -import { safePath } from "./shared.js"; -import type { InitToolDefinition } from "./types.js"; - -const NATIVE_SEP = path.sep; -const INIT_SKIP_DIRS = new Set([ - ...DEFAULT_SKIP_DIRS, - "tmp", - "temp", - "bin", - "Pods", -]); - -/** - * List files and directories within the workflow sandbox. - * - * Paths in the result are POSIX-normalized (`/`-separated) regardless - * of host OS, so the Mastra agent sees a consistent wire shape. - */ -export async function listDir(payload: ListDirPayload): Promise { - const { cwd, params } = payload; - const targetPath = safePath(cwd, params.path); - const maxDepth = params.maxDepth ?? 3; - const maxEntries = params.maxEntries ?? 500; - const recursive = params.recursive ?? false; - const state: WalkState = { - cwd, - // Cached prefix length used to turn an absolute native path into a - // cwd-relative POSIX path via `abs.slice(cwdPrefixLen)` — O(1) and - // avoids the per-entry `path.relative` allocation. - cwdPrefixLen: cwd.length + 1, - entries: [], - maxDepth, - maxEntries, - recursive, - truncated: false, - }; - - await walkDirectory(targetPath, 0, state); - return { - ok: true, - data: { - entries: state.entries, - ...(state.truncated ? { truncated: true } : {}), - }, - }; -} - -type WalkState = { - cwd: string; - cwdPrefixLen: number; - entries: DirEntry[]; - maxDepth: number; - maxEntries: number; - recursive: boolean; - truncated: boolean; -}; - -async function readDirEntries(dir: string): Promise { - // 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 { - return await fs.promises.readdir(dir, { withFileTypes: true }); - } catch { - return; - } -} - -function shouldRecurseInto(entry: fs.Dirent, state: WalkState): boolean { - return ( - state.recursive && - entry.isDirectory() && - !entry.isSymbolicLink() && - !entry.name.startsWith(".") && - !INIT_SKIP_DIRS.has(entry.name) - ); -} - -/** - * Build a `DirEntry` for `entry` sitting inside `dir`. Returns - * `undefined` for symlinks that escape the sandbox. - * - * `dir` is absolute native-separator, guaranteed to start with - * `state.cwd + sep`, so `abs.slice(state.cwdPrefixLen)` yields the - * cwd-relative path without a `path.relative` allocation. - */ -function toDirEntry( - state: WalkState, - dir: string, - entry: fs.Dirent, -): DirEntry | undefined { - const abs = dir + NATIVE_SEP + entry.name; - const relNative = abs.slice(state.cwdPrefixLen); - - if (entry.isSymbolicLink()) { - // 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 { - safePath(state.cwd, relNative); - } catch { - return; - } - } - - if (entry.isDirectory()) { - return { - name: entry.name, - path: normalizePath(relNative), - type: "directory", - }; - } - - // Only regular files carry size. lstat avoids following a path that changed - // into a symlink after readdir; special files are never opened. - return { - name: entry.name, - path: normalizePath(relNative), - type: "file", - ...(entry.isFile() ? fileSize(abs) : {}), - }; -} - -/** Return a regular file's byte size without opening or reading its contents. */ -function fileSize(abs: string): { size?: number } { - // 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 stat = fs.lstatSync(abs); - return stat.isFile() ? { size: stat.size } : {}; - } catch { - return {}; - } -} - -async function walkDirectory( - dir: string, - depth: number, - state: WalkState, -): Promise { - if (depth > state.maxDepth || state.entries.length >= state.maxEntries) { - state.truncated = true; - return; - } - - const entries = await readDirEntries(dir); - if (!entries) { - state.truncated = true; - return; - } - - for (const entry of entries) { - if (state.entries.length >= state.maxEntries) { - state.truncated = true; - return; - } - const nextEntry = toDirEntry(state, dir, entry); - if (!nextEntry) { - continue; - } - state.entries.push(nextEntry); - if (shouldRecurseInto(entry, state)) { - if (depth >= state.maxDepth) { - state.truncated = true; - } else { - await walkDirectory(dir + NATIVE_SEP + entry.name, depth + 1, state); - } - } - } -} - -/** - * Tool definition for directory listing requests. - */ -export const listDirTool: InitToolDefinition<"list-dir"> = { - operation: "list-dir", - describe: () => "Listing directory...", - execute: listDir, -}; diff --git a/packages/cli/src/lib/init/tools/project-file.ts b/packages/cli/src/lib/init/tools/project-file.ts deleted file mode 100644 index e3d23193c..000000000 --- a/packages/cli/src/lib/init/tools/project-file.ts +++ /dev/null @@ -1,201 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { ReadFileErrorCode } from "../types.js"; -import { safePath } from "./shared.js"; - -const SENSITIVE_PATH_TOKEN_RE = /[\\/{},()[\]!+@?*]+/u; -const SENSITIVE_PATH_TOKENS = new Set([ - ".git", - ".git-credentials", - ".hg", - ".netrc", - ".npmrc", - ".pypirc", - ".svn", - ".yarnrc", - ".yarnrc.yml", -]); -const CONTROL_CHARACTER_RE = /\p{Cc}/u; -const WINDOWS_DRIVE_RE = /^[A-Za-z]:/u; -const WINDOWS_RESERVED_SEGMENT_RE = - /^(?:aux|con|nul|prn|com[1-9]|lpt[1-9])(?:\..*)?$/iu; - -export type OpenedProjectFile = { - handle: fs.promises.FileHandle; - stat: fs.Stats; -}; - -/** - * Normalize a portable filesystem-root-relative file path. - * - * Tool requests may use either slash style, but aliases that resolve - * differently across operating systems are rejected before filesystem I/O. - */ -export function normalizeProjectFilePath(filePath: string): string | undefined { - const portablePath = filePath.replaceAll("\\", "/"); - if ( - portablePath.startsWith("/") || - WINDOWS_DRIVE_RE.test(portablePath) || - CONTROL_CHARACTER_RE.test(portablePath) - ) { - return; - } - - const segments = portablePath.split("/"); - if ( - segments.some( - (segment) => - segment.length === 0 || - segment === "." || - segment === ".." || - segment.includes(":") || - segment.endsWith(".") || - segment.endsWith(" ") || - WINDOWS_RESERVED_SEGMENT_RE.test(segment), - ) - ) { - return; - } - return segments.join("/"); -} - -/** - * Open a stable regular file inside a project root. - * - * Direct reads of sensitive metadata are allowed. An alias whose resolved - * target is sensitive is rejected because downstream redaction selects its - * format from the requested path. - */ -export async function openProjectFile( - cwd: string, - filePath: string, -): Promise { - let handle: fs.promises.FileHandle | undefined; - try { - const absPath = safePath(cwd, filePath); - handle = await fs.promises.open( - absPath, - // oxlint-disable-next-line no-bitwise -- fs open flags are a bitmask. - fs.constants.O_RDONLY | fs.constants.O_NONBLOCK, - ); - const stat = await handle.stat(); - if (!stat.isFile()) { - await closeProjectFile(handle); - return { error: "not-file" }; - } - if (!(await openedPathIsAllowed(cwd, absPath, stat))) { - await closeProjectFile(handle); - return { error: "unreadable" }; - } - return { handle, stat }; - } catch (error) { - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. - await handle?.close().catch(() => { - // Preserve the primary open error when cleanup fails. - }); - return { error: readErrorCode(error) }; - } -} - -export async function closeProjectFile( - handle: fs.promises.FileHandle, -): Promise { - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. - await handle.close().catch(() => { - // Descriptor cleanup must not replace the primary read classification. - }); -} - -export function projectFileChanged( - initial: fs.Stats, - final: fs.Stats, -): boolean { - return ( - final.dev !== initial.dev || - final.ino !== initial.ino || - final.size !== initial.size || - final.mtimeMs !== initial.mtimeMs - ); -} - -async function openedPathIsAllowed( - cwd: string, - absPath: string, - openedStat: fs.Stats, -): Promise { - const [realCwd, realPath] = await Promise.all([ - fs.promises.realpath(cwd), - fs.promises.realpath(absPath), - ]); - if (realPath !== realCwd && !realPath.startsWith(`${realCwd}${path.sep}`)) { - return false; - } - - const requestedRelativePath = path.relative(path.resolve(cwd), absPath); - const resolvedRelativePath = path.relative(realCwd, realPath); - if ( - isSensitiveReadPath(resolvedRelativePath) && - (!sameProjectPath(requestedRelativePath, resolvedRelativePath) || - (await requestedPathContainsSymlink(cwd, absPath))) - ) { - return false; - } - - const resolvedStat = await fs.promises.stat(realPath); - return ( - resolvedStat.dev === openedStat.dev && resolvedStat.ino === openedStat.ino - ); -} - -async function requestedPathContainsSymlink( - cwd: string, - absPath: string, -): Promise { - let currentPath = path.resolve(cwd); - const relativePath = path.relative(currentPath, absPath); - for (const segment of relativePath.split(path.sep)) { - if (!segment) { - continue; - } - currentPath = path.join(currentPath, segment); - if ((await fs.promises.lstat(currentPath)).isSymbolicLink()) { - return true; - } - } - return false; -} - -function sameProjectPath(first: string, second: string): boolean { - const normalizedFirst = path.normalize(first); - const normalizedSecond = path.normalize(second); - if (process.platform === "win32" || process.platform === "darwin") { - return normalizedFirst.toLowerCase() === normalizedSecond.toLowerCase(); - } - return normalizedFirst === normalizedSecond; -} - -function isSensitiveReadPath(filePath: string): boolean { - const tokens = filePath - .toLowerCase() - .split(SENSITIVE_PATH_TOKEN_RE) - .filter(Boolean); - return tokens.some( - (token) => - SENSITIVE_PATH_TOKENS.has(token) || - token === ".dev.vars" || - token === ".env" || - token.startsWith(".env."), - ); -} - -function readErrorCode(error: unknown): ReadFileErrorCode { - if (typeof error === "object" && error !== null && "code" in error) { - if (error.code === "ENOENT") { - return "not-found"; - } - if (error.code === "EISDIR") { - return "not-file"; - } - } - return "unreadable"; -} diff --git a/packages/cli/src/lib/init/tools/read-files.ts b/packages/cli/src/lib/init/tools/read-files.ts deleted file mode 100644 index 6499f3717..000000000 --- a/packages/cli/src/lib/init/tools/read-files.ts +++ /dev/null @@ -1,373 +0,0 @@ -import type fs from "node:fs"; -import { TextDecoder } from "node:util"; -import type { - ReadFileErrorCode, - ReadFilesPayload, - ReadFilesV2Data, - ReadFileV2Result, - ToolResult, -} from "../types.js"; -import { - normalizeProjectFilePath, - type OpenedProjectFile, - openProjectFile, - projectFileChanged, -} from "./project-file.js"; -import type { InitToolDefinition } from "./types.js"; - -const MAX_READ_OUTPUT_BYTES = 40_000; -const MAX_READ_PATHS = 20; -const FILE_READ_CHUNK_BYTES = 64 * 1024; -const PATH_SEGMENT_RE = /[/\\\\]/u; - -type ReadWindow = { - outputBytes: number; - startLine: number; -}; - -type LineScan = { - consumedBytes: number; - found: boolean; - skippedLines: number; -}; - -/** - * Read one or more files from the sandboxed project directory. - * - * Returns independent, bounded line ranges. Callers may issue another read or - * use grep when they need different evidence. The CLI keeps no continuation - * state between calls. - */ -export async function readFiles( - payload: ReadFilesPayload, -): Promise { - const validated = validateReadRequest(payload); - if ("error" in validated) { - return { error: validated.error, ok: false }; - } - const perFileOutputBytes = Math.max( - 1, - Math.floor(MAX_READ_OUTPUT_BYTES / validated.paths.length), - ); - const window = { - outputBytes: perFileOutputBytes, - startLine: validated.startLine, - } satisfies ReadWindow; - const results = await Promise.all( - validated.paths.map(async (filePath) => { - const result = await readSingleFileV2(payload.cwd, filePath, window); - return [filePath, result] as const; - }), - ); - - return { - data: { - files: Object.fromEntries(results), - version: 2, - } satisfies ReadFilesV2Data, - ok: true, - }; -} - -async function readSingleFileV2( - cwd: string, - filePath: string, - window: ReadWindow, -): Promise { - let opened: OpenedProjectFile | undefined; - try { - const result = await openProjectFile(cwd, filePath); - if ("error" in result) { - return { error: result.error, status: "error" }; - } - opened = result; - if (opened.stat.size === 0) { - if (projectFileChanged(opened.stat, await opened.handle.stat())) { - return { error: "unreadable", status: "error" }; - } - return window.startLine === 1 - ? { - content: "", - status: "ok", - truncated: false, - } - : { error: "invalid-range", status: "error" }; - } - - const page = await readTextPage(opened.handle, opened.stat.size, window); - if (projectFileChanged(opened.stat, await opened.handle.stat())) { - return { error: "unreadable", status: "error" }; - } - return page; - } catch (error) { - return { error: readErrorCode(error), status: "error" }; - } finally { - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. - await opened?.handle.close().catch(() => { - // Preserve the primary read result when descriptor cleanup fails. - }); - } -} - -async function readTextPage( - handle: fs.promises.FileHandle, - fileSize: number, - window: ReadWindow, -): Promise { - const located = await locateLineStart(handle, fileSize, window.startLine); - if ("error" in located) { - return { error: located.error, status: "error" }; - } - - const pageBytes = Math.min(window.outputBytes, fileSize - located.offset); - const page = Buffer.alloc(pageBytes); - const decoder = new TextDecoder("utf-8", { fatal: true }); - let bytesRead = 0; - while (bytesRead < pageBytes) { - const result = await handle.read( - page, - bytesRead, - pageBytes - bytesRead, - located.offset + bytesRead, - ); - if (result.bytesRead === 0) { - return { error: "unreadable", status: "error" }; - } - const nextBytesRead = bytesRead + result.bytesRead; - if ( - !isTextChunk( - decoder, - page.subarray(bytesRead, nextBytesRead), - located.offset + nextBytesRead < fileSize, - ) - ) { - return { error: "not-text", status: "error" }; - } - bytesRead = nextBytesRead; - } - - if (located.offset + bytesRead === fileSize) { - return { - content: page.toString("utf-8"), - status: "ok", - truncated: false, - }; - } - - const lastNewline = page.lastIndexOf(0x0a); - if (lastNewline === -1) { - return { error: "line-too-long", status: "error" }; - } - return { - content: page.subarray(0, lastNewline + 1).toString("utf-8"), - status: "ok", - truncated: true, - }; -} - -async function locateLineStart( - handle: fs.promises.FileHandle, - fileSize: number, - startLine: number, -): Promise< - { offset: number } | { error: "invalid-range" | "not-text" | "unreadable" } -> { - if (startLine === 1) { - return { offset: 0 }; - } - - const buffer = Buffer.alloc(Math.min(FILE_READ_CHUNK_BYTES, fileSize)); - const decoder = new TextDecoder("utf-8", { fatal: true }); - let linesToSkip = startLine - 1; - let position = 0; - while (position < fileSize) { - const result = await handle.read( - buffer, - 0, - Math.min(buffer.length, fileSize - position), - position, - ); - if (result.bytesRead === 0) { - return { error: "unreadable" }; - } - - const chunk = buffer.subarray(0, result.bytesRead); - const scan = scanLineBreaks(chunk, linesToSkip); - const nextPosition = position + scan.consumedBytes; - if ( - !isTextChunk( - decoder, - chunk.subarray(0, scan.consumedBytes), - !scan.found && nextPosition < fileSize, - ) - ) { - return { error: "not-text" }; - } - if (scan.found) { - return nextPosition < fileSize - ? { offset: nextPosition } - : { error: "invalid-range" }; - } - linesToSkip -= scan.skippedLines; - position += result.bytesRead; - } - return { error: "invalid-range" }; -} - -function scanLineBreaks(buffer: Buffer, linesToSkip: number): LineScan { - let offset = 0; - for (let skippedLines = 0; skippedLines < linesToSkip; skippedLines += 1) { - const newline = buffer.indexOf(0x0a, offset); - if (newline === -1) { - return { - consumedBytes: buffer.length, - found: false, - skippedLines, - }; - } - offset = newline + 1; - } - return { - consumedBytes: offset, - found: true, - skippedLines: linesToSkip, - }; -} - -function validateReadRequest( - payload: ReadFilesPayload, -): { error: string } | { paths: string[]; startLine: number } { - const params = (payload as { params?: unknown }).params; - if (typeof params !== "object" || params === null) { - return { error: "read-files params must be an object" }; - } - const { paths, resultVersion, startLine } = params as Partial< - ReadFilesPayload["params"] - >; - if (resultVersion !== 2) { - return { error: "read-files requires resultVersion 2" }; - } - const supportedKeys = new Set(["paths", "resultVersion", "startLine"]); - if (Object.keys(params).some((key) => !supportedKeys.has(key))) { - return { error: "read-files params include unsupported fields" }; - } - const normalizedPaths = normalizeReadPaths(paths); - if ("error" in normalizedPaths) { - return normalizedPaths; - } - if (isInvalidPositiveInteger(startLine)) { - return { - error: "read-files startLine must be a positive safe integer", - }; - } - const normalizedStartLine = startLine ?? 1; - if (normalizedStartLine > 1 && normalizedPaths.paths.length !== 1) { - return { error: "read-files range reads require exactly one path" }; - } - return { paths: normalizedPaths.paths, startLine: normalizedStartLine }; -} - -function normalizeReadPaths( - paths: unknown, -): { error: string } | { paths: string[] } { - if ( - !Array.isArray(paths) || - paths.length === 0 || - paths.length > MAX_READ_PATHS - ) { - return { - error: `read-files requires between 1 and ${MAX_READ_PATHS} paths`, - }; - } - if ( - paths.some( - (filePath) => - typeof filePath !== "string" || - filePath.length === 0 || - filePath.length > 1000, - ) - ) { - return { error: "read-files paths must be non-empty bounded strings" }; - } - const normalizedPaths = (paths as string[]).map(normalizeProjectFilePath); - if (normalizedPaths.some((filePath) => filePath === undefined)) { - return { - error: - "read-files paths must be portable filesystem-root-relative paths without aliases", - }; - } - const canonicalPaths = normalizedPaths as string[]; - if (new Set(canonicalPaths).size !== canonicalPaths.length) { - return { - error: "read-files paths must be unique after path normalization", - }; - } - return { paths: canonicalPaths }; -} - -function isInvalidPositiveInteger(value: unknown): boolean { - return ( - value !== undefined && - (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) - ); -} - -function isTextChunk( - decoder: TextDecoder, - buffer: Buffer, - hasMoreBytes: boolean, -): boolean { - if ( - buffer.some( - (byte) => - byte === 0x7f || (byte < 0x20 && ![0x09, 0x0a, 0x0d].includes(byte)), - ) - ) { - return false; - } - // 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 { - decoder.decode(buffer, { stream: hasMoreBytes }); - return true; - } catch { - return false; - } -} - -function readErrorCode(error: unknown): ReadFileErrorCode { - if (typeof error === "object" && error !== null && "code" in error) { - if (error.code === "ENOENT") { - return "not-found"; - } - if (error.code === "EISDIR") { - return "not-file"; - } - } - return "unreadable"; -} - -/** - * Tool definition for batched file reads. - */ -export const readFilesTool: InitToolDefinition<"read-files"> = { - operation: "read-files", - describe: (payload) => { - const [first, second] = payload.params.paths; - if (!first) { - return "Reading files..."; - } - if (!second && payload.params.paths.length === 1) { - return `Reading \`${pathBase(first)}\`...`; - } - if (payload.params.paths.length === 2 && second) { - return `Reading \`${pathBase(first)}\`, \`${pathBase(second)}\`...`; - } - return `Reading ${payload.params.paths.length} files (\`${pathBase(first)}\`${second ? `, \`${pathBase(second)}\`` : ""}, ...)...`; - }, - execute: readFiles, -}; - -function pathBase(filePath: string): string { - const parts = filePath.split(PATH_SEGMENT_RE); - return parts.at(-1) ?? filePath; -} diff --git a/packages/cli/src/lib/init/tools/registry.ts b/packages/cli/src/lib/init/tools/registry.ts deleted file mode 100644 index 8335efedd..000000000 --- a/packages/cli/src/lib/init/tools/registry.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { ApiError } from "../../errors.js"; -import type { ToolOperation, ToolPayload, ToolResult } from "../types.js"; -import { agentCheckpointTool } from "./agent-checkpoint.js"; -import { applyPatchsetTool } from "./apply-patchset.js"; -import { - createSentryProjectTool, - ensureSentryProjectTool, -} from "./create-sentry-project.js"; -import { detectSentryTool } from "./detect-sentry.js"; -import { fileExistsBatchTool } from "./file-exists-batch.js"; -import { globTool } from "./glob.js"; -import { grepTool } from "./grep.js"; -import { listDirTool } from "./list-dir.js"; -import { readFilesTool } from "./read-files.js"; -import { runCommandsTool } from "./run-commands.js"; -import { formatToolError, validateToolSandbox } from "./shared.js"; -import type { AnyInitToolDefinition, ToolContext } from "./types.js"; - -const toolDefinitions = [ - agentCheckpointTool, - listDirTool, - readFilesTool, - fileExistsBatchTool, - runCommandsTool, - applyPatchsetTool, - grepTool, - globTool, - createSentryProjectTool, - ensureSentryProjectTool, - detectSentryTool, -] as const satisfies readonly AnyInitToolDefinition[]; - -const toolRegistry = new Map( - toolDefinitions.map((tool) => [tool.operation, tool] as const), -); - -/** Sentry API operations never inspect or mutate the local filesystem. */ -const CWD_INDEPENDENT_OPERATIONS = new Set([ - "agent-checkpoint", - "create-sentry-project", - "ensure-sentry-project", -]); - -/** - * Build the spinner message for a suspended tool request. - */ -export function describeTool(payload: ToolPayload): string { - const tool = toolRegistry.get(payload.operation); - return tool ? tool.describe(payload as never) : `${payload.operation}...`; -} - -/** - * Execute a suspended tool request against the local filesystem/API context. - */ -export async function executeTool( - payload: ToolPayload, - context: ToolContext, -): Promise { - const tool = toolRegistry.get(payload.operation); - if (!tool) { - return { - ok: false, - error: `Unknown operation: ${(payload as { operation?: string }).operation ?? "unknown"}`, - }; - } - - let sandboxedPayload = payload; - if (!CWD_INDEPENDENT_OPERATIONS.has(payload.operation)) { - const sandbox = validateToolSandbox(payload, context.directory); - if ("ok" in sandbox) { - return sandbox; - } - sandboxedPayload = { ...payload, cwd: sandbox.cwd } as ToolPayload; - } - - try { - return await tool.execute(sandboxedPayload as never, context); - } catch (error) { - if ( - error instanceof ApiError && - (error.status === 401 || error.status === 403) - ) { - throw error; - } - return { ok: false, error: formatToolError(error) }; - } -} diff --git a/packages/cli/src/lib/init/tools/run-commands.ts b/packages/cli/src/lib/init/tools/run-commands.ts deleted file mode 100644 index 19d414579..000000000 --- a/packages/cli/src/lib/init/tools/run-commands.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { spawn } from "node:child_process"; -import { addBreadcrumb } from "@sentry/node"; -import { whichSync } from "../../which.js"; -import { DEFAULT_COMMAND_TIMEOUT_MS } from "../constants.js"; -import type { RunCommandsPayload, ToolResult } from "../types.js"; -import { - parseCommand, - readSpawnOutput, - validateCommand as validateToolCommand, -} from "./command-utils.js"; -import type { InitToolDefinition, ToolContext } from "./types.js"; - -const WINDOWS_BATCH_SHIM_RE = /\.(?:cmd|bat)$/iu; - -type SpawnCommand = { - executable: string; - args: string[]; - windowsVerbatimArguments?: true; -}; - -function isWindowsBatchShim(executable: string): boolean { - return process.platform === "win32" && WINDOWS_BATCH_SHIM_RE.test(executable); -} - -function quoteWindowsCommandArg(value: string): string { - let quoted = '"'; - let backslashes = 0; - - for (const char of value) { - if (char === "\\") { - backslashes += 1; - continue; - } - - if (char === '"') { - quoted += "\\".repeat(backslashes * 2); - quoted += '""'; - backslashes = 0; - continue; - } - - quoted += "\\".repeat(backslashes); - quoted += char; - backslashes = 0; - } - - quoted += "\\".repeat(backslashes * 2); - quoted += '"'; - return quoted; -} - -function buildWindowsBatchCommand(executable: string, args: string[]): string { - const commandLine = [executable, ...args] - .map(quoteWindowsCommandArg) - .join(" "); - - // cmd.exe /s strips the outer quote pair, leaving a quoted exe + argv. - return `"${commandLine}"`; -} - -/** - * Validate and execute a batch of commands. - */ -export async function runCommands( - payload: RunCommandsPayload, - context: Pick, -): Promise { - const timeoutMs = payload.params.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; - const parsedCommands: ReturnType[] = []; - - for (const command of payload.params.commands) { - const validationError = validateToolCommand(command); - if (validationError) { - return { ok: false, error: validationError }; - } - parsedCommands.push(parseCommand(command)); - } - - const results: Array<{ - command: string; - exitCode: number; - stdout: string; - stderr: string; - }> = []; - - for (const command of parsedCommands) { - if (context.dryRun) { - results.push({ - command: command.original, - exitCode: 0, - stdout: "(dry-run: skipped)", - stderr: "", - }); - continue; - } - - const result = await runSingleCommand(command, payload.cwd, timeoutMs); - results.push(result); - if (result.exitCode !== 0) { - addBreadcrumb({ - level: "error", - message: `Command failed: ${command.original}`, - data: { - exitCode: result.exitCode, - stdout: result.stdout.slice(0, 500), - stderr: result.stderr.slice(0, 500), - cwd: payload.cwd, - }, - }); - return { - ok: false, - error: `Command "${command.original}" failed with exit code ${result.exitCode}: ${result.stderr}`, - data: { results }, - }; - } - } - - return { ok: true, data: { results } }; -} - -async function runSingleCommand( - command: ReturnType, - cwd: string, - timeoutMs: number, -): Promise<{ - command: string; - exitCode: number; - stdout: string; - stderr: string; -}> { - const executable = whichSync(command.executable) ?? command.executable; - const spawnCommand: SpawnCommand = isWindowsBatchShim(executable) - ? { - executable: process.env.ComSpec ?? "cmd.exe", - args: [ - "/d", - "/s", - "/c", - buildWindowsBatchCommand(executable, command.args), - ], - windowsVerbatimArguments: true, - } - : { executable, args: command.args }; - - try { - const child = spawn(spawnCommand.executable, spawnCommand.args, { - cwd, - shell: false, - stdio: ["ignore", "pipe", "pipe"], - ...(spawnCommand.windowsVerbatimArguments - ? { windowsVerbatimArguments: true } - : {}), - }); - const exited = new Promise((resolve) => { - child.on("close", (code) => resolve(code ?? 1)); - child.on("error", () => resolve(1)); - }); - let timedOut = false; - const timer = globalThis.setTimeout(() => { - timedOut = true; - child.kill(); - }, timeoutMs); - - const [exitCode, stdout, stderr] = await Promise.all([ - exited, - readSpawnOutput(child.stdout), - readSpawnOutput(child.stderr), - ]); - clearTimeout(timer); - - return { - command: command.original, - exitCode: timedOut ? 1 : exitCode, - stdout, - stderr: timedOut - ? stderr || `Command timed out after ${timeoutMs}ms` - : stderr, - }; - } catch (error) { - return { - command: command.original, - exitCode: 1, - stdout: "", - stderr: error instanceof Error ? error.message : String(error), - }; - } -} - -/** - * Tool definition for sandboxed command execution. - */ -export const runCommandsTool: InitToolDefinition<"run-commands"> = { - operation: "run-commands", - describe: (payload) => { - const [first] = payload.params.commands; - if (payload.params.commands.length === 1 && first) { - return `Running \`${first}\`...`; - } - return `Running ${payload.params.commands.length} commands (\`${first ?? "..."}\`, ...)...`; - }, - execute: runCommands, -}; diff --git a/packages/cli/src/lib/init/tools/shared.ts b/packages/cli/src/lib/init/tools/shared.ts deleted file mode 100644 index 4cfecf718..000000000 --- a/packages/cli/src/lib/init/tools/shared.ts +++ /dev/null @@ -1,99 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { ApiError } from "../../errors.js"; -import type { ToolPayload, ToolResult } from "../types.js"; - -/** - * Resolve a path relative to cwd and verify it stays inside the project root. - */ -export function safePath(cwd: string, relative: string): string { - const resolved = path.resolve(cwd, relative); - const normalizedCwd = path.resolve(cwd); - if ( - !resolved.startsWith(normalizedCwd + path.sep) && - resolved !== normalizedCwd - ) { - throw new Error(`Path "${relative}" resolves outside project directory`); - } - - let realCwd: string; - // 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 { - realCwd = fs.realpathSync(normalizedCwd); - } catch { - return resolved; - } - - let checkPath = resolved; - for (;;) { - try { - const real = fs.realpathSync(checkPath); - if (!real.startsWith(realCwd + path.sep) && real !== realCwd) { - throw new Error( - `Path "${relative}" resolves outside project directory via symlink`, - ); - } - break; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - - const parent = path.dirname(checkPath); - if (parent === checkPath) { - break; - } - checkPath = parent; - } - } - - return resolved; -} - -/** - * Resolve a tool cwd inside the selected project root, or reject it. - * - * The returned real path pins any safe cwd symlink before tool execution. - */ -export function validateToolSandbox( - payload: Pick, - directory: string, -): { cwd: string } | ToolResult { - // 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 realDirectory = fs.realpathSync(path.resolve(directory)); - const realCwd = fs.realpathSync(path.resolve(payload.cwd)); - const relativeCwd = path.relative(realDirectory, realCwd); - const isInsideDirectory = - relativeCwd === "" || - (relativeCwd !== ".." && - !relativeCwd.startsWith(`..${path.sep}`) && - !path.isAbsolute(relativeCwd)); - - if (isInsideDirectory && fs.statSync(realCwd).isDirectory()) { - // Execute against the resolved directory so a symlinked cwd cannot be - // retargeted between this boundary check and the tool implementation. - return { cwd: realCwd }; - } - } catch { - // Missing or unreadable roots fail closed at the shared tool boundary. - } - - return { - ok: false, - error: `Blocked: cwd "${payload.cwd}" is outside project directory "${directory}" or cannot be resolved`, - }; -} - -/** - * Format thrown tool errors into user-facing strings. - */ -export function formatToolError(error: unknown): string { - if (error instanceof ApiError) { - return error.format(); - } - if (error instanceof Error) { - return error.message; - } - return String(error); -} diff --git a/packages/cli/src/lib/init/tools/types.ts b/packages/cli/src/lib/init/tools/types.ts deleted file mode 100644 index 2f487539a..000000000 --- a/packages/cli/src/lib/init/tools/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { - ResolvedInitContext, - ToolOperation, - ToolPayload, - ToolResult, -} from "../types.js"; - -/** - * Client-side context available to init tools while the workflow is suspended. - */ -export type ToolContext = ResolvedInitContext; - -/** - * A single init tool implementation plus its user-facing spinner copy. - */ -export type InitToolDefinition = { - /** Stable operation name used in suspend payloads. */ - operation: TOperation; - /** Build a short spinner message for the current payload. */ - describe: ( - payload: Extract, - ) => string; - /** Execute the tool and return a resumable payload result. */ - execute: ( - payload: Extract, - context: ToolContext, - ) => Promise; -}; - -/** - * Union of all concrete init tool definitions. - */ -export type AnyInitToolDefinition = { - [Operation in ToolOperation]: InitToolDefinition; -}[ToolOperation]; diff --git a/packages/cli/src/lib/init/types.ts b/packages/cli/src/lib/init/types.ts deleted file mode 100644 index 965e09a88..000000000 --- a/packages/cli/src/lib/init/types.ts +++ /dev/null @@ -1,377 +0,0 @@ -export type DirEntry = { - name: string; - path: string; - type: "file" | "directory"; - /** File size in bytes; omitted for directories. Lets the agent skip huge - * files without reading them, like `ls -l`. */ - size?: number; -}; - -export type ExistingProjectData = { - orgSlug: string; - projectSlug: string; - projectId: string; - dsn: string; - url: string; - platform?: string; -}; - -export type WizardOptions = { - directory: string; - yes: boolean; - dryRun: boolean; - features?: string[]; - team?: string; - org?: string; - project?: string; - /** Pre-selected app name for monorepo runs. When set, skips the interactive - * app-selection prompt and uses this value directly. Required when `--yes` - * is passed against a monorepo with more than one detected app. */ - app?: string; - /** - * Force the non-Ink fallback (`LoggingUI`). Mapped from - * `--no-tui`. Acts as an escape hatch when the Ink TUI - * misbehaves; in an interactive run this effectively disables - * prompts (any prompt path will throw a `LoggingUIPromptError`), - * so users hitting this flag should also pass `--yes` or set - * every choice via flags. - */ - forceLegacyUi?: boolean; -}; - -export type ResolvedInitContext = { - directory: string; - yes: boolean; - dryRun: boolean; - features?: string[]; - org: string; - /** - * Resolved team slug for init operations. - * Omitted when init defers empty-org auto-creation until project creation. - */ - team?: string; - /** - * True only when `team` was supplied via the `--team` CLI flag. - * False/absent when the team was auto-selected by preflight. - * Used by project creation tools to decide whether to suppress the - * org-scoped fallback on 403 (only suppress for explicitly named teams). - */ - isExplicitTeam?: boolean; - project?: string; - /** Pre-selected app name for monorepo runs. Passed through from `--app`. */ - app?: string; - authToken?: string; - existingProject?: ExistingProjectData; -}; - -export type InteractiveContext = Pick< - ResolvedInitContext, - "yes" | "dryRun" | "app" ->; - -export type InitProtocolEnvelope = { - protocolVersion: 1; - requestId: string; -}; - -// Tool suspend payloads -export type ToolPayload = - | AgentCheckpointPayload - | ListDirPayload - | ReadFilesPayload - | FileExistsBatchPayload - | RunCommandsPayload - | ApplyPatchsetPayload - | GrepPayload - | GlobPayload - | CreateSentryProjectPayload - | EnsureSentryProjectPayload - | DetectSentryPayload; - -export type ToolOperation = ToolPayload["operation"]; - -/** - * Suspend request used to continue a persisted server-side agent session. - * The CLI acknowledges it without inspecting or mutating the local project. - */ -export type AgentCheckpointPayload = { - /** Discriminator for locally handled workflow tools. */ - type: "tool"; - /** Stable no-op operation name. */ - operation: "agent-checkpoint"; - /** Optional progress copy displayed while the checkpoint is acknowledged. */ - detail?: string; - /** Workflow filesystem root; not accessed by this operation. */ - cwd: string; - /** Reserved operation arguments; currently always empty. */ - params: Record; -}; - -/** Explicit no-op acknowledgement validated by the init service boundary. */ -export type AgentCheckpointData = { - acknowledged: true; -}; - -export type ListDirPayload = { - type: "tool"; - operation: "list-dir"; - cwd: string; - params: { - path: string; - recursive?: boolean; - maxDepth?: number; - maxEntries?: number; - }; -}; - -export type ReadFilesPayload = { - type: "tool"; - operation: "read-files"; - cwd: string; - params: { - paths: string[]; - /** First 1-based line to inspect. Range reads support one path. */ - startLine?: number; - /** Structured read-results protocol used by sentry init. */ - resultVersion: 2; - }; -}; - -export type ReadFileErrorCode = - | "invalid-range" - | "line-too-long" - | "not-file" - | "not-found" - | "not-text" - | "unreadable"; - -export type ReadFileV2Result = - | { - content: string; - status: "ok"; - truncated: boolean; - } - | { - error: ReadFileErrorCode; - status: "error"; - }; - -export type ReadFilesV2Data = { - files: Record; - version: 2; -}; - -export type FileExistsBatchPayload = { - type: "tool"; - operation: "file-exists-batch"; - cwd: string; - params: { - paths: string[]; - }; -}; - -export type RunCommandsPayload = { - type: "tool"; - operation: "run-commands"; - cwd: string; - params: { - commands: string[]; - timeoutMs?: number; - }; -}; - -export type GrepSearch = { - pattern: string; - path?: string; - include?: string; - /** - * Case-insensitive match. Default: false (case-sensitive, matching - * `rg`'s default). A leading `(?i)` inline flag in `pattern` has - * the same effect — callers can use either. - * - * No current Mastra server invocation sets this field; reserving - * it here means the server can start sending it without a CLI - * update. The underlying scan engine natively supports it. - */ - caseInsensitive?: boolean; - /** - * Multiline mode: when true (default), `^` and `$` match at line - * boundaries within the file — grep/rg semantics. When false, they - * anchor to the buffer start/end — strict JS `RegExp` semantics. - * Rarely needs to be set. - */ - multiline?: boolean; -}; - -export type GrepPayload = { - type: "tool"; - operation: "grep"; - cwd: string; - params: { - searches: GrepSearch[]; - maxResultsPerSearch?: number; - }; -}; - -export type GlobPayload = { - type: "tool"; - operation: "glob"; - cwd: string; - params: { - patterns: string[]; - path?: string; - maxResults?: number; - }; -}; - -export type PatchEdit = { - oldString: string; - newString: string; -}; - -export type ApplyPatchsetPatch = - | { path: string; action: "create"; patch: string } - | { path: string; action: "modify"; edits: PatchEdit[] } - | { path: string; action: "delete"; patch?: string }; - -/** - * Wire envelope for a file-change batch. - * - * The operation name remains stable for compatibility with deployed workflow - * servers and older CLI releases. - */ -export type ApplyPatchsetPayload = { - type: "tool"; - operation: "apply-patchset"; - cwd: string; - params: { - patches: ApplyPatchsetPatch[]; - }; -}; - -export type CreateSentryProjectPayload = { - type: "tool"; - operation: "create-sentry-project"; - /** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */ - detail?: string; - cwd: string; - params: { - name: string; - platform: string; - }; -}; - -export type EnsureSentryProjectPayload = { - type: "tool"; - operation: "ensure-sentry-project"; - /** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */ - detail?: string; - cwd: string; - params: { - name: string; - platform: string; - }; -}; - -export type DetectSentryPayload = { - type: "tool"; - operation: "detect-sentry"; - detail?: string; - cwd: string; - params: Record; -}; - -export type ToolResult = { - ok: boolean; - error?: string; - message?: string; - data?: unknown; -}; - -// Wizard output -export type WizardOutput = { - platform?: string; - projectDir?: string; - features?: string[]; - commands?: string[]; - changedFiles?: Array<{ action: string; path: string }>; - warnings?: string[]; - exitCode?: number; - docsUrl?: string; - sentryProjectUrl?: string; - orgSlug?: string; - projectSlug?: string; - projectId?: string; - message?: string; - featureBlurbs?: Array<{ feature: string; blurb: string }>; -}; - -/** - * Sentry project identity captured locally during the run. - * - * The CLI creates (or resolves) the Sentry project itself via the - * `create-sentry-project` / `ensure-sentry-project` tool, so it already knows - * the org/project identifiers first-hand — the server does not need to echo - * them back in the final output. Captured from the tool result in the wizard - * runner and used to build the Issues / event URLs on the completion screen. - */ -export type SentryProjectIdentity = { - orgSlug: string; - projectSlug: string; - projectId: string; - dsn?: string; - url?: string; -}; - -// Interactive payloads -export type InteractivePayload = - | SelectPayload - | MultiSelectPayload - | ConfirmPayload; - -export type SelectPayload = { - type: "interactive"; - kind: "select"; - prompt: string; - options?: string[]; - apps?: Array<{ name: string; path: string; framework?: string }>; -}; - -export type MultiSelectPayload = { - type: "interactive"; - kind: "multi-select"; - prompt: string; - availableFeatures?: string[]; - options?: string[]; -}; - -export type ConfirmPayload = { - type: "interactive"; - kind: "confirm"; - prompt: string; -}; - -export type SuspendPayload = (ToolPayload | InteractivePayload) & - InitProtocolEnvelope; - -export type WorkflowRunResult = { - status: "suspended" | "success" | "failed"; - suspended?: string[][]; - activeStepsPath?: Record; - steps?: Record< - string, - { - status?: - | "success" - | "failed" - | "suspended" - | "running" - | "waiting" - | "paused"; - suspendPayload?: unknown; - } - >; - suspendPayload?: unknown; - result?: WizardOutput; - error?: string; -}; diff --git a/packages/cli/src/lib/init/ui/factory.ts b/packages/cli/src/lib/init/ui/factory.ts deleted file mode 100644 index 34066ae24..000000000 --- a/packages/cli/src/lib/init/ui/factory.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * WizardUI Factory - * - * Picks the appropriate `WizardUI` implementation based on runtime - * environment and CLI flags. This is the single chokepoint for UI - * selection — every part of the init wizard goes through `getUIAsync()` - * rather than instantiating implementations directly. - * - * Selection priority (highest first): - * - * 1. `--yes` flag set, OR stdin/stdout is not a TTY — `LoggingUI` - * (CI / piped input). Prompt methods throw, so callers must - * pre-resolve every choice up-front. - * 2. `SENTRY_INIT_TUI=0` or `--no-tui` — `LoggingUI`. Acts as a debug - * escape hatch when the Ink path misbehaves. In an interactive - * context this means the wizard becomes effectively non-interactive - * (any prompt aborts), so users hitting this path will need to set - * every choice via flags or rely on auto-detection. - * 3. Default (interactive, no opt-out) — `InkUI`. Works on both the - * Bun binary (Ink embedded via `with { type: "file" }`) and the - * npm/Node distribution (self-contained ESM sidecar loaded via - * dynamic `import()`). Falls back to `LoggingUI` if the import - * fails for any reason. - * - * Implementation history: - * - PR 4: replaced `ClackUI` with `OpenTuiUI` as the default. - * - This PR: replaced `OpenTuiUI` with `InkUI`. OpenTUI's Zig - * bindings added ~10.7 MB to the compiled binary; Ink + React + - * companions add a fraction of that and use no native code. - */ - -import { LoggingUI } from "./logging-ui.js"; -import type { WelcomeOptions, WizardUI } from "./types.js"; - -/** - * Inputs that affect UI selection. Mirrors the relevant subset of - * `WizardOptions` so we don't drag the full type into the factory. - */ -export type UIFactoryOptions = { - /** True when `--yes` (or `--dry-run`, which implies non-interactive) is set. */ - yes: boolean; - /** - * Optional first Ink prompt to seed before `ink.render()` is called. - * This keeps the first painted frame from flashing the normal wizard - * shell before the welcome screen is ready. - */ - initialWelcome?: WelcomeOptions; - /** - * True when the user explicitly opted out of the new TUI via - * `--no-tui`. Forces `LoggingUI`. - */ - forceLegacy?: boolean; -}; - -/** - * Detect whether the current process can run an interactive prompt. - * Both stdin (read keystrokes) and stdout (render the prompt) must be - * TTYs. Piped input or output disqualifies us. - * - * Exported for the test suite. - */ -export function isInteractiveTerminal(): boolean { - return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY); -} - -/** - * Returns `true` when the `LoggingUI` should be used — i.e. we're in - * a non-interactive context, the user opted out of the TUI, or the - * env var override is set. - */ -function shouldUseLogging(opts: UIFactoryOptions): boolean { - if (process.env.SENTRY_INIT_TUI === "0") { - return true; - } - if (opts.forceLegacy) { - return true; - } - if (opts.yes) { - return true; - } - if (!isInteractiveTerminal()) { - return true; - } - return false; -} - -/** - * Async factory — picks `InkUI` for interactive runs, otherwise - * `LoggingUI`. Works on both the Bun binary and the npm/Node - * distribution (the Ink sidecar is self-contained ESM loaded via - * dynamic `import()`). Falls back to `LoggingUI` if the Ink import - * fails for any reason. - * - * Callers should treat the return value as an `AsyncDisposable` and - * use `await using ui = await getUIAsync(...)` to guarantee teardown - * on every exit path. - */ -export async function getUIAsync(opts: UIFactoryOptions): Promise { - if (shouldUseLogging(opts)) { - return new LoggingUI(); - } - // 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 { createInkUI } = await import("./ink-ui.js"); - return await createInkUI({ initialWelcome: opts.initialWelcome }); - } catch { - // Fall through to LoggingUI so a missing/broken sidecar - // doesn't take down the wizard. Unreachable on a correctly - // built package — safety net for corrupted installs. - return new LoggingUI(); - } -} diff --git a/packages/cli/src/lib/init/ui/file-tree.ts b/packages/cli/src/lib/init/ui/file-tree.ts deleted file mode 100644 index 5b74537a9..000000000 --- a/packages/cli/src/lib/init/ui/file-tree.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Changed-files tree builder. - * - * Both `InkUI`'s React `` / `` and - * `LoggingUI.summary()` (plus the post-dispose chalk report) want a - * nested directory tree view of the wizard's changed files — - * collapses common prefixes and makes the actual scope of edits - * visible at a glance. - * - * The pre-React formatter built this with `colorTag()` markdown tags - * (`+`); the TUI couldn't render those because the - * text renderer stripped ANSI/markdown. Keeping the tree as pure - * data plus a flat render-list lets each renderer attach its own - * colors / box-drawing. - */ - -export type ChangedFile = { - action: string; - path: string; -}; - -/** - * One entry in the read-files tree. `status` mirrors the - * `FileReadEntry.status` shape from the wizard store so the Ink - * `FilesPanel` can render an at-a-glance icon per row. - */ -export type ReadFile = { - path: string; - status: "reading" | "analyzed"; -}; - -export type FileTreeNode = { - /** Path segment for this node (e.g. "src", "router.tsx"). */ - name: string; - /** - * Full file path relative to the project root. Set only on leaf - * (file) nodes. Directory nodes leave this `undefined`. - */ - path?: string; - /** Action recorded by the workflow — only on leaf nodes. */ - action?: string; - /** - * Read-progress status for the leaf — only set when the tree is - * built from read entries (vs. changed files, which carry `action` - * instead). Mutually exclusive with {@link FileTreeNode.action} in - * practice; consumers branch on whichever is populated. - */ - status?: "reading" | "analyzed"; - children: FileTreeNode[]; -}; - -/** - * Flat row produced by `flattenTree()` — one per visible line in the - * rendered output. Carries everything a renderer needs to draw a - * single row without re-walking the tree. - */ -export type FileTreeRow = { - /** Box-drawing prefix for ancestor pipes (e.g. `"│ │ "`). */ - prefix: string; - /** Branch glyph for this row — `"├─"` or `"└─"`. */ - branch: string; - /** - * `"file"` if this row represents a leaf (with action + path); - * `"directory"` otherwise. Renderers use this to decide whether to - * draw the action glyph cell. - */ - kind: "file" | "directory"; - /** Display name. Directories get a trailing `/`. */ - label: string; - /** Full path — only set on `file` rows. */ - path?: string; - /** Action — only set on `file` rows from a changed-files tree. */ - action?: string; - /** - * Read-progress status — only set on `file` rows from a read-files - * tree. Mutually exclusive with `action` in practice. - */ - status?: "reading" | "analyzed"; -}; - -function splitPath(filePath: string): string[] { - return filePath - .replaceAll("\\", "/") - .split("/") - .filter((segment) => segment.length > 0); -} - -/** - * Build a directory tree from the flat changed-files list. Files - * sharing a common prefix collapse into nested directories. - */ -export function buildFileTree(files: ChangedFile[]): FileTreeNode { - const root: FileTreeNode = { name: "", children: [] }; - - // Maintain a parallel map keyed by parent reference so we can do - // O(1) lookups for "does this directory already have a child named - // X?" without scanning each parent's children array. - const childIndex = new WeakMap>(); - childIndex.set(root, new Map()); - - for (const file of files) { - const parts = splitPath(file.path); - let current = root; - - for (const [index, part] of parts.entries()) { - const map = childIndex.get(current) ?? new Map(); - let child = map.get(part); - if (!child) { - child = { name: part, children: [] }; - map.set(part, child); - childIndex.set(current, map); - childIndex.set(child, new Map()); - current.children.push(child); - } - - if (index === parts.length - 1) { - child.path = file.path; - child.action = file.action; - } - - current = child; - } - } - - sortRecursive(root); - return root; -} - -/** - * Sort the tree in place: directories before files at each level, - * then alphabetical within each group. Matches the legacy formatter's - * ordering so existing screenshots/snapshots stay valid. - */ -function sortRecursive(node: FileTreeNode): void { - node.children.sort((left, right) => { - const leftIsDir = left.children.length > 0 && !left.action; - const rightIsDir = right.children.length > 0 && !right.action; - if (leftIsDir !== rightIsDir) { - return leftIsDir ? -1 : 1; - } - return left.name.localeCompare(right.name); - }); - for (const child of node.children) { - sortRecursive(child); - } -} - -/** - * Walk the tree and emit one {@link FileTreeRow} per line, ready to - * be fed into a renderer. Directory nodes appear before their - * children with the appropriate box-drawing prefix. - */ -export function flattenTree(root: FileTreeNode): FileTreeRow[] { - const rows: FileTreeRow[] = []; - walk(root.children, "", rows); - return rows; -} - -function walk( - nodes: FileTreeNode[], - prefix: string, - rows: FileTreeRow[], -): void { - for (const [index, node] of nodes.entries()) { - const isLast = index === nodes.length - 1; - rows.push(rowFor(node, prefix, isLast)); - if (node.children.length > 0) { - const childPrefix = `${prefix}${isLast ? " " : "│ "}`; - walk(node.children, childPrefix, rows); - } - } -} - -function rowFor( - node: FileTreeNode, - prefix: string, - isLast: boolean, -): FileTreeRow { - // Files are leaves that carry either a change `action` (from - // `buildFileTree`) or a read `status` (from `buildReadTree`). A - // node with neither but a `path` set is also a file — covers - // future tree builders that don't tag leaves. - const isFile = - Boolean(node.action) || - Boolean(node.status) || - (node.path !== undefined && node.children.length === 0); - return { - prefix, - branch: isLast ? "└─" : "├─", - kind: isFile ? "file" : "directory", - label: isFile ? node.name : `${node.name}/`, - ...(node.path !== undefined ? { path: node.path } : {}), - ...(node.action !== undefined ? { action: node.action } : {}), - ...(node.status !== undefined ? { status: node.status } : {}), - }; -} - -/** - * Build a directory tree from the wizard's read-files list. Mirrors - * {@link buildFileTree} but tags leaves with `status` instead of - * `action`. - * - * Insertion order is preserved (no sort) so newly-read files always - * land at the bottom of their parent directory — gives the Ink - * `FilesPanel`'s tail-window viewport a stable "tail -f" feel. - */ -export function buildReadTree(files: ReadFile[]): FileTreeNode { - const root: FileTreeNode = { name: "", children: [] }; - const childIndex = new WeakMap>(); - childIndex.set(root, new Map()); - - for (const file of files) { - const parts = splitPath(file.path); - let current = root; - - for (const [index, part] of parts.entries()) { - const map = childIndex.get(current) ?? new Map(); - let child = map.get(part); - if (!child) { - child = { name: part, children: [] }; - map.set(part, child); - childIndex.set(current, map); - childIndex.set(child, new Map()); - current.children.push(child); - } - - if (index === parts.length - 1) { - child.path = file.path; - child.status = file.status; - } - - current = child; - } - } - - // Deliberately no `sortRecursive(root)` — keep insertion order so - // sticky-bottom scrollbox tracking feels right. - return root; -} diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx deleted file mode 100644 index 8d6157871..000000000 --- a/packages/cli/src/lib/init/ui/ink-app.tsx +++ /dev/null @@ -1,3255 +0,0 @@ -/** - * InkUI React App — Full-Screen Wizard - * - * Renders the wizard in alternate-screen mode using Ink. The layout - * fills the terminal: - * - * ┌─ ◆ Sentry Init Wizard ──────────────────── sentry.io ─┐ - * │ │ - * │ ╔═══╗ │ ╭ Tasks ────── 2/8 ──────╮ │ - * │ ║ S ║ Sentry banner │ │ ◼ Analyze project │ │ - * │ ╚═══╝ │ │ ▶ Apply changes │ │ - * │ ● log line │ ╰────────────────────────╯ │ - * │ ▲ log line │ │ - * │ │ ╭ Did you know? ─────────╮ │ - * │ ◐ spinner... │ │ Errors → context → fix │ │ - * │ [PromptArea] │ │ │ │ - * │ │ ╰────────────────────────╯ │ - * │ ● Status Files │ - * │ ←→ switch tab │ - * │ Sentry $ sentry cli feedback... │ - * └─────────────────────────────────────────────────────────┘ - * - * Tab 1 (Status): Banner + logs + spinner + prompts + summary - * Tab 2 (Files): Scrollable file read tree - */ - -import { Box, render as inkRender, Text } from "ink"; -import Spinner from "ink-spinner"; -import { - createElement, - useCallback, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import stringWidth from "string-width"; -import wrapAnsi from "wrap-ansi"; -import { - type BannerLine, - bannerLinesForWidth, - bannerLinesWidth, -} from "../../banner.js"; -import { - buildFileTree, - buildReadTree, - type FileTreeRow, - flattenTree, -} from "./file-tree.js"; -import { - type FrameTab, - getInkFrameMargin, - getInkFrameWidth, - InitRenderBoundary, - ShortcutFooter, - TabFooter, - useInkFrameSize, -} from "./ink-frame.js"; -import { - type ShortcutBinding, - ShortcutHintProvider, - useInkShortcuts, -} from "./ink-shortcuts.js"; -import { BLOCK_LINE_COUNT, LEARN_SEQUENCE } from "./learn-content.js"; -import { - filterSelectOptions, - removeLastFilterCharacter, - sanitizeSelectFilterInput, -} from "./select-filter.js"; -import { SENTRY_TIPS, type SentryTip } from "./sentry-tips.js"; -import type { - CompletionActions, - PromptDetail, - WizardCompletion, - WizardSummary, -} from "./types.js"; -import type { - ActivePrompt, - FileReadEntry, - LearnState, - LogEntry, - LogSeverity, - SpinnerState, - StepEntry, - WizardStore, -} from "./wizard-store.js"; - -// ──────────────────────────── Visual constants ──────────────────────── - -/** Sentry blurple — primary brand accent. */ -const ACCENT = "#7553FF"; -/** De-emphasized text that still clears dark terminal backgrounds. */ -const MUTED = "#898294"; -/** Lowest-contrast treatment for borders, counters, and supporting details. */ -const MUTED_DIM = "#68616F"; -/** Sentry purple — spinners, in-progress states. */ -const PRIMARY = "#8B6AC8"; - -const COLOR_INFO = "#9C84D4"; -const COLOR_WARN = "#FDB81B"; -const COLOR_ERROR = "#fe4144"; -const COLOR_SUCCESS = "#83da90"; -const ACTIVE_TASK_PULSE_INTERVAL_MS = 600; - -const ICON_BY_SEVERITY: Record = - { - info: { glyph: "●", color: COLOR_INFO }, - warn: { glyph: "▲", color: COLOR_WARN }, - error: { glyph: "✖", color: COLOR_ERROR }, - success: { glyph: "✔", color: COLOR_SUCCESS }, - message: { glyph: " " }, - }; - -const ICONS = { - diamond: "\u25C6", - diamondOpen: "\u25C7", - separator: "\u250A", - verticalLine: "\u2502", - squareFilled: "\u25FC", - squareOpen: "\u25FB", - triangleRight: "\u25B6", - triangleRightOutline: "\u25B7", - triangleSmallRight: "\u25B8", - bullet: "\u2022", -} as const; - -const DEFAULT_WELCOME_OPTIONS = { - title: "Sentry Init", - body: [ - "We'll use AI to inspect this project and configure Sentry.", - "You'll choose the setup before local files change.", - ], - punchline: "Continue to let Sentry use AI for setup.", -}; -const FEEDBACK_BANNER_TEXT = '$ sentry cli feedback "what worked or broke"'; -const FEEDBACK_BANNER_FG = "#FFFFFF"; - -function getIntroTopPadding(rows: number): number { - return Math.min(6, Math.max(1, Math.floor(rows * 0.18))); -} - -function truncateForBanner(text: string, maxLength: number): string { - if (text.length <= maxLength) { - return text; - } - if (maxLength <= 3) { - return text.slice(0, maxLength); - } - return `${text.slice(0, maxLength - 3)}...`; -} - -function formatBannerBrand(cliVersion: string | null): string { - return cliVersion ? `Sentry v${cliVersion}` : "Sentry"; -} - -/** @internal Exported for testing. */ -export function formatFeedbackBanner( - width: number, - cliVersion: string | null, -): string { - const brand = formatBannerBrand(cliVersion); - const left = ` ${brand}`; - if (width <= left.length) { - return left.slice(0, Math.max(0, width)); - } - - const rightPadding = 1; - const maxRight = Math.max(0, width - left.length - rightPadding - 1); - const clippedRight = truncateForBanner(FEEDBACK_BANNER_TEXT, maxRight); - if (clippedRight.length === 0) { - return left.padEnd(width, " "); - } - - const spacerWidth = Math.max( - 1, - width - left.length - clippedRight.length - rightPadding, - ); - return `${left}${" ".repeat(spacerWidth)}${clippedRight}${" ".repeat(rightPadding)}`; -} - -// ────────────────────────────── App entry ───────────────────────────── - -export type AppProps = { - store: WizardStore; -}; - -export function App({ store }: AppProps): React.ReactNode { - return ( - - - - ); -} - -function AppBody({ store }: AppProps): React.ReactNode { - const snapshot = useSyncExternalStore( - store.subscribe, - store.getSnapshot, - store.getSnapshot, - ); - const { columns, rows } = useInkFrameSize(); - const [activeTab, setActiveTab] = useState(0); - - const width = getInkFrameWidth(columns); - const contentHeight = Math.max(5, rows - 4); - const isWide = width >= 80; - - const tabs = useMemo( - () => [ - { id: "status", label: "Status" }, - { id: "files", label: "Files" }, - ], - [], - ); - - const appShortcuts = useMemo(() => { - const bindings: ShortcutBinding[] = [ - { - key: "ctrl+c", - action: "cancel", - priority: 0, - showInFooter: false, - match: (input, key) => key.ctrl && input === "c", - run: () => snapshot.requestCancel?.(), - }, - { - key: "\u2190\u2192", - action: "switch tab", - priority: 10, - match: (_input, key) => key.leftArrow || key.rightArrow, - run: (_input, key) => { - if (key.leftArrow) { - setActiveTab((prev) => Math.max(0, prev - 1)); - } - if (key.rightArrow) { - setActiveTab((prev) => Math.min(tabs.length - 1, prev + 1)); - } - }, - }, - ]; - return bindings; - }, [snapshot.requestCancel, tabs.length]); - useInkShortcuts("init-app", appShortcuts, { - isActive: - snapshot.layout === "workflow" && - snapshot.prompt === null && - snapshot.outroState === null, - }); - - if (snapshot.outroState?.kind === "success") { - return ( - - - - ); - } - - if (snapshot.layout === "intro" || snapshot.prompt?.kind === "welcome") { - const inner = ( - - - - - - - ); - return ( - {inner} - ); - } - - const inner = ( - - - - - - {activeTab === 0 ? ( - - ) : ( - - )} - - {isWide ? ( - - ) : null} - - - {snapshot.overlay ? ( - - ) : null} - - - - - - - - ); - - return ( - {inner} - ); -} - -// ────────────────────────────── Sidebar ─────────────────────────────── - -function Sidebar({ - learnState, - steps, - terminalRows, - tipIndex, -}: { - learnState: LearnState; - steps: StepEntry[]; - terminalRows: number; - tipIndex: number; -}): React.ReactNode { - const showTips = terminalRows >= 24; - return ( - - - {showTips ? ( - <> - - {learnState.complete ? ( - - ) : ( - - )} - - ) : null} - - ); -} - -// ─────────────────────────── Activity Pane ──────────────────────────── - -function ActivityPane({ - logs, - spinner, - prompt, - summary, - terminalRows, -}: { - logs: LogEntry[]; - spinner: SpinnerState; - prompt: ActivePrompt | null; - summary: WizardSummary | null; - terminalRows: number; -}): React.ReactNode { - const promptLogs = logs.filter( - (log) => log.severity === "warn" || log.severity === "error", - ); - // The shortest supported frame can keep one actionable log above a prompt; - // roomier terminals retain the complete warning/error history. - let visibleLogs = logs; - if (prompt) { - visibleLogs = terminalRows <= 16 ? promptLogs.slice(-1) : promptLogs; - } - const hasContent = - visibleLogs.length > 0 || - spinner.active || - prompt !== null || - summary !== null; - - return ( - - {hasContent ? null : ( - - - - - - Initializing wizard... - - - )} - {visibleLogs.length > 0 ? ( - - {visibleLogs.map((log) => ( - - ))} - - ) : null} - {spinner.active ? : null} - {summary ? : null} - {prompt ? ( - - ) : null} - - ); -} - -// ─────────────────────────── Files Screen ───────────────────────────── - -function FilesScreen({ - filesRead, - hasActivePrompt, - terminalRows, -}: { - filesRead: FileReadEntry[]; - hasActivePrompt: boolean; - terminalRows: number; -}): React.ReactNode { - if (filesRead.length === 0) { - return ( - - No files read yet... - - ); - } - - return ( - - - - ); -} - -// ──────────────────────────── Outro Screen ──────────────────────────── - -// ──────────────────────────── Overlay ───────────────────────────────── - -function OverlayPanel({ - overlay, -}: { - overlay: NonNullable; -}): React.ReactNode { - return ( - - - ▲ - {overlay.message} - - {overlay.retryCount > 0 ? ( - - Retry {overlay.retryCount}... - - ) : null} - - ); -} - -// ──────────────────────────── Components ────────────────────────────── - -type ChoiceRow = { - value: T; - label: string; - hint?: string; -}; - -function useChoiceNavigation({ - choices, - onChoose, - onCancel, - scope, -}: { - choices: ChoiceRow[]; - onChoose: (value: T) => void; - onCancel: () => void; - scope: string; -}): number { - const [highlighted, setHighlighted] = useState(0); - const totalCount = choices.length; - - const shortcuts = useMemo( - () => [ - { - key: "\u2191\u2193", - action: "navigate", - priority: 40, - match: (_input, key) => key.upArrow || key.downArrow, - run: (_input, key) => { - if (key.upArrow) { - setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); - return; - } - setHighlighted((idx) => (idx + 1) % totalCount); - }, - }, - { - key: "enter", - action: "select", - priority: 41, - match: (_input, key) => key.return, - run: () => { - const current = choices[highlighted]; - if (current) { - onChoose(current.value); - } - }, - }, - { - key: "esc", - action: "cancel", - priority: 42, - match: (input, key) => key.escape || (key.ctrl && input === "c"), - run: onCancel, - }, - ], - [choices, highlighted, onCancel, onChoose, totalCount], - ); - useInkShortcuts(scope, shortcuts); - - return highlighted; -} - -function ActionList({ - centered = false, - choices, - highlighted, -}: { - centered?: boolean; - choices: ChoiceRow[]; - highlighted: number; -}): React.ReactNode { - const listWidth = centered ? "100%" : undefined; - return ( - - {choices.map((choice, index) => { - const isCursor = index === highlighted; - if (centered) { - return ( - - - {isCursor ? `${ICONS.triangleRight} ` : " "} - - {choice.label} - {choice.hint ? {choice.hint} : null} - - ); - } - return ( - - - {isCursor ? ICONS.triangleRight : " "} - - {choice.label} - {choice.hint ? {choice.hint} : null} - - ); - })} - - ); -} - -function FeedbackBanner({ - cliVersion, - width, -}: { - cliVersion: string | null; - width: number; -}): React.ReactNode { - return ( - - - {formatFeedbackBanner(width, cliVersion)} - - - ); -} - -// ────────────────────────── Completion Screen ───────────────────────── - -type CompletionActionId = "issues" | "agent" | "finish"; - -/** Build the "What's next?" menu from the completion data + current state. - * Pick one, run it, and come back — the plugin item shows its queued state so - * doing several reads clearly. */ -function buildCompletionMenu({ - agentCommand, - agentQueued, - completion, - primaryUrl, -}: { - agentCommand: string | undefined; - agentQueued: boolean; - completion: WizardCompletion | null; - primaryUrl: string | undefined; -}): ChoiceRow[] { - const list: ChoiceRow[] = []; - if (primaryUrl) { - list.push( - completion?.verification.eventUrl - ? { - value: "issues", - label: "View my first event", - hint: "opens the exact event", - } - : { - value: "issues", - label: "Open my Issues feed", - hint: "see your first error", - }, - ); - } - if (agentCommand) { - list.push({ - value: "agent", - label: agentQueued - ? "Agent plugin — queued ✓" - : "Install the Sentry agent plugin", - hint: agentQueued ? "runs when you finish" : agentCommand, - }); - } - list.push({ value: "finish", label: "Finish" }); - return list; -} - -/** - * The interactive exit screen. Speaks the same visual language as the rest of - * init — no boxed hero, glyph-aligned rows, and the prompt option idiom — with - * a clear hierarchy: - * - what happened (Sentry installed, what was enabled), unnumbered context, - * 1. see your first error — the focal step, - * 2. next steps (MCP, agent plugin) as a driveable menu. - */ -function CompletionScreen({ - actions, - cliVersion, - columns, - completion, - onDismiss, - postExitActions, - rows, - store, - width, -}: { - actions: CompletionActions; - cliVersion: string | null; - columns: number; - completion: WizardCompletion | null; - onDismiss: () => void; - postExitActions: string[]; - rows: number; - store: WizardStore; - width: number; -}): React.ReactNode { - const [note, setNote] = useState(""); - - const verified = completion?.verification.received ?? false; - const primaryUrl = completion?.verification.eventUrl ?? completion?.issuesUrl; - const agentCommand = completion?.agentInstallCommand; - const agentQueued = agentCommand - ? postExitActions.includes(agentCommand) - : false; - - const menuChoices = useMemo( - () => - buildCompletionMenu({ - agentCommand, - agentQueued, - completion, - primaryUrl, - }), - [agentCommand, agentQueued, completion, primaryUrl], - ); - - const onMenuChoose = useCallback( - (value: CompletionActionId) => { - switch (value) { - case "issues": - if (primaryUrl) { - actions.openUrl(primaryUrl); - actions.track("open-sentry"); - setNote("Opened Sentry in your browser."); - } - break; - case "agent": - if (agentCommand) { - if (agentQueued) { - store.dequeuePostExitAction(agentCommand); - actions.track("agent-plugin-unqueued"); - setNote(""); - } else { - store.queuePostExitAction(agentCommand); - actions.track("agent-plugin-queued"); - setNote(`Queued — ${agentCommand} runs when you finish.`); - } - } - break; - case "finish": - onDismiss(); - break; - default: - break; - } - }, - [actions, agentCommand, agentQueued, onDismiss, primaryUrl, store], - ); - - // `o` opens the first-error link directly (the hint next to it), separate - // from arrowing to "Open my Issues feed" in the menu. - const openShortcuts = useMemo( - () => - primaryUrl - ? [ - { - key: "o", - action: "open", - priority: 45, - match: (input, key) => - !key.ctrl && (input === "o" || input === "O"), - run: () => { - actions.openUrl(primaryUrl); - actions.track("open-sentry"); - setNote("Opened Sentry in your browser."); - }, - }, - ] - : [], - [actions, primaryUrl], - ); - useInkShortcuts("completion-open", openShortcuts); - - return ( - - - - - - - {note ? ( - - - - {ICON_BY_SEVERITY.success.glyph} - - - - {note} - - - ) : null} - - - - - - ); -} - -/** - * A ` Title` section header. The `marker` numbers the two action - * sections ("1", "2") so they read as a sequence; the passive "what we set up" - * context passes a blank marker. Colour encodes importance: omit `color` for - * the focal steps (bright white), `MUTED_DIM` for the supporting context. - */ -function CompletionSectionHeader({ - color, - marker, - title, -}: { - color?: string; - marker: string; - title: string; -}): React.ReactNode { - return ( - - - - {marker} - - - - {title} - - - ); -} - -/** - * The ✔ headline. When there's a feature list to show it flows straight into - * it ("Sentry is set up in , with:"), so the list needs no separate - * subheader. The ✔ shares the glyph column with the numbered steps below. - */ -function CompletionHeader({ - completion, -}: { - completion: WizardCompletion | null; -}): React.ReactNode { - const projectName = completion?.projectName ?? "your project"; - const hasSetup = - (completion?.featureBlurbs.length ?? 0) > 0 || - (completion?.features.length ?? 0) > 0; - return ( - - - - {ICON_BY_SEVERITY.success.glyph} - - - Sentry is set up in - - {projectName} - - {hasSetup ? , with: : null} - - ); -} - -/** "4 files changed" — the quiet footnote under the feature list. */ -function completionSummaryText( - completion: WizardCompletion | null, -): string | null { - const n = completion?.changedFileCount ?? 0; - if (n <= 0) { - return null; - } - return `${n} ${n === 1 ? "file" : "files"} changed`; -} - -/** - * The project info under the header's "…set up in , with:" lead-in: - * one small line per enabled feature (AI blurbs when available, plain labels - * otherwise) plus a changed-files footnote. - */ -function CompletionWhatWeSetUp({ - completion, -}: { - completion: WizardCompletion | null; -}): React.ReactNode { - if (!completion) { - return null; - } - const { featureBlurbs, features } = completion; - const footnote = completionSummaryText(completion); - if (featureBlurbs.length === 0 && features.length === 0 && !footnote) { - return null; - } - return ( - - - {featureBlurbs.length > 0 - ? featureBlurbs.map(({ label, blurb }) => ( - - - - {label} - - - - - {blurb} - - - - )) - : features.map((label) => ( - - {label} - - ))} - {footnote ? {footnote} : null} - - - ); -} - -/** Copy for the first-error section, by state. */ -function firstErrorInstruction( - verified: boolean, - startCommand: string | undefined, -): string { - if (verified) { - return "Your app is already sending events — here's the one you just sent:"; - } - if (startCommand) { - return `Run ${startCommand}, make it throw an error, and it lands here:`; - } - return "Run your app, make it throw an error, and it lands here:"; -} - -/** The one thing to do next — the emphasized (ACCENT) section. */ -function CompletionFirstError({ - completion, - verified, -}: { - completion: WizardCompletion | null; - verified: boolean; -}): React.ReactNode { - const primaryUrl = completion?.verification.eventUrl ?? completion?.issuesUrl; - const instruction = firstErrorInstruction(verified, completion?.startCommand); - return ( - - - - - {instruction} - - {primaryUrl ? ( - - {"→ "} - - {primaryUrl} - - {" (o) to open"} - - ) : null} - - - ); -} - -/** Option rows for the completion menu: a bold ▶ cursor (bigger than the - * prompt's small ▸ so the selection is unmistakable), bold label on focus. */ -function CompletionOptions({ - choices, - highlighted, -}: { - choices: ChoiceRow[]; - highlighted: number; -}): React.ReactNode { - // Indent so the cursor sits directly under the section title (e.g. below the - // "N" of "Next steps"), not under the section's number marker. - return ( - - {choices.map((choice, index) => { - const isCursor = index === highlighted; - return ( - - - - {isCursor ? ICONS.triangleRight : " "} - - - {choice.label} - {choice.hint ? {choice.hint} : null} - - ); - })} - - ); -} - -/** Optional next steps — a de-emphasized (MUTED) `◇` section + driveable menu. */ -function CompletionMenu({ - choices, - onChoose, - onDismiss, -}: { - choices: ChoiceRow[]; - onChoose: (value: CompletionActionId) => void; - onDismiss: () => void; -}): React.ReactNode { - const highlighted = useChoiceNavigation({ - choices, - onCancel: onDismiss, - onChoose, - scope: "completion-menu", - }); - return ( - - - - - ); -} - -// ─────────────────────────── Intro Screen ──────────────────────────── - -function IntroScreen({ - bannerRows, - logs, - prompt, - spinner, - width, -}: { - bannerRows: BannerLine[]; - logs: LogEntry[]; - prompt: ActivePrompt | null; - spinner: SpinnerState; - width: number; -}): React.ReactNode { - const welcomePrompt = prompt?.kind === "welcome" ? prompt : null; - const options = welcomePrompt?.options ?? DEFAULT_WELCOME_OPTIONS; - const bodyWidth = Math.min(width, 84); - // Use the provided banner rows if they fit; if they're too wide for the - // current terminal, downsize to the widest variant that fits so the banner - // never wraps on resize. An explicitly-empty list means "no banner". - const banner = - bannerRows.length === 0 || bannerLinesWidth(bannerRows) <= bodyWidth - ? bannerRows - : bannerLinesForWidth(bodyWidth); - // Center the banner as one unit; centering rows separately shifts shorter rows. - const bannerWidth = bannerLinesWidth(banner); - - return ( - - - {banner.map((row, i) => ( - // positional banner rows - - {row.content} - - ))} - - {welcomePrompt ? ( - - {options.body.map((line) => ( - {line} - ))} - - ) : null} - {welcomePrompt ? ( - <> - - {options.punchline} - - - - ) : null} - {welcomePrompt ? null : ( - - )} - - ); -} - -function WelcomeActions({ - prompt, -}: { - prompt: Extract; -}): React.ReactNode { - const choices = useMemo[]>( - () => [ - { value: "continue", label: "Continue" }, - { value: "cancel", label: "Cancel" }, - ], - [], - ); - const onChoose = useCallback( - (value: "continue" | "cancel") => { - if (value === "cancel") { - prompt.resolve(null); - return; - } - prompt.resolve("continue"); - }, - [prompt], - ); - const highlighted = useChoiceNavigation({ - choices, - onChoose, - onCancel: () => prompt.resolve(null), - scope: "welcome-screen", - }); - - return ( - - - - ); -} - -function IntroPreflightContent({ - logs, - prompt, - spinner, -}: { - logs: LogEntry[]; - prompt: ActivePrompt | null; - spinner: SpinnerState; -}): React.ReactNode { - const visibleLogs = prompt ? [] : logs.slice(-5); - const hasContent = - visibleLogs.length > 0 || spinner.active || prompt !== null; - - if (!hasContent) { - return null; - } - - const promptContent = prompt ? ( - - - - ) : null; - - return ( - - {visibleLogs.length > 0 ? ( - - {visibleLogs.map((log) => ( - - ))} - - ) : null} - {spinner.active ? ( - - - - ) : null} - {promptContent} - - ); -} - -function LogLine({ entry }: { entry: LogEntry }): React.ReactNode { - const { glyph, color } = ICON_BY_SEVERITY[entry.severity]; - return ( - - - {glyph} - - {entry.text} - - ); -} - -function SpinnerRow({ state }: { state: SpinnerState }): React.ReactNode { - return ( - - - - - - - {state.message} - - ); -} - -// ──────────────────────────── Tip Panel ────────────────────────────── - -function TipPanel({ tipIndex }: { tipIndex: number }): React.ReactNode { - const tip = SENTRY_TIPS[tipIndex % SENTRY_TIPS.length] as SentryTip; - const total = SENTRY_TIPS.length; - const oneIndexed = (tipIndex % total) + 1; - return ( - - - {ICONS.diamondOpen} Did you know? - - - - {tip.title} - - {tip.body} - - - - {oneIndexed}/{total} - - - - ); -} - -// ─────────────────────────── Learn Panel ────────────────────────────── - -function LearnPanel({ - learnState, -}: { - learnState: LearnState; -}): React.ReactNode { - const block = LEARN_SEQUENCE[learnState.blockIndex]; - if (!block) { - return null; - } - // Pad short blocks to BLOCK_LINE_COUNT so height stays fixed. - const lines = block.lines.slice(0, BLOCK_LINE_COUNT); - const padding = Math.max(0, BLOCK_LINE_COUNT - lines.length); - return ( - - - - {block.title} - - - {learnState.blockIndex + 1}/{LEARN_SEQUENCE.length} - - - - {lines.map((line, i) => ( - // positional content lines - {line || " "} - ))} - {Array.from({ length: padding }, (_, i) => ( - // positional filler - - ))} - - ); -} - -// ────────────────────────── Progress Panel ──────────────────────────── - -function ProgressPanel({ steps }: { steps: StepEntry[] }): React.ReactNode { - const completedCount = steps.filter( - (entry) => entry.status === "completed", - ).length; - const totalCount = steps.length; - - const headerRight = totalCount > 0 ? `${completedCount}/${totalCount}` : ""; - const badgeColor = completedCount === totalCount ? COLOR_SUCCESS : MUTED; - - return ( - - - - {ICONS.diamondOpen} Tasks - - {headerRight ? {headerRight} : null} - - - {steps.length === 0 ? ( - - - - - Analyzing project... - - ) : null} - {steps.map((entry) => ( - - ))} - - ); -} - -function ProgressRow({ entry }: { entry: StepEntry }): React.ReactNode { - const { boldLabel, glyph, glyphColor, labelColor } = progressStyle(entry); - return ( - - - {entry.status === "in_progress" ? ( - - ) : ( - {glyph} - )} - - - {entry.label} - - - ); -} - -function ActiveTaskGlyph(): React.ReactNode { - const [isFilled, setIsFilled] = useState(true); - - useEffect(() => { - const timer = setInterval(() => { - setIsFilled((current) => !current); - }, ACTIVE_TASK_PULSE_INTERVAL_MS); - return () => clearInterval(timer); - }, []); - - return isFilled ? ( - {ICONS.triangleRight} - ) : ( - {ICONS.triangleRightOutline} - ); -} - -function progressStyle(entry: StepEntry): { - boldLabel: boolean; - glyph: string; - glyphColor: string; - labelColor?: string; -} { - if (entry.status === "in_progress") { - return { - boldLabel: true, - glyph: ICONS.triangleRight, - glyphColor: PRIMARY, - }; - } - if (entry.status === "completed") { - return { - boldLabel: false, - glyph: ICONS.squareFilled, - glyphColor: COLOR_SUCCESS, - labelColor: MUTED, - }; - } - if (entry.status === "failed") { - return { - boldLabel: true, - glyph: "\u2716", - glyphColor: COLOR_ERROR, - labelColor: COLOR_ERROR, - }; - } - if (entry.status === "skipped") { - return { - boldLabel: false, - glyph: "\u25CC", - glyphColor: MUTED_DIM, - labelColor: MUTED, - }; - } - return { - boldLabel: false, - glyph: ICONS.squareOpen, - glyphColor: MUTED, - }; -} - -// ─────────────────────────── Files Panel ────────────────────────────── - -function FilesPanel({ - filesRead, - maxRows, - hasActivePrompt, -}: { - filesRead: FileReadEntry[]; - maxRows: number; - hasActivePrompt: boolean; -}): React.ReactNode { - const [pinnedToBottom, setPinnedToBottom] = useState(true); - const [offset, setOffset] = useState(0); - - const tree = buildReadTree(filesRead); - const rows = flattenTree(tree); - const totalRows = rows.length; - - const viewport = Math.max(1, maxRows - 1); - const canScroll = totalRows > viewport; - - const maxOffset = Math.max(0, totalRows - viewport); - const effectiveOffset = pinnedToBottom ? 0 : Math.min(offset, maxOffset); - - const sliceEnd = totalRows - effectiveOffset; - const sliceStart = Math.max(0, sliceEnd - viewport); - const visible = rows.slice(sliceStart, sliceEnd); - - const prevTotalRef = useRef(totalRows); - useEffect(() => { - const prev = prevTotalRef.current; - prevTotalRef.current = totalRows; - if (pinnedToBottom) { - return; - } - const newMax = Math.max(0, totalRows - viewport); - if (totalRows > prev) { - setOffset((current) => Math.min(newMax, current + (totalRows - prev))); - } else if (totalRows < prev) { - setOffset((current) => Math.min(current, newMax)); - } - }, [totalRows, viewport, pinnedToBottom]); - - const fileShortcuts = useMemo(() => { - if (!canScroll) { - return []; - } - return [ - { - key: "\u2191\u2193", - action: "scroll", - priority: 30, - match: (_input, key) => key.upArrow || key.downArrow, - run: (_input, key) => { - if (key.upArrow) { - setPinnedToBottom(false); - setOffset((current) => Math.min(maxOffset, current + 1)); - return; - } - setOffset((current) => { - const next = Math.max(0, current - 1); - if (next === 0) { - setPinnedToBottom(true); - } - return next; - }); - }, - }, - { - key: "page", - action: "scroll", - priority: 31, - showInFooter: false, - match: (_input, key) => key.pageUp || key.pageDown, - run: (_input, key) => { - if (key.pageUp) { - setPinnedToBottom(false); - setOffset((current) => Math.min(maxOffset, current + viewport)); - return; - } - setOffset((current) => { - const next = Math.max(0, current - viewport); - if (next === 0) { - setPinnedToBottom(true); - } - return next; - }); - }, - }, - { - key: "home/end", - action: "jump", - priority: 32, - showInFooter: false, - match: (_input, key) => key.home || key.end, - run: (_input, key) => { - if (key.home) { - setPinnedToBottom(false); - setOffset(maxOffset); - return; - } - setPinnedToBottom(true); - setOffset(0); - }, - }, - ]; - }, [canScroll, maxOffset, viewport]); - useInkShortcuts("files-panel", fileShortcuts, { - isActive: !hasActivePrompt && canScroll, - }); - - if (filesRead.length === 0) { - return null; - } - - const analyzedCount = filesRead.filter( - (entry) => entry.status === "analyzed", - ).length; - const padding = Math.max(0, viewport - visible.length); - - return ( - - - - Files analyzed - - - {pinnedToBottom ? "" : "\u2191 "} - {analyzedCount}/{filesRead.length} - - - - - - {visible.map((row, i) => ( - // positional read-tree rows - - ))} - {Array.from({ length: padding }, (_, i) => ( - // positional filler - - ))} - - {canScroll ? ( - - ) : null} - - - ); -} - -function Scrollbar({ - offset, - totalRows, - viewport, -}: { - offset: number; - totalRows: number; - viewport: number; -}): React.ReactNode { - const maxOff = Math.max(1, totalRows - viewport); - const thumbSize = Math.max(1, Math.floor((viewport * viewport) / totalRows)); - const trackSpan = Math.max(1, viewport - thumbSize); - const thumbStart = Math.round(((maxOff - offset) / maxOff) * trackSpan); - const cells = Array.from({ length: viewport }, (_v, i) => { - const inThumb = i >= thumbStart && i < thumbStart + thumbSize; - return inThumb ? "\u2588" : ICONS.verticalLine; - }); - return ( - - {cells.map((cell, i) => ( - // positional scrollbar - - {cell} - - ))} - - ); -} - -function ReadTreeLine({ row }: { row: FileTreeRow }): React.ReactNode { - if (row.kind === "directory") { - return ( - - {`${row.prefix}${row.branch} `} - {row.label} - - ); - } - const { glyph, glyphColor, labelColor } = readStatusStyle(row.status); - return ( - - {`${row.prefix}${row.branch} `} - {`${glyph} `} - {row.label} - - ); -} - -function readStatusStyle(status: FileTreeRow["status"]): { - glyph: string; - glyphColor: string; - labelColor?: string; -} { - if (status === "reading") { - return { glyph: "\u25D0", glyphColor: PRIMARY }; - } - return { glyph: "\u2713", glyphColor: COLOR_SUCCESS, labelColor: MUTED }; -} - -// ────────────────────────────── Summary ─────────────────────────────── - -function SummaryPanel({ - summary, -}: { - summary: WizardSummary; -}): React.ReactNode { - return ( - - {summary.fields.length > 0 ? ( - - {summary.fields.map((field) => ( - - - {field.label} - - {field.value} - - ))} - - ) : null} - {summary.featureBlurbs !== undefined && - summary.featureBlurbs.length > 0 ? ( - - - Here's what we set up - - {summary.featureBlurbs.map(({ label, blurb }) => ( - - - - {label} - - - - - {blurb} - - - - ))} - - ) : null} - {summary.changedFiles !== undefined && summary.changedFiles.length > 0 ? ( - - ) : null} - - ); -} - -function ChangedFilesTree({ - files, -}: { - files: { action: string; path: string }[]; -}): React.ReactNode { - const tree = buildFileTree(files); - const treeRows = flattenTree(tree); - return ( - - - Changed files - - {treeRows.map((row, i) => ( - // positional tree rows - - ))} - - ); -} - -function FileTreeLine({ row }: { row: FileTreeRow }): React.ReactNode { - if (row.kind === "directory") { - return ( - - {`${row.prefix}${row.branch} `} - {row.label} - - ); - } - const { glyph, color } = changedFileStyle(row.action ?? "modify"); - return ( - - {`${row.prefix}${row.branch} `} - {`${glyph} `} - {row.label} - - ); -} - -function changedFileStyle(action: string): { glyph: string; color: string } { - if (action === "create") { - return { glyph: "+", color: COLOR_SUCCESS }; - } - if (action === "delete") { - return { glyph: "\u2212", color: COLOR_ERROR }; - } - return { glyph: "~", color: COLOR_WARN }; -} - -// ─────────────────────────────── Prompts ────────────────────────────── - -type PromptAlignment = "start" | "center"; -type SelectPromptOptionData = Extract< - ActivePrompt, - { kind: "select" } ->["options"][number]; -type MultiSelectPromptOptionData = Extract< - ActivePrompt, - { kind: "multiselect" } ->["options"][number]; -type CenteredSelectLayout = { - labelWidth: number; - width: number; -}; - -function getCenteredSelectLayout( - options: SelectPromptOptionData[], -): CenteredSelectLayout { - const labelWidth = Math.max( - 0, - ...options.map((option) => stringWidth(option.label)), - ); - const hintWidth = Math.max( - 0, - ...options.map((option) => stringWidth(option.hint ?? "")), - ); - return { - labelWidth, - width: 2 + labelWidth + (hintWidth > 0 ? hintWidth + 1 : 0), - }; -} - -/** - * Rows unavailable to option lists: workflow chrome reserves the tab and - * shortcut footers, multiselects add their local controls, and centered - * prompts additionally reserve intro padding and the full banner. - */ -const WORKFLOW_PROMPT_RESERVED_ROWS = 10; -const WORKFLOW_MULTISELECT_RESERVED_ROWS = 12; -const CENTERED_SELECT_RESERVED_ROWS = 20; -const CENTERED_MULTISELECT_RESERVED_ROWS = 23; -const WORKFLOW_ACTIVITY_RESERVED_ROWS = 7; - -/** - * Returns the half-open option range that keeps the highlighted item visible. - * The range never exceeds the requested viewport size or the option count. - */ -export function getOptionWindow( - totalCount: number, - highlighted: number, - maxVisible: number, -): readonly [number, number] { - const normalizedTotal = Math.max(0, Math.floor(totalCount)); - if (normalizedTotal === 0) { - return [0, 0]; - } - - const viewportSize = Math.min( - normalizedTotal, - Math.max(1, Math.floor(maxVisible)), - ); - const normalizedHighlight = Math.min( - normalizedTotal - 1, - Math.max(0, Math.floor(highlighted)), - ); - const centeredStart = normalizedHighlight - Math.floor(viewportSize / 2); - const start = Math.min( - normalizedTotal - viewportSize, - Math.max(0, centeredStart), - ); - return [start, start + viewportSize]; -} - -function getPromptOptionCapacity({ - terminalRows, - alignment, - kind, - occupiedRows, - messageRows, -}: { - terminalRows: number; - alignment: PromptAlignment; - kind: "select" | "multiselect"; - occupiedRows: number; - messageRows: number; -}): number { - let reservedRows = - kind === "multiselect" - ? WORKFLOW_MULTISELECT_RESERVED_ROWS - : WORKFLOW_PROMPT_RESERVED_ROWS; - if (alignment === "center") { - reservedRows = - kind === "multiselect" - ? CENTERED_MULTISELECT_RESERVED_ROWS - : CENTERED_SELECT_RESERVED_ROWS; - } - const extraMessageRows = Math.max(0, messageRows - 1); - return terminalRows - reservedRows - occupiedRows - extraMessageRows; -} - -function getPromptOptionLimit( - args: Parameters[0], -): number { - return Math.max(1, getPromptOptionCapacity(args)); -} - -/** - * Locked rows stay pinned, while the selectable viewport budgets for its - * tallest wrapped description so changing the cursor cannot overflow it. - */ -function getMultiSelectLayout({ - options, - availableRows, - terminalColumns, - alignment, -}: { - options: MultiSelectPromptOptionData[]; - availableRows: number; - terminalColumns: number; - alignment: PromptAlignment; -}): { maxVisibleOptions: number; showDescriptions: boolean } { - const descriptionWidth = Math.max( - 1, - getPromptContentWidth(terminalColumns, alignment) - - (alignment === "center" ? 4 : 5), - ); - const optionRows = options.map((option) => { - if (!option.description) { - return 1; - } - const descriptionRows = wrapAnsi(option.description, descriptionWidth, { - hard: true, - trim: false, - wordWrap: true, - }).split("\n").length; - return 1 + descriptionRows; - }); - const lockedRows = optionRows.reduce( - (total, rows, index) => (options[index]?.locked ? total + rows : total), - 0, - ); - const selectableRows = optionRows.filter( - (_rows, index) => !options[index]?.locked, - ); - const tallestSelectable = Math.max(1, ...selectableRows); - if (lockedRows + tallestSelectable > availableRows) { - const lockedCount = options.filter((option) => option.locked).length; - return { - maxVisibleOptions: Math.max(1, availableRows - lockedCount), - showDescriptions: false, - }; - } - return { - maxVisibleOptions: Math.max( - 1, - Math.floor(Math.max(0, availableRows - lockedRows) / tallestSelectable), - ), - showDescriptions: true, - }; -} - -function getPromptContentWidth( - terminalColumns: number, - alignment: PromptAlignment, -): number { - const frameWidth = getInkFrameWidth(terminalColumns); - if (alignment === "center") { - return Math.min(frameWidth, 84); - } - return frameWidth >= 80 ? Math.floor((frameWidth - 1) * 0.6) : frameWidth; -} - -function getPromptTextRows(text: string, availableWidth: number): number { - return wrapAnsi(text, Math.max(1, availableWidth), { - hard: true, - trim: false, - wordWrap: true, - }).split("\n").length; -} - -function getPromptDetailWidth( - terminalColumns: number, - alignment: PromptAlignment, -): number { - const leadingWidth = alignment === "start" ? 3 : 0; - return Math.max( - 1, - getPromptContentWidth(terminalColumns, alignment) - leadingWidth, - ); -} - -function getPromptDetailsRows( - details: readonly PromptDetail[], - availableWidth: number, -): number { - return details.reduce( - (rows, detail) => rows + getPromptTextRows(detail.text, availableWidth), - 0, - ); -} - -function getStructuredSelectLayout({ - alignment, - details, - footer, - occupiedRows, - optionCount, - promptTitle, - terminalColumns, - terminalRows, -}: { - alignment: PromptAlignment; - details: readonly PromptDetail[]; - footer?: PromptDetail; - occupiedRows: number; - optionCount: number; - promptTitle: string; - terminalColumns: number; - terminalRows: number; -}): { - compact: boolean; - detailWidth: number; - maxVisibleDetailRows: number; - maxVisibleOptions: number; -} { - const detailWidth = getPromptDetailWidth(terminalColumns, alignment); - const titleRows = getPromptTextRows(promptTitle, detailWidth); - const detailRows = getPromptDetailsRows(details, detailWidth); - const footerRows = footer ? getPromptTextRows(footer.text, detailWidth) : 0; - const optionRows = Math.max(1, optionCount); - const minimumOptionRows = - details.length > 0 && optionCount <= 2 ? optionRows : 1; - const availableRows = Math.max( - 1, - terminalRows - WORKFLOW_ACTIVITY_RESERVED_ROWS - occupiedRows, - ); - const naturalSpacingRows = 2 + (footer ? 1 : 0); - const naturalRows = - titleRows + detailRows + footerRows + optionRows + naturalSpacingRows; - - if (naturalRows <= availableRows) { - return { - compact: false, - detailWidth, - maxVisibleDetailRows: detailRows + footerRows, - maxVisibleOptions: optionRows, - }; - } - - return { - compact: true, - detailWidth, - maxVisibleDetailRows: Math.max( - 0, - availableRows - titleRows - minimumOptionRows, - ), - maxVisibleOptions: minimumOptionRows, - }; -} - -function getPromptMessageRows({ - message, - terminalColumns, - alignment, - kind, - totalCount, -}: { - message: string; - terminalColumns: number; - alignment: PromptAlignment; - kind: "select" | "multiselect"; - totalCount: number; -}): number { - let availableWidth = getPromptContentWidth(terminalColumns, alignment); - const countDigits = String(Math.max(1, totalCount)).length; - - if (kind === "select") { - const positionWidth = stringWidth( - `(${"9".repeat(countDigits)}/${"9".repeat(countDigits)})`, - ); - availableWidth -= positionWidth + (alignment === "center" ? 1 : 3); - } else if (alignment === "start") { - const count = "9".repeat(countDigits); - availableWidth -= - stringWidth(`${count}/${count} selected • ${count}/${count}`) + 4; - } - - return wrapAnsi(message, Math.max(1, availableWidth), { - hard: true, - trim: false, - wordWrap: true, - }).split("\n").length; -} - -function PromptDetails({ - alignment, - details, -}: { - alignment: PromptAlignment; - details: PromptDetail[]; -}): React.ReactNode { - const layoutProps = - alignment === "center" - ? ({ justifyContent: "center", width: "100%" } as const) - : ({ paddingLeft: 3 } as const); - return details.map((detail, index) => { - const color = detail.tone === "success" ? COLOR_SUCCESS : MUTED; - return ( - - {detail.text} - - ); - }); -} - -function normalizeDetailWindowStart(start: number, itemCount: number): number { - return Math.min(Math.max(0, start), Math.max(0, itemCount - 1)); -} - -function getDetailWindowEnd( - start: number, - itemRows: readonly number[], - rowBudget: number, -): number { - let usedRows = 0; - let end = normalizeDetailWindowStart(start, itemRows.length); - while (end < itemRows.length) { - const nextRows = itemRows[end] ?? 1; - if (usedRows + nextRows > rowBudget) { - break; - } - usedRows += nextRows; - end += 1; - } - return end; -} - -function getPreviousDetailWindowStart( - start: number, - itemRows: readonly number[], - rowBudget: number, -): number { - let previousStart = normalizeDetailWindowStart(start, itemRows.length); - let usedRows = 0; - while (previousStart > 0) { - const nextRows = itemRows[previousStart - 1] ?? 1; - if (usedRows + nextRows > rowBudget) { - break; - } - usedRows += nextRows; - previousStart -= 1; - } - return previousStart; -} - -function getNextDetailWindowStart( - start: number, - itemRows: readonly number[], - rowBudget: number, -): number { - const currentStart = normalizeDetailWindowStart(start, itemRows.length); - const windowEnd = getDetailWindowEnd(currentStart, itemRows, rowBudget); - const nextStart = windowEnd === currentStart ? currentStart + 1 : windowEnd; - return normalizeDetailWindowStart(nextStart, itemRows.length); -} - -/** - * Keeps the first detail pinned and pages the remaining details by their - * wrapped row cost, reserving space for the footer and page indicator. - */ -function usePromptDetailWindow( - promptDetails: PromptDetail[], - maxVisibleRows: number, - detailWidth: number, - footer?: PromptDetail, -): { - detailShortcut: ShortcutBinding | null; - isWindowed: boolean; - visibleDetails: PromptDetail[]; -} { - const detailLayout = useMemo(() => { - const header = promptDetails[0]; - const items = header ? promptDetails.slice(1) : promptDetails; - return { - header, - headerRows: header ? getPromptTextRows(header.text, detailWidth) : 0, - itemRows: items.map((detail) => - getPromptTextRows(detail.text, detailWidth), - ), - items, - }; - }, [detailWidth, promptDetails]); - const footerRows = footer ? getPromptTextRows(footer.text, detailWidth) : 0; - const allDetailRows = - detailLayout.headerRows + - detailLayout.itemRows.reduce((total, rows) => total + rows, 0) + - footerRows; - const isWindowed = - allDetailRows > maxVisibleRows && detailLayout.items.length > 0; - const indicatorRows = isWindowed - ? getPromptTextRows( - `${detailLayout.items.length}-${detailLayout.items.length}/${detailLayout.items.length} · pgup/pgdn`, - detailWidth, - ) - : 0; - const itemRowBudget = Math.max( - 0, - maxVisibleRows - detailLayout.headerRows - footerRows - indicatorRows, - ); - const [windowStart, setWindowStart] = useState(0); - const normalizedStart = normalizeDetailWindowStart( - windowStart, - detailLayout.items.length, - ); - const windowEnd = getDetailWindowEnd( - normalizedStart, - detailLayout.itemRows, - itemRowBudget, - ); - const visibleDetails = isWindowed - ? [ - ...(detailLayout.header === undefined ? [] : [detailLayout.header]), - ...detailLayout.items.slice(normalizedStart, windowEnd), - { - text: `${normalizedStart + 1}-${windowEnd}/${detailLayout.items.length} · pgup/pgdn`, - }, - ] - : promptDetails; - const detailShortcut = useMemo(() => { - if (!isWindowed) { - return null; - } - return { - key: "pgup/pgdn", - action: "review features", - priority: 41, - showInFooter: false, - match: (_input, key) => key.pageUp || key.pageDown, - run: (_input, key) => { - const moveWindow = key.pageUp - ? getPreviousDetailWindowStart - : getNextDetailWindowStart; - setWindowStart((start) => - moveWindow(start, detailLayout.itemRows, itemRowBudget), - ); - }, - }; - }, [detailLayout, isWindowed, itemRowBudget]); - - return { detailShortcut, isWindowed, visibleDetails }; -} - -function PromptFooter({ - alignment, - compact, - detail, -}: { - alignment: PromptAlignment; - compact: boolean; - detail?: PromptDetail; -}): React.ReactNode { - if (!detail) { - return null; - } - return ( - - - - ); -} - -function SelectPromptHeader({ - alignment, - compact, - detailsAreWindowed, - footer, - isOptionWindowed, - highlighted, - minTitleRows, - promptTitle, - totalCount, - visiblePromptDetails, -}: { - alignment: PromptAlignment; - compact: boolean; - detailsAreWindowed: boolean; - footer?: PromptDetail; - isOptionWindowed: boolean; - highlighted: number; - minTitleRows?: number; - promptTitle: string; - totalCount: number; - visiblePromptDetails: PromptDetail[]; -}): React.ReactNode { - const position = isOptionWindowed ? ( - - ({highlighted + 1}/{totalCount}) - - ) : null; - const detailRows = ( - <> - - - - ); - - if (alignment === "center") { - return ( - - - {promptTitle} - {position} - - {detailRows} - - ); - } - - return ( - - - - - {ICONS.diamondOpen} - - - {promptTitle} - {position} - - {detailRows} - - ); -} - -function SelectPromptArea({ - alignment, - occupiedRows, - prompt, -}: { - alignment: PromptAlignment; - occupiedRows: number; - prompt: Extract; -}): React.ReactNode { - const { columns, rows } = useInkFrameSize(); - const reservedRows = occupiedRows + (prompt.filterable ? 1 : 0); - const hasStructuredCopy = - (prompt.details?.length ?? 0) > 0 || prompt.footer !== undefined; - if (alignment === "start" && hasStructuredCopy) { - const layout = getStructuredSelectLayout({ - alignment, - details: prompt.details ?? [], - footer: prompt.footer, - occupiedRows: reservedRows, - optionCount: prompt.options.length, - promptTitle: prompt.message, - terminalColumns: columns, - terminalRows: rows, - }); - return ( - - ); - } - - const promptMessage = [ - prompt.message, - ...(prompt.details ?? []).map((detail) => detail.text), - ...(prompt.footer ? ["", prompt.footer.text] : []), - ].join("\n"); - const messageRows = getPromptMessageRows({ - message: promptMessage, - terminalColumns: columns, - alignment, - kind: prompt.kind, - totalCount: prompt.options.length, - }); - const optionCapacity = getPromptOptionCapacity({ - terminalRows: rows, - alignment, - kind: prompt.kind, - occupiedRows: reservedRows, - messageRows, - }); - const optionLimit = Math.max(1, optionCapacity); - const minimumVisibleOptions = - (prompt.details?.length ?? 0) > 0 && prompt.options.length <= 2 - ? prompt.options.length - : 1; - const maxVisibleDetailRows = Math.max( - 0, - messageRows - 1 - Math.max(0, minimumVisibleOptions - optionCapacity), - ); - return ( - - ); -} - -function MultiSelectPromptArea({ - alignment, - occupiedRows, - prompt, -}: { - alignment: PromptAlignment; - occupiedRows: number; - prompt: Extract; -}): React.ReactNode { - const { columns, rows } = useInkFrameSize(); - const promptMessage = [ - prompt.message, - ...(prompt.details ?? []).map((detail) => detail.text), - ...((prompt.details?.length ?? 0) > 0 ? [""] : []), - ].join("\n"); - const messageRows = getPromptMessageRows({ - message: promptMessage, - terminalColumns: columns, - alignment, - kind: prompt.kind, - totalCount: prompt.options.length, - }); - const availableRows = getPromptOptionLimit({ - terminalRows: rows, - alignment, - kind: prompt.kind, - occupiedRows, - messageRows, - }); - const multiselectLayout = getMultiSelectLayout({ - options: prompt.options, - availableRows, - terminalColumns: columns, - alignment, - }); - return ( - - ); -} - -function PromptArea({ - alignment = "start", - occupiedRows = 0, - prompt, -}: { - alignment?: PromptAlignment; - occupiedRows?: number; - prompt: ActivePrompt; -}): React.ReactNode { - if (prompt.kind === "select") { - return ( - - ); - } - if (prompt.kind === "multiselect") { - return ( - - ); - } - if (prompt.kind === "confirm") { - return ; - } - return null; -} - -function SelectPrompt({ - alignment, - compact, - detailWidth, - maxVisibleDetailRows, - maxVisibleOptions, - prompt, -}: { - alignment: PromptAlignment; - compact: boolean; - detailWidth: number; - maxVisibleDetailRows: number; - maxVisibleOptions: number; - prompt: Extract; -}): React.ReactNode { - const isCentered = alignment === "center"; - const promptWidth = isCentered ? "100%" : undefined; - const promptTitle = prompt.message; - const promptDetails = prompt.details ?? []; - const { - detailShortcut, - isWindowed: detailsAreWindowed, - visibleDetails: visiblePromptDetails, - } = usePromptDetailWindow( - promptDetails, - maxVisibleDetailRows, - detailWidth, - prompt.footer, - ); - const { columns } = useInkFrameSize(); - const centeredLayout = isCentered - ? getCenteredSelectLayout(prompt.options) - : null; - const centeredOptionsWidth = centeredLayout - ? Math.min(centeredLayout.width, getPromptContentWidth(columns, alignment)) - : undefined; - const initialHighlighted = Math.min( - Math.max(prompt.initialIndex, 0), - Math.max(0, prompt.options.length - 1), - ); - const [selection, setSelection] = useState({ - prompt, - query: "", - highlighted: initialHighlighted, - }); - const selectionRef = useRef(selection); - // Ink can deliver paste and Enter before React commits the next render. - const updateSelection = useCallback( - (update: (state: typeof selection) => typeof selection) => { - const next = update(selectionRef.current); - selectionRef.current = next; - setSelection(next); - }, - [], - ); - // Successive wizard prompts can replace each other without unmounting. - if (selection.prompt !== prompt) { - const next = { prompt, query: "", highlighted: initialHighlighted }; - selectionRef.current = next; - setSelection(next); - } - const { query, highlighted } = selection; - const filteredOptions = filterSelectOptions(prompt.options, query); - const totalCount = filteredOptions.length; - const [windowStart, windowEnd] = getOptionWindow( - totalCount, - highlighted, - maxVisibleOptions, - ); - const visibleOptions = filteredOptions.slice(windowStart, windowEnd); - const isWindowed = visibleOptions.length < totalCount; - // Keep the title's height when filtering removes its window position counter. - const minTitleRows = - prompt.filterable && prompt.options.length > maxVisibleOptions - ? getPromptMessageRows({ - message: promptTitle, - terminalColumns: columns, - alignment, - kind: "select", - totalCount: prompt.options.length, - }) - : undefined; - - const shortcuts = useMemo( - () => [ - { - key: "\u2191\u2193", - action: "navigate", - priority: 40, - match: (_input, key) => key.upArrow || key.downArrow, - run: (_input, key) => { - updateSelection((state) => { - const count = filterSelectOptions( - prompt.options, - state.query, - ).length; - if (count === 0) { - return state; - } - return { - ...state, - highlighted: - (state.highlighted + (key.upArrow ? count - 1 : 1)) % count, - }; - }); - }, - }, - ...(detailShortcut ? [detailShortcut] : []), - { - key: "enter", - action: "confirm", - priority: 42, - match: (_input, key) => key.return, - run: () => { - const state = selectionRef.current; - const current = filterSelectOptions(prompt.options, state.query)[ - state.highlighted - ]; - if (current) { - prompt.resolve(current.value); - } - }, - }, - { - key: "esc", - action: query ? "clear filter" : "cancel", - priority: 43, - match: (input, key) => key.escape || (key.ctrl && input === "c"), - run: (input, key) => { - if (key.ctrl && input === "c") { - prompt.resolve(null); - } else if (selectionRef.current.query) { - updateSelection((state) => ({ - ...state, - query: "", - highlighted: 0, - })); - } else { - prompt.resolve(null); - } - }, - }, - ...(prompt.filterable - ? ([ - { - key: "backspace", - action: "delete", - showInFooter: false, - match: (_input, key) => key.backspace || key.delete, - run: () => { - updateSelection((state) => { - if (!state.query) { - return state; - } - return { - ...state, - query: removeLastFilterCharacter(state.query), - highlighted: 0, - }; - }); - }, - }, - { - key: "type", - action: "filter", - showInFooter: false, - match: (input, key) => - !(key.ctrl || key.meta || key.tab) && - sanitizeSelectFilterInput(input).length > 0, - run: (input) => { - const text = sanitizeSelectFilterInput(input); - updateSelection((state) => ({ - ...state, - query: state.query + text, - highlighted: 0, - })); - }, - }, - ] satisfies ShortcutBinding[]) - : []), - ], - [detailShortcut, prompt, query, updateSelection], - ); - useInkShortcuts("select-prompt", shortcuts); - - return ( - - - - {totalCount === 0 ? ( - - No matches - - ) : ( - - {visibleOptions.map((option, visibleIndex) => { - const idx = windowStart + visibleIndex; - const isCursor = idx === highlighted; - return ( - - ); - })} - - )} - - {prompt.filterable ? ( - - ) : null} - - ); -} - -function SelectFilterStatus({ - centered, - matches, - query, - total, -}: { - centered: boolean; - matches: number; - query: string; - total: number; -}): React.ReactNode { - const color = query ? ACCENT : MUTED; - return ( - - - - {query ? `/${query}` : "type to filter"} - - - {query ? ( - - {`${matches} of ${total}`} - - ) : null} - - ); -} - -function SelectOptionText({ - text, - query, -}: { - text: string; - query: string; -}): React.ReactNode { - const start = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1; - if (start === -1) { - return text; - } - return ( - <> - {text.slice(0, start)} - {text.slice(start, start + query.length)} - {text.slice(start + query.length)} - - ); -} - -function SelectPromptOptionRow({ - centered, - centeredLabelWidth, - isCursor, - option, - query, -}: { - centered: boolean; - centeredLabelWidth?: number; - isCursor: boolean; - option: SelectPromptOptionData; - query: string; -}): React.ReactNode { - if (centered) { - return ( - - - {isCursor ? ICONS.triangleRight : " "} - - - - - - - {option.hint !== undefined && option.hint !== "" ? ( - - {" "} - - - ) : null} - - ); - } - return ( - - - {isCursor ? ICONS.triangleRight : " "} - - - - - {option.hint !== undefined && option.hint !== "" ? ( - - {" "} - - - ) : null} - - ); -} - -function ConfirmPrompt({ - alignment, - prompt, -}: { - alignment: PromptAlignment; - prompt: Extract; -}): React.ReactNode { - const isCentered = alignment === "center"; - const promptWidth = isCentered ? "100%" : undefined; - const shortcuts = useMemo( - () => [ - { - key: "y/n", - action: "answer", - priority: 40, - match: (input) => - input === "y" || input === "Y" || input === "n" || input === "N", - run: (input) => prompt.resolve(input === "y" || input === "Y"), - }, - { - key: "enter", - action: "default", - priority: 41, - showInFooter: false, - match: (_input, key) => key.return, - run: () => prompt.resolve(prompt.initialValue), - }, - { - key: "esc", - action: "cancel", - priority: 42, - showInFooter: false, - match: (input, key) => key.escape || (key.ctrl && input === "c"), - run: () => prompt.resolve(null), - }, - ], - [prompt], - ); - useInkShortcuts("confirm-prompt", shortcuts); - - const yLabel = prompt.initialValue ? "Y" : "y"; - const nLabel = prompt.initialValue ? "n" : "N"; - - return ( - - {isCentered ? ( - - {prompt.message} - - ({yLabel}/{nLabel}) - - - ) : ( - - - - {ICONS.diamondOpen} - - - {prompt.message} - - ({yLabel}/{nLabel}) - - - )} - - ); -} - -function MultiSelectPrompt({ - alignment, - maxVisibleOptions, - prompt, - showDescriptions, -}: { - alignment: PromptAlignment; - maxVisibleOptions: number; - prompt: Extract; - showDescriptions: boolean; -}): React.ReactNode { - const isCentered = alignment === "center"; - const promptWidth = isCentered ? "100%" : undefined; - const promptTitle = prompt.message; - const promptDetails = prompt.details ?? []; - const lockedOptions = useMemo( - () => prompt.options.filter((option) => option.locked), - [prompt.options], - ); - const selectableOptions = useMemo( - () => prompt.options.filter((option) => !option.locked), - [prompt.options], - ); - const [selected, setSelected] = useState>( - () => - new Set([ - ...prompt.initialSelected, - ...prompt.options - .filter((option) => option.locked) - .map((option) => option.value), - ]), - ); - const [highlighted, setHighlighted] = useState(() => { - const firstUnselected = selectableOptions.findIndex( - (option) => !prompt.initialSelected.includes(option.value), - ); - return Math.max(0, firstUnselected); - }); - const totalCount = prompt.options.length; - const selectableCount = selectableOptions.length; - const [windowStart, windowEnd] = getOptionWindow( - selectableCount, - highlighted, - maxVisibleOptions, - ); - const visibleOptions = selectableOptions.slice(windowStart, windowEnd); - const isWindowed = visibleOptions.length < selectableCount; - - const toggleAt = useCallback( - (idx: number) => { - const current = selectableOptions[idx]; - if (!current) { - return; - } - setSelected((prev) => { - const next = new Set(prev); - if (next.has(current.value)) { - next.delete(current.value); - } else { - next.add(current.value); - } - return next; - }); - }, - [selectableOptions], - ); - - const commit = useCallback(() => { - if (prompt.required && selected.size === 0) { - return; - } - const ordered = prompt.options - .map((option) => option.value) - .filter((value) => selected.has(value)); - prompt.resolve(ordered); - }, [prompt, selected]); - - const shortcuts = useMemo( - () => [ - { - key: "\u2191\u2193", - action: "navigate", - priority: 40, - match: (_input, key) => key.upArrow || key.downArrow, - run: (_input, key) => { - if (selectableCount === 0) { - return; - } - if (key.upArrow) { - setHighlighted((idx) => - idx === 0 ? selectableCount - 1 : idx - 1, - ); - return; - } - setHighlighted((idx) => (idx + 1) % selectableCount); - }, - }, - { - key: "space", - action: "toggle", - priority: 41, - match: (input) => input === " ", - run: () => toggleAt(highlighted), - }, - { - key: "a", - action: "all", - priority: 42, - match: (input) => input === "a", - run: () => { - setSelected((prev) => { - const lockedValues = lockedOptions.map((option) => option.value); - const allSelectableSelected = selectableOptions.every((option) => - prev.has(option.value), - ); - if (allSelectableSelected) { - return new Set(lockedValues); - } - return new Set(prompt.options.map((option) => option.value)); - }); - }, - }, - { - key: "enter", - action: "continue", - priority: 43, - match: (_input, key) => key.return, - run: commit, - }, - { - key: "esc", - action: "cancel", - priority: 44, - match: (input, key) => key.escape || (key.ctrl && input === "c"), - run: () => prompt.resolve(null), - }, - ], - [ - commit, - highlighted, - lockedOptions, - prompt, - selectableCount, - selectableOptions, - toggleAt, - ], - ); - useInkShortcuts("multiselect-prompt", shortcuts); - const shortcutText = `↑↓ move ${ICONS.bullet} space toggle ${ICONS.bullet} a all ${ICONS.bullet} enter continue`; - const selectedCount = isWindowed - ? `${selected.size}/${totalCount} selected ${ICONS.bullet} ${highlighted + 1}/${selectableCount}` - : `${selected.size}/${totalCount}`; - - return ( - - {isCentered ? ( - - - {promptTitle} - - - - ) : ( - - - - - - {ICONS.diamondOpen} - - - {promptTitle} - - {selectedCount} - - - - )} - 0 ? 1 : 0} - width={promptWidth} - > - {lockedOptions.map((option) => ( - - ))} - {visibleOptions.map((option, visibleIndex) => { - const idx = windowStart + visibleIndex; - const isSelected = selected.has(option.value); - const isCursor = idx === highlighted; - return ( - - ); - })} - - {isCentered ? ( - - {shortcutText} - {selectedCount} - - ) : ( - - {shortcutText} - - )} - - ); -} - -function getLockedOptionHint( - option: MultiSelectPromptOptionData, - availableWidth: number, -): string { - if (!option.locked) { - return ""; - } - const lockedHint = " (always included)"; - const optionHint = option.hint ? ` ${option.hint}` : ""; - return stringWidth(`${option.label}${optionHint}${lockedHint}`) <= - availableWidth - ? lockedHint - : ""; -} - -function MultiSelectOptionLabel({ - isCursor, - lockedHint, - option, -}: { - isCursor: boolean; - lockedHint: string; - option: MultiSelectPromptOptionData; -}): React.ReactNode { - const labelColor = option.locked ? MUTED : undefined; - return ( - <> - - {option.label} - - {option.hint ? {option.hint} : null} - {lockedHint} - - ); -} - -function MultiSelectPromptOptionRow({ - centered, - isCursor, - isSelected, - option, - showDescription, -}: { - centered: boolean; - isCursor: boolean; - isSelected: boolean; - option: MultiSelectPromptOptionData; - showDescription: boolean; -}): React.ReactNode { - const marker = isSelected ? ICONS.squareFilled : ICONS.squareOpen; - const markerColor = isSelected ? COLOR_SUCCESS : MUTED; - const { columns } = useInkFrameSize(); - const alignment = centered ? "center" : "start"; - const optionContentWidth = - getPromptContentWidth(columns, alignment) - (centered ? 4 : 5); - const lockedHint = getLockedOptionHint(option, optionContentWidth); - const descriptionIndent = 5; - const visibleDescription = showDescription ? option.description : undefined; - if (centered) { - return ( - - - - {isCursor ? `${ICONS.triangleRight} ` : " "} - - {marker} - - - {visibleDescription ? ( - - {visibleDescription} - - ) : null} - - ); - } - return ( - - - - {isCursor ? ICONS.triangleRight : " "} - - {marker} - - - {visibleDescription ? ( - - {visibleDescription} - - ) : null} - - ); -} - -/** Compact entity picker for commands outside the full-screen init wizard. */ -export function SelectPicker({ - prompt, -}: { - prompt: Extract; -}): React.ReactNode { - return ( - - - - - - - ); -} - -/** Mount the compact picker with the sidecar's own Ink and React instances. */ -export function mountSelectPrompt( - prompt: Extract, - options: Parameters[1], -): ReturnType { - return inkRender(createElement(SelectPicker, { prompt }), options); -} - -/** - * Mount the wizard App component via Ink and return the Ink instance. - * - * This function must live inside the sidecar so it uses the same - * `react` and `ink` copies as the App's hooks. Importing ink/react - * separately in the main bundle and calling `ink.render()` from - * there would create a second React instance, causing "Invalid hook - * call" errors at runtime. By centralising the mount call here, - * only one copy of ink + react exists. - */ -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; -} { - return inkRender(createElement(App, { store }), options); -} diff --git a/packages/cli/src/lib/init/ui/ink-frame.tsx b/packages/cli/src/lib/init/ui/ink-frame.tsx deleted file mode 100644 index 14ff52449..000000000 --- a/packages/cli/src/lib/init/ui/ink-frame.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { Box, Text, useWindowSize } from "ink"; -import { Component, type ReactNode } from "react"; -import stringWidth from "string-width"; -import { useShortcutHints } from "./ink-shortcuts.js"; - -const MIN_FRAME_WIDTH = 80; -const MAX_FRAME_WIDTH = 120; - -export type FrameTab = { - id: string; - label: string; -}; - -export function getInkFrameWidth(terminalColumns: number): number { - if (terminalColumns < MIN_FRAME_WIDTH) { - return terminalColumns; - } - return Math.min(MAX_FRAME_WIDTH, terminalColumns); -} - -export function getInkFrameMargin( - terminalColumns: number, - frameWidth: number, -): number { - return Math.max(0, Math.floor((terminalColumns - frameWidth) / 2)); -} - -export function useInkFrameSize(): { columns: number; rows: number } { - return useWindowSize(); -} - -export function StatusHistory({ - messages, - borderColor, - currentColor, - historyColor, - currentGlyph, - historyGlyph, -}: { - messages: string[]; - borderColor: string; - currentColor: string; - historyColor: string; - currentGlyph: string; - historyGlyph: string; -}): React.ReactNode { - return ( - - {messages.map((message, index, allMessages) => { - const isLatest = index === allMessages.length - 1; - let glyph = historyGlyph; - let color = historyColor; - if (isLatest) { - glyph = currentGlyph; - color = currentColor; - } - return ( - // positional status messages - - {glyph} {message} - - ); - })} - - ); -} - -export function TabFooter({ - tabs, - activeTab, - activeColor, - inactiveColor, - activeGlyph, -}: { - tabs: FrameTab[]; - activeTab: number; - activeColor: string; - inactiveColor: string; - activeGlyph: string; -}): React.ReactNode { - return ( - - {tabs.map((tab, index) => { - const isActive = index === activeTab; - let color = inactiveColor; - let glyph = " "; - if (isActive) { - color = activeColor; - glyph = activeGlyph; - } - return ( - - - {glyph} {tab.label} - - - ); - })} - - ); -} - -export function ShortcutFooter({ color }: { color: string }): React.ReactNode { - const hints = useShortcutHints(); - const { columns } = useWindowSize(); - const availableWidth = Math.max(0, getInkFrameWidth(columns) - 2); - let usedWidth = 0; - const visibleHints: typeof hints = []; - for (const hint of hints) { - const gapWidth = visibleHints.length === 0 ? 0 : 2; - const hintWidth = stringWidth(`${hint.key} ${hint.action}`); - if (usedWidth + gapWidth + hintWidth > availableWidth) { - continue; - } - usedWidth += gapWidth + hintWidth; - visibleHints.push(hint); - } - - return ( - - {visibleHints.map((hint, index) => ( - - - {hint.key} - - {hint.action} - - ))} - - ); -} - -type RenderErrorBoundaryProps = { - children: ReactNode; - errorColor: string; -}; - -type RenderErrorBoundaryState = { - message: string | null; -}; - -export class InitRenderBoundary extends Component< - RenderErrorBoundaryProps, - RenderErrorBoundaryState -> { - override state: RenderErrorBoundaryState = { message: null }; - - static getDerivedStateFromError(error: Error): RenderErrorBoundaryState { - return { message: error.message || "Unknown render error" }; - } - - override render(): ReactNode { - if (this.state.message) { - return ( - - - Sentry init UI hit a rendering error. - - {this.state.message} - - ); - } - - return this.props.children; - } -} diff --git a/packages/cli/src/lib/init/ui/ink-report.ts b/packages/cli/src/lib/init/ui/ink-report.ts deleted file mode 100644 index 2f89677fc..000000000 --- a/packages/cli/src/lib/init/ui/ink-report.ts +++ /dev/null @@ -1,184 +0,0 @@ -import chalk from "chalk"; -import { renderTextTable } from "../../formatters/text-table.js"; -import { buildFileTree, flattenTree } from "./file-tree.js"; -import type { WizardSummary } from "./types.js"; - -// Brand palette mirrored from `ink-app.tsx` so the post-dispose -// success/failure echo (rendered via chalk after Ink unmounts) feels -// like a continuation of the live screen. -const REPORT_MUTED = "#898294"; -const REPORT_SUCCESS = "#83da90"; -const REPORT_ERROR = "#fe4144"; -const REPORT_WARN = "#FDB81B"; - -/** Splits on `: ` to separate error label from detail. */ -const ERROR_SPLIT_RE = /:\s+/; - -/** - * Build the chalk-formatted failure report shown after alternate - * screen exit. Includes up to 5 recent error log entries with - * structured formatting for readability. - */ -export function formatFailureReport( - message: string, - logs: readonly { severity: string; text: string }[], - feedbackHint?: string, -): string { - const icon = chalk.hex(REPORT_ERROR)("\u2716"); - const lines: string[] = [ - `\n${icon} ${chalk.hex(REPORT_ERROR).bold(message)}`, - ]; - const errorLogs = logs.filter( - (entry) => - entry.severity === "error" && - entry.text !== message && - entry.text !== "Failed", - ); - if (errorLogs.length > 0) { - lines.push(""); - } - for (const entry of errorLogs.slice(-5)) { - formatErrorEntry(entry.text, lines); - } - appendFeedbackHint(lines, feedbackHint); - return lines.join("\n"); -} - -export function formatSuccessReport( - message: string, - summary: WizardSummary | undefined, - feedbackHint?: string, -): string { - const successIcon = chalk.hex(REPORT_SUCCESS)("✔"); - const lines: string[] = ["", `${successIcon} ${chalk.bold(message)}`]; - if (summary && summary.fields.length > 0) { - lines.push(""); - const labelWidth = Math.max( - ...summary.fields.map((field) => field.label.length), - ); - for (const field of summary.fields) { - const label = chalk.hex(REPORT_MUTED)(field.label.padEnd(labelWidth)); - lines.push(` ${label} ${field.value}`); - } - } - if (summary?.featureBlurbs && summary.featureBlurbs.length > 0) { - lines.push(""); - lines.push(` ${chalk.hex(REPORT_MUTED).bold("Here's what we set up")}`); - const tableRows = summary.featureBlurbs.map(({ label, blurb }) => [ - chalk.bold(label), - chalk.hex(REPORT_MUTED)(blurb), - ]); - const table = renderTextTable(["", ""], tableRows, { - shrinkable: [false, true], - }); - for (const line of table.trimEnd().split("\n")) { - lines.push(` ${line}`); - } - } - if (summary?.changedFiles && summary.changedFiles.length > 0) { - lines.push(""); - lines.push(` ${chalk.hex(REPORT_MUTED).bold("Changed files")}`); - const tree = buildFileTree(summary.changedFiles); - for (const row of flattenTree(tree)) { - lines.push(formatTreeRowChalk(row)); - } - } - appendFeedbackHint(lines, feedbackHint); - return lines.join("\n"); -} - -/** - * Compact success echo left in the user's scrollback after the completion - * screen closes. The screen already showed everything; here we keep only a - * warm confirmation plus the one thing that stays useful post-exit — the link - * to your errors (no key hints, since there's nothing to press anymore). - */ -export function formatSuccessExitLine( - summary: WizardSummary | undefined, -): string { - const completion = summary?.completion; - const project = completion?.projectName ?? "your project"; - const icon = chalk.hex(REPORT_SUCCESS)("✔"); - const lines: string[] = [ - "", - `${icon} ${chalk.bold(`All set — Sentry is watching ${project}`)}`, - ]; - const target = completion?.verification.eventUrl ?? completion?.issuesUrl; - if (target) { - const label = completion?.verification.received - ? "View your first event" - : "See your first error"; - lines.push(` ${chalk.hex(REPORT_MUTED)(`${label} → ${target}`)}`); - } - return lines.join("\n"); -} - -function appendFeedbackHint(lines: string[], feedbackHint?: string): void { - if (!feedbackHint) { - return; - } - lines.push(""); - for (const line of feedbackHint.split("\n")) { - lines.push(` ${chalk.hex(REPORT_MUTED)(line)}`); - } - lines.push(""); -} - -/** - * Format a single error log entry into indented report lines. - * Splits on newlines first, then separates the first segment - * (bold red) from subsequent detail (muted) on each line. - */ -function formatErrorEntry(text: string, out: string[]): void { - const rawLines = text - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - if (rawLines.length === 0) { - return; - } - const first = rawLines[0] ?? ""; - const parts = first.split(ERROR_SPLIT_RE); - out.push(` ${chalk.hex(REPORT_ERROR).bold(parts[0] ?? "")}`); - for (const part of parts.slice(1)) { - out.push(` ${chalk.hex(REPORT_MUTED)(part)}`); - } - for (const line of rawLines.slice(1)) { - out.push(` ${chalk.hex(REPORT_MUTED)(line)}`); - } -} - -/** - * Colored glyph for a changed-files row in the post-dispose report. - * The plain ASCII variant lives in `logging-ui.ts` for the - * non-interactive CI path. - */ -function changedFileGlyphColored(action: string): string { - if (action === "create") { - return chalk.hex(REPORT_SUCCESS)("+"); - } - if (action === "delete") { - return chalk.hex(REPORT_ERROR)("−"); - } - return chalk.hex(REPORT_WARN)("~"); -} - -/** - * Render a single `FileTreeRow` for the post-dispose report. - * Directories show only the box-drawing branch + label; files add - * the action glyph (colored). - */ -function formatTreeRowChalk(row: { - prefix: string; - branch: string; - kind: "file" | "directory"; - label: string; - action?: string; -}): string { - const branch = chalk.hex(REPORT_MUTED)(`${row.prefix}${row.branch}`); - if (row.kind === "directory") { - return ` ${branch} ${row.label}`; - } - const glyph = changedFileGlyphColored(row.action ?? "modify"); - return ` ${branch} ${glyph} ${row.label}`; -} diff --git a/packages/cli/src/lib/init/ui/ink-shortcuts.tsx b/packages/cli/src/lib/init/ui/ink-shortcuts.tsx deleted file mode 100644 index e15b7826a..000000000 --- a/packages/cli/src/lib/init/ui/ink-shortcuts.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useInput } from "ink"; -import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; - -type InkKey = Parameters[0]>[1]; - -const DEFAULT_SHORTCUT_PRIORITY = 100; - -export type ShortcutHint = { - key: string; - action: string; - priority?: number; -}; - -export type ShortcutBinding = ShortcutHint & { - match: (input: string, key: InkKey) => boolean; - run: (input: string, key: InkKey) => void; - showInFooter?: boolean; -}; - -type ShortcutRegistry = { - setScope: (scope: string, hints: ShortcutHint[]) => void; - clearScope: (scope: string) => void; -}; - -const ShortcutRegistryContext = createContext(null); -const ShortcutHintsContext = createContext([]); - -function shortcutPriority(hint: ShortcutHint): number { - return hint.priority ?? DEFAULT_SHORTCUT_PRIORITY; -} - -function shortcutKey(hint: ShortcutHint): string { - return `${hint.key}\u0000${hint.action}`; -} - -function shortcutSignature(hints: readonly ShortcutHint[]): string { - return hints - .map((hint) => `${shortcutPriority(hint)}:${hint.key}:${hint.action}`) - .join("\n"); -} - -export function arrangeShortcutHints( - hints: readonly ShortcutHint[], -): ShortcutHint[] { - const seen = new Set(); - const unique: Array<{ hint: ShortcutHint; index: number }> = []; - - for (const hint of hints) { - const key = shortcutKey(hint); - if (seen.has(key)) { - continue; - } - seen.add(key); - unique.push({ hint, index: unique.length }); - } - - unique.sort((left, right) => { - const priorityDelta = - shortcutPriority(left.hint) - shortcutPriority(right.hint); - if (priorityDelta !== 0) { - return priorityDelta; - } - return left.index - right.index; - }); - - return unique.map(({ hint }) => hint); -} - -export function ShortcutHintProvider({ - children, -}: { - children: ReactNode; -}): React.ReactNode { - const scopes = useRef(new Map()); - const signature = useRef(""); - const [hints, setHints] = useState([]); - - const refresh = useCallback(() => { - const allHints: ShortcutHint[] = []; - for (const scopedHints of scopes.current.values()) { - allHints.push(...scopedHints); - } - const nextHints = arrangeShortcutHints(allHints); - const nextSignature = shortcutSignature(nextHints); - if (nextSignature === signature.current) { - return; - } - signature.current = nextSignature; - setHints(nextHints); - }, []); - - const setScope = useCallback( - (scope: string, scopedHints: ShortcutHint[]) => { - if (scopedHints.length === 0) { - scopes.current.delete(scope); - } else { - scopes.current.set(scope, scopedHints); - } - refresh(); - }, - [refresh], - ); - - const clearScope = useCallback( - (scope: string) => { - scopes.current.delete(scope); - refresh(); - }, - [refresh], - ); - - const registry = useMemo( - () => ({ setScope, clearScope }), - [setScope, clearScope], - ); - - return ( - - - {children} - - - ); -} - -export function useShortcutHints(): ShortcutHint[] { - return useContext(ShortcutHintsContext); -} - -export function useInkShortcuts( - scope: string, - bindings: readonly ShortcutBinding[], - options: { isActive?: boolean } = {}, -): void { - const registry = useContext(ShortcutRegistryContext); - const isActive = options.isActive ?? true; - - const footerHints = useMemo(() => { - if (!isActive) { - return []; - } - return bindings - .filter((binding) => binding.showInFooter !== false) - .map(({ key, action, priority }) => ({ key, action, priority })); - }, [bindings, isActive]); - - useEffect(() => { - if (!registry) { - return; - } - registry.setScope(scope, footerHints); - return () => { - registry.clearScope(scope); - }; - }, [registry, scope, footerHints]); - - useInput( - (input, key) => { - for (const binding of bindings) { - if (binding.match(input, key)) { - binding.run(input, key); - return; - } - } - }, - { isActive }, - ); -} diff --git a/packages/cli/src/lib/init/ui/ink-ui.ts b/packages/cli/src/lib/init/ui/ink-ui.ts deleted file mode 100644 index 22be8c5a6..000000000 --- a/packages/cli/src/lib/init/ui/ink-ui.ts +++ /dev/null @@ -1,1295 +0,0 @@ -/** - * InkUI — Ink-based `WizardUI` implementation. - * - * The class is a thin bridge between the imperative `WizardUI` - * surface (which the wizard runner calls into) and a React tree - * mounted via Ink's `render()`. State lives in a `WizardStore` - * (see `wizard-store.ts`) that React subscribes to via - * `useSyncExternalStore`. Each method on this class translates a - * single imperative call into one or more store mutations; React - * re-renders. - * - * Why Ink rather than OpenTUI? - * - * - **No native binary cost.** The OpenTUI implementation added - * ~10.7 MB to the compiled Bun binary (the `libopentui.so` - * plus the ~12k-line generated FFI bindings). Ink is pure JS, - * so it bundles cleanly with no platform-specific peer - * packages. - * - **Inline rendering.** Ink writes incrementally to stdout, so - * log lines naturally end up in the user's scrollback. OpenTUI - * needed an alternate-screen buffer + a post-dispose stderr - * replay to leave any trace of the run behind. - * - * **Stdin workaround for Bun.** Ink listens for `readable` events - * on its `stdin` option (default `process.stdin`) and calls - * `stdin.read()` to consume bytes. Bun's compiled binaries have a - * long-standing bug — `process.stdin` accepts `setRawMode(true)` but - * never delivers `readable` events for terminal input - * (oven-sh/bun#6862, vadimdemedes/ink#636, both still open). The - * symptom: the wizard renders fine but arrow keys, Enter, and - * Ctrl+C all do nothing. - * - * Workaround: open a fresh `/dev/tty` `ReadStream` ourselves and - * pass it to Ink as the `stdin` option. The fresh stream's - * `readable` events fire correctly because the file-descriptor - * inheritance bug only affects fd 0, not fds we open inside the - * process. We close the stream on dispose to release the libuv - * handle. - * - * **Lazy import.** The Ink app sidecar (`ink-app.js`) is a - * self-contained ESM bundle with all deps (ink, react, yoga-layout) - * inlined. It's loaded lazily by `createInkUI()` via dynamic - * `import()` so the `LoggingUI` path stays cheap to instantiate - * when interactive UI is not needed. On the Bun binary the sidecar - * is embedded in `/$bunfs/`; on the npm/Node distribution it ships - * as `dist/ink-app.js` alongside the CJS bundle. - */ - -import { spawn } from "node:child_process"; -import { openSync } from "node:fs"; -import { createRequire } from "node:module"; -import { join } from "node:path"; -import { ReadStream } from "node:tty"; - -const _require = createRequire(import.meta.url); - -import { addBreadcrumb, setTag } from "@sentry/node"; -import { FULL_BANNER_LINES } from "../../banner.js"; -import { openBrowser } from "../../browser.js"; -import { CLI_VERSION } from "../../constants.js"; -import { stripAnsi } from "../../formatters/plain-detect.js"; -import { - createWizardPromptTelemetry, - type WizardPromptKind, -} from "../../telemetry.js"; -import { formatFeedbackHint, type InitFeedbackOutcome } from "../feedback.js"; -import { - formatFailureReport, - formatSuccessExitLine, - formatSuccessReport, -} from "./ink-report.js"; -import { LEARN_SEQUENCE } from "./learn-content.js"; -import { SENTRY_TIPS } from "./sentry-tips.js"; -import { - CANCELLED, - type Cancelled, - type ConfirmOptions, - type MultiSelectOptions, - type SelectOptions, - type SpinnerExitCode, - type SpinnerHandle, - type WelcomeOptions, - type WizardLog, - type WizardSummary, - type WizardUI, -} from "./types.js"; -import { type ActivePrompt, WizardStore } from "./wizard-store.js"; - -type CreateInkUIOptions = { - initialWelcome?: WelcomeOptions; -}; - -type PendingWelcome = { - promise: Promise<"continue" | Cancelled>; - tracedPromise?: Promise<"continue" | Cancelled>; - resolve: (value: "continue" | Cancelled) => void; - settled: boolean; -}; - -type InkPromptOptions = { - initialWelcome?: PendingWelcome; - telemetry?: ReturnType; -}; - -/** Tip rotation cadence in the sidebar — slow enough to read each tip. */ -const TIP_ROTATE_INTERVAL_MS = 15_000; - -function sanitizeWelcomeOptions(opts: WelcomeOptions): WelcomeOptions { - return { - title: stripAnsi(opts.title), - body: opts.body.map(stripAnsi), - punchline: stripAnsi(opts.punchline), - }; -} - -function createPendingWelcome(): PendingWelcome { - let resolve!: (value: "continue" | Cancelled) => void; - const pending: PendingWelcome = { - promise: new Promise<"continue" | Cancelled>((r) => { - resolve = r; - }), - resolve: (value) => { - if (pending.settled) { - return; - } - pending.settled = true; - resolve(value); - }, - settled: false, - }; - return pending; -} - -/** Handle that keeps the wizard alive on the completion screen until the user - * dismisses it. `[Symbol.asyncDispose]` awaits `promise`. */ -type PendingOutro = { - promise: Promise; - resolve: () => void; - settled: boolean; -}; - -function createPendingOutro(): PendingOutro { - let resolve!: () => void; - const pending: PendingOutro = { - promise: new Promise((r) => { - resolve = r; - }), - resolve: () => { - if (pending.settled) { - return; - } - pending.settled = true; - resolve(); - }, - settled: false, - }; - return pending; -} - -/** Run one shell command with the real terminal attached, resolving when it - * exits. Used for post-exit actions (e.g. the interactive agent installer) - * after the alternate screen has been torn down. Never rejects. */ -function runInheritedCommand(command: string): Promise { - return new Promise((resolve) => { - try { - const child = spawn(command, { shell: true, stdio: "inherit" }); - child.on("close", () => resolve()); - child.on("error", () => resolve()); - } catch { - resolve(); - } - }); -} - -function seedWelcomePrompt( - store: WizardStore, - opts: WelcomeOptions, - pending: PendingWelcome, -): void { - store.setLayout("intro"); - store.setPrompt({ - kind: "welcome", - options: sanitizeWelcomeOptions(opts), - resolve: (value) => { - store.setPrompt(null); - pending.resolve(value === null ? CANCELLED : value); - }, - }); -} - -/** - * Log severities recognised by InkUI. Mirrors the keys of - * `ICON_BY_SEVERITY` in `ink-app.tsx`. - */ -type LogSeverity = "info" | "warn" | "error" | "success" | "message"; - -/** - * Severity returned for a spinner stop given its exit code. - * 0 → success, 1 → error, 2 → warn. - */ -function severityForStopCode(code: SpinnerExitCode): LogSeverity { - if (code === 1) { - return "error"; - } - if (code === 2) { - return "warn"; - } - return "success"; -} - -/** - * Resolve the Ink sidecar path/source for the current runtime context. - * - * The sidecar (`ink-app.js`) is a self-contained ESM bundle produced - * by `text-import-plugin` during the esbuild step. It inlines ink, - * react, and all local deps so it can run without `node_modules`. - * - * Three runtime contexts: - * - * 1. **Node SEA binary**: The sidecar is embedded as a SEA asset via - * fossilize's `--assets` flag. Extract with `node:sea.getAsset()`, - * write to a temp file, and `import()` it. - * - * 2. **Node/npm bundle** (`npx sentry`): The sidecar ships as - * `dist/ink-app.js` alongside the CJS bundle. The `text-import-plugin` - * emits a virtual module exporting the relative path `"./ink-app.js"`. - * Resolved via `import.meta.url` at runtime. - * - * 3. **Dev mode** (`pnpm run cli`): The absolute filesystem path to - * `ink-app.tsx` is resolved by the text-import-plugin at build time. - * In dev (tsx), it points to the source file directly. - */ -// @ts-expect-error: `with { type: "file" }` handled by text-import-plugin at build time -import inkAppPath from "./ink-app.tsx" with { type: "file" }; - -/** - * Open a fresh `/dev/tty` `ReadStream` for Ink to consume. Returns - * `null` when `/dev/tty` isn't available (non-TTY environment, or - * platforms that don't expose it — Windows). The caller falls back - * to `process.stdin` in that case, which works on Node but is - * broken in Bun-compiled binaries (see module docstring). - */ -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"); - return new ReadStream(fd); - } catch { - return null; - } -} - -/** Load the self-contained Ink sidecar without importing React into the CLI bundle. */ -async function loadInkApp(): Promise { - // Import the Ink App sidecar. Three runtime contexts: - // - // 1. Node SEA binary: the sidecar is embedded as a SEA asset. - // Extract it via node:sea.getAsset(), write to a temp file, - // and import() it. - // - // 2. Node/npm (npx sentry@latest): inkAppPath is a relative path - // like "./ink-app.js" (emitted by text-import-plugin as a - // string literal). Resolve it to an absolute file:// URL using - // import.meta.url so Node's dynamic import() can load the - // self-contained ESM sidecar from the dist/ directory. - // - // 3. Dev mode (pnpm run cli): inkAppPath is the absolute - // filesystem path to ink-app.tsx. - let importPath: string; - let seaTmpDir: string | undefined; - - // Check if running inside a Node SEA binary - let isSea = false; - // 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 { - // oxlint-disable-next-line typescript/no-explicit-any -- node:sea types not yet in @types/node - const sea = _require("node:sea") as any; - isSea = sea.isSea?.() === true; - } catch { - // node:sea not available (older Node or non-SEA context) - } - - if (isSea) { - // Extract the embedded sidecar to a temp file and import it. - // The asset key matches what fossilize registered via --assets. - // oxlint-disable-next-line typescript/no-explicit-any -- node:sea types not yet in @types/node - const sea = _require("node:sea") as any; - const { writeFileSync, mkdtempSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const tmpDir = mkdtempSync(join(tmpdir(), "sentry-ink-")); - const tmpFile = join(tmpDir, "ink-app.js"); - writeFileSync(tmpFile, sea.getAsset("dist-build/ink-app.js", "utf-8")); - // Node's dynamic import() requires file:// URLs for absolute paths on Windows - const { pathToFileURL } = await import("node:url"); - importPath = pathToFileURL(tmpFile).href; - seaTmpDir = tmpDir; - } else if (inkAppPath.startsWith("./")) { - // Node/npm bundle — resolve relative to the bundle location - importPath = new URL(inkAppPath, import.meta.url).href; - } else { - // Dev mode — absolute filesystem path - importPath = inkAppPath; - } - const app = (await import(importPath)) as typeof import("./ink-app.js"); - - // Clean up SEA temp file — module is cached in memory after import() - if (seaTmpDir) { - // 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 { rmSync } = await import("node:fs"); - rmSync(seaTmpDir, { recursive: true, force: true }); - } catch { - // best-effort cleanup - } - } - - return app; -} - -/** Build the same select prompt for the wizard and inline entity pickers. */ -function createSelectPrompt( - opts: SelectOptions, - resolve: (value: string | null) => void, -): Extract { - return { - kind: "select", - message: stripAnsi(opts.message), - ...(opts.details - ? { - details: opts.details.map((detail) => ({ - ...detail, - text: stripAnsi(detail.text), - })), - } - : {}), - ...(opts.footer - ? { footer: { ...opts.footer, text: stripAnsi(opts.footer.text) } } - : {}), - options: opts.options.map((option) => ({ - value: option.value, - label: option.label, - ...(option.hint ? { hint: option.hint } : {}), - })), - initialIndex: - opts.initialValue !== undefined - ? Math.max( - 0, - opts.options.findIndex( - (option) => option.value === opts.initialValue, - ), - ) - : 0, - filterable: opts.filterable, - resolve, - }; -} - -/** - * Show an inline entity picker using the wizard's filterable select control. - * Loads the sidecar lazily and releases terminal resources after selection. - * - * @param opts - Choices, prompt copy, and initial selection. Filtering is always enabled. - * @returns The selected value, or `null` on cancellation or exit without a choice. - * @throws Propagates loading, rendering, and terminal cleanup failures. - */ -export async function selectWithFilter( - opts: SelectOptions, -): Promise { - const app = await loadInkApp(); - const freshStdin = openFreshTtyForInk(); - const stdin = freshStdin ?? process.stdin; - const wasRaw = stdin.isRaw ?? false; - let instance: InkInstance | undefined; - let resolveSelection!: (value: string | null) => void; - const selection = new Promise((resolve) => { - resolveSelection = resolve; - }); - const cancel = () => resolveSelection(null); - process.once("SIGINT", cancel); - try { - instance = app.mountSelectPrompt( - createSelectPrompt({ ...opts, filterable: true }, resolveSelection), - { stdin, exitOnCtrlC: false, patchConsole: false }, - ); - // Surface renderer failures instead of waiting forever for a selection. - return await Promise.race([ - selection, - instance.waitUntilExit().then(() => null), - ]); - } finally { - process.removeListener("SIGINT", cancel); - // Each resource must be released even if an earlier cleanup step fails. - try { - instance?.clear(); - } finally { - try { - instance?.unmount(); - await instance?.waitUntilExit(); - } finally { - try { - stdin.setRawMode?.(wasRaw); - } finally { - if (freshStdin) { - try { - freshStdin.pause(); - } finally { - freshStdin.destroy(); - } - } - } - } - } - } -} - -/** - * 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). - */ -export async function createInkUI( - opts: CreateInkUIOptions = {}, -): Promise { - const app = await loadInkApp(); - const store = new WizardStore({ - cliVersion: CLI_VERSION, - // Seed with the full banner; IntroScreen re-fits it to the live terminal - // width on every render (shrinking or growing) so it never wraps. - bannerRows: FULL_BANNER_LINES, - }); - const initialWelcome = opts.initialWelcome - ? createPendingWelcome() - : undefined; - if (opts.initialWelcome && initialWelcome) { - seedWelcomePrompt(store, opts.initialWelcome, initialWelcome); - } - const promptTelemetry = createWizardPromptTelemetry(); - - // Open a fresh /dev/tty so Ink's `readable` event listener - // actually fires — see the module docstring for the Bun bug - // details. We hold onto the stream so we can close it on dispose - // (libuv otherwise keeps the handle alive and the process can't - // exit cleanly). - const freshStdin = openFreshTtyForInk(); - - // Build render options for Ink: - // - `exitOnCtrlC: false` lets us route Ctrl+C through the prompt - // cancellation path instead of yanking the process down. - // - `patchConsole: false` keeps `console.*` calls flowing to the - // real stdout — Sentry SDK breadcrumbs, debug logs, etc. would - // otherwise be swallowed by Ink's render loop. - const renderOptions: { - exitOnCtrlC: boolean; - patchConsole: boolean; - stdin?: ReadStream; - } = { - exitOnCtrlC: false, - patchConsole: false, - }; - if (freshStdin) { - renderOptions.stdin = freshStdin; - } - // Enter the alternate screen buffer so the wizard occupies the full - // terminal. Clear and home the cursor before Ink's first frame so - // startup never shows stale layout from a prior render. - process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H"); - try { - const instance = app.mountApp(store, renderOptions); - - return new InkUI(instance, store, freshStdin, { - initialWelcome, - telemetry: promptTelemetry, - }); - } catch (error) { - // Restore the terminal if Ink rendering or UI init fails, - // otherwise the user is stuck in the alternate screen buffer. - process.stdout.write("\x1b[?1049l"); - throw error; - } -} - -/** - * Subset of the Ink `Instance` type we actually use. - * - * Defined structurally rather than imported from `ink` so the - * dynamic-import boundary in `createInkUI` doesn't leak Ink types - * into the rest of the bridge module. `rerender` takes - * `react.ReactNode` upstream; we widen it to a generic function - * type and only ever call `unmount`/`waitUntilExit`/`clear` from - * the bridge anyway. - */ -type InkInstance = { - unmount: () => void; - waitUntilExit: () => Promise; - // oxlint-disable-next-line typescript/no-explicit-any -- dynamic-import boundary - rerender: (node: any) => void; - /** - * Clears Ink's last rendered output from the terminal. We call - * this on dispose so the final post-dispose chalk summary is - * the only thing left on screen — without it the bordered - * wizard box stays above the summary, which looked redundant. - */ - clear: () => void; -}; - -// ──────────────────────────── Implementation ────────────────────────── - -/** - * Bridge between the imperative `WizardUI` surface and the Ink - * `App` component. Mutations land in the `WizardStore`; React - * re-renders. - */ -export class InkUI implements WizardUI { - private readonly instance: InkInstance; - private readonly store: WizardStore; - /** - * Fresh `/dev/tty` stream Ink reads from. We own this — closing - * it on dispose lets the libuv handle drain so `process.exit` (or - * a natural exit) actually fires. `null` when `/dev/tty` couldn't - * be opened (Windows, sandboxed environments) — Ink falls back to - * `process.stdin` in that case. - */ - private readonly freshStdin: ReadStream | null; - private readonly promptTelemetry: ReturnType< - typeof createWizardPromptTelemetry - >; - private tipTimer: ReturnType | undefined; - private learnTimer: ReturnType | undefined; - - private tipIndex = 0; - private activePromptCancel: (() => void) | undefined; - /** A resolved prompt that may remain visible until its successor is ready. */ - private completedPrompt: ActivePrompt | undefined; - private cancelHandler: (() => void) | undefined; - /** - * Guard so `tearDown()` runs at most once even when called from - * multiple paths (Ctrl+C in a spinner, then SIGINT, then - * `[Symbol.asyncDispose]` on the wizard-runner exit). Calling - * `unmount()` on an already-unmounted Ink instance throws on some - * Ink versions; running raw-mode restoration on a destroyed stream - * also throws. The flag short-circuits before either can happen. - */ - private torndown = false; - /** - * Guard so `requestCancel()` runs its no-active-prompt branch at - * most once. With this flag set, a subsequent Ctrl+C / SIGINT - * becomes a no-op rather than re-entering teardown — the user is - * already on the way out. - */ - private cancelRequested = false; - /** - * Final wizard outcome captured by the bridge. - * - * Ink renders inline so the log lines naturally land in scrollback - * — we don't need to replay a transcript on dispose. We do echo - * a final success/failure summary line after `unmount()` so the - * user has a clear "what happened" signal at the bottom of the - * scrollback. - */ - private outroMessage: string | undefined; - private failureMessage: string | undefined; - private feedbackHint: string | undefined; - private initialWelcome: PendingWelcome | undefined; - /** - * Resolved when the user presses any key on the outro screen. - * `[Symbol.asyncDispose]` awaits this so the `using` block keeps the - * UI alive until the user has seen and acknowledged the final screen. - */ - private pendingOutro: PendingOutro | undefined; - - constructor( - instance: InkInstance, - store: WizardStore, - freshStdin: ReadStream | null, - promptOptions: InkPromptOptions = {}, - ) { - this.instance = instance; - this.store = store; - this.freshStdin = freshStdin; - this.initialWelcome = promptOptions.initialWelcome; - this.promptTelemetry = - promptOptions.telemetry ?? createWizardPromptTelemetry(); - if (this.initialWelcome && !this.initialWelcome.tracedPromise) { - const initialWelcome = this.initialWelcome; - initialWelcome.tracedPromise = this.promptTelemetry.tracePrompt( - "welcome", - () => initialWelcome.promise, - ); - } - if (this.initialWelcome && !this.initialWelcome.settled) { - const initialWelcome = this.initialWelcome; - this.activePromptCancel = () => { - this.store.setPrompt(null); - this.activePromptCancel = undefined; - initialWelcome.resolve(CANCELLED); - }; - } - this.installCancelHandler(); - // Hand the App a reference to `requestCancel` via the store so - // the top-level `useInput` Ctrl+C catcher in `ink-app.tsx` can - // route through the same teardown path as SIGINT and prompt - // cancellation. Without this the App would have to call - // `process.exit(130)` directly — bypassing termios restoration - // and leaking the `/dev/tty` handle. - this.store.setRequestCancel(() => this.requestCancel()); - } - - // ── Lifecycle ───────────────────────────────────────────────────── - - banner(_art: string): void { - // No-op — the App paints the banner inside its header from the - // gradient rows pre-loaded into the store. The runner-supplied - // ANSI string is discarded. - } - - intro(_title: string): void { - // No-op. The outer box already has a title-bar feel via the - // banner; an extra "▸ sentry init" line felt redundant. - } - - outro(message: string): void { - const clean = stripAnsi(message); - this.outroMessage = clean; - // Keep the interactive completion screen mounted until the user dismisses - // it (see `[Symbol.asyncDispose]`), pausing the sidebar tip rotation so the - // final screen is stable. - this.pauseSidebarTimers(); - this.pendingOutro ??= createPendingOutro(); - // Bind the screen's side effects here (main bundle) so the Ink sidecar - // never imports Node built-ins (browser launch). - this.store.setOutro({ - kind: "success", - dismiss: () => this.dismissOutro(), - actions: { - openUrl: (url) => { - // Best-effort; openBrowser never throws, catch keeps it non-blocking. - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. - openBrowser(url).catch(() => { - // ignore - }); - }, - track: (event) => { - // Land completion-screen interactions on the run's `cli.command` - // transaction so we can see, per run, whether the user opened Sentry - // and/or chose to install the agent plugin. Tags are sticky: once a - // user has clicked "install", un-toggling leaves the click recorded. - switch (event) { - case "open-sentry": - setTag("wizard.completion.opened_sentry", "true"); - break; - case "agent-plugin-queued": - setTag("wizard.completion.agent_plugin_clicked", "true"); - break; - case "agent-plugin-unqueued": - break; - default: - break; - } - addBreadcrumb({ - category: "wizard.completion", - message: event, - level: "info", - }); - }, - }, - }); - } - - cancel(message: string): void { - const clean = stripAnsi(message); - this.appendLog("error", clean); - this.failureMessage = clean; - } - - feedback(outcome: InitFeedbackOutcome): void { - this.feedbackHint = formatFeedbackHint(outcome); - } - - summary(summary: WizardSummary): void { - this.store.setSummary(summary); - } - - recordFilesReading(paths: string[]): void { - this.store.recordFilesReading(paths); - } - - markFilesAnalyzed(paths: string[]): void { - this.store.markFilesAnalyzed(paths); - } - - setStep( - stepId: string, - status: "in_progress" | "completed" | "failed" | "skipped", - ): void { - this.promptTelemetry.setActiveStep(stepId, status === "in_progress"); - this.store.setStepStatus(stepId, status); - } - - setOverlay(overlay: { - kind: string; - message: string; - retryCount: number; - }): void { - this.store.setOverlay({ - kind: "health", - message: overlay.message, - retryCount: overlay.retryCount, - }); - } - - clearOverlay(): void { - this.store.clearOverlay(); - } - - setIntroMode(enabled: boolean): void { - if (enabled) { - this.store.setLayout("intro"); - this.pauseSidebarTimers(); - return; - } - this.store.setLayout("workflow"); - this.startSidebarTimers(); - } - - // ── Logging ─────────────────────────────────────────────────────── - - log: WizardLog = { - info: (message) => this.appendLog("info", message), - warn: (message) => this.appendLog("warn", message), - error: (message) => this.appendLog("error", message), - success: (message) => this.appendLog("success", message), - message: (message) => this.appendLog("message", message), - }; - - // ── Spinner ─────────────────────────────────────────────────────── - - spinner(): SpinnerHandle { - return { - start: (message?: string) => { - const clean = stripAnsi(message ?? ""); - if ( - this.completedPrompt && - this.store.getSnapshot().prompt === this.completedPrompt - ) { - this.store.replacePromptWithSpinner(clean); - this.completedPrompt = undefined; - } else { - this.store.startSpinner(clean); - } - if (clean) { - this.store.appendStatus(clean); - } - }, - message: (message?: string) => { - if (message !== undefined) { - const clean = stripAnsi(message); - this.store.setSpinnerMessage(clean); - if (clean) { - this.store.appendStatus(clean); - } - } - }, - stop: (message?: string, code: SpinnerExitCode = 0) => { - const finalMessage = - message !== undefined - ? stripAnsi(message) - : this.store.getSnapshot().spinner.message; - this.store.stopSpinner(); - if (finalMessage) { - this.appendLog(severityForStopCode(code), finalMessage); - } - }, - }; - } - - // ── Prompts ─────────────────────────────────────────────────────── - - /** Measures only the interval in which the prompt can accept user input. */ - private waitForPrompt( - kind: WizardPromptKind, - mount: (resolve: (value: T) => void) => void, - ): Promise { - return this.promptTelemetry.tracePrompt(kind, () => new Promise(mount)); - } - - /** - * Defers cleanup so the resumed promise chain can replace the completed prompt. - * The identity check prevents the old cleanup from removing its successor. - */ - private completePrompt( - prompt: ActivePrompt, - value: T, - resolve: (resolvedValue: T) => void, - ): void { - this.activePromptCancel = undefined; - this.completedPrompt = prompt; - resolve(value); - setImmediate(() => { - if (this.store.getSnapshot().prompt === prompt) { - this.store.setPrompt(null); - } - if (this.completedPrompt === prompt) { - this.completedPrompt = undefined; - } - }); - } - - select(opts: SelectOptions): Promise { - return this.waitForPrompt("select", (resolve) => { - let settled = false; - this.activePromptCancel = () => { - if (settled) { - return; - } - settled = true; - this.store.setPrompt(null); - this.activePromptCancel = undefined; - resolve(CANCELLED); - }; - const prompt = createSelectPrompt(opts, (value) => { - if (settled) { - return; - } - settled = true; - this.completePrompt( - prompt, - value === null ? CANCELLED : (value as T), - resolve, - ); - }); - this.store.setPrompt(prompt); - }); - } - - multiselect( - opts: MultiSelectOptions, - ): Promise { - return this.waitForPrompt("multiselect", (resolve) => { - let settled = false; - this.activePromptCancel = () => { - if (settled) { - return; - } - settled = true; - this.store.setPrompt(null); - this.activePromptCancel = undefined; - resolve(CANCELLED); - }; - const prompt: Extract = { - kind: "multiselect", - message: stripAnsi(opts.message), - ...(opts.details - ? { - details: opts.details.map((detail) => ({ - ...detail, - text: stripAnsi(detail.text), - })), - } - : {}), - options: opts.options.map((option) => ({ - value: option.value, - label: option.label, - ...(option.hint ? { hint: option.hint } : {}), - ...(option.description ? { description: option.description } : {}), - ...(option.locked ? { locked: true } : {}), - })), - initialSelected: opts.initialValues ?? [], - required: opts.required ?? false, - resolve: (values) => { - if (settled) { - return; - } - settled = true; - this.completePrompt( - prompt, - values === null ? CANCELLED : (values as T[]), - resolve, - ); - }, - }; - this.store.setPrompt(prompt); - }); - } - - confirm(opts: ConfirmOptions): Promise { - return this.waitForPrompt("confirm", (resolve) => { - this.activePromptCancel = () => { - this.store.setPrompt(null); - this.activePromptCancel = undefined; - resolve(CANCELLED); - }; - this.store.setPrompt({ - kind: "confirm", - message: stripAnsi(opts.message), - initialValue: opts.initialValue ?? true, - resolve: (value) => { - this.store.setPrompt(null); - this.activePromptCancel = undefined; - if (value === null) { - resolve(CANCELLED); - } else { - resolve(value); - } - }, - }); - }); - } - - welcome(opts: WelcomeOptions): Promise<"continue" | Cancelled> { - this.store.setLayout("intro"); - this.pauseSidebarTimers(); - if (this.initialWelcome) { - if (!this.initialWelcome.settled) { - seedWelcomePrompt(this.store, opts, this.initialWelcome); - } - const initialWelcome = this.initialWelcome; - const promptPromise = - initialWelcome.tracedPromise ?? - this.promptTelemetry.tracePrompt( - "welcome", - () => initialWelcome.promise, - ); - return promptPromise.finally(() => { - this.activePromptCancel = undefined; - this.initialWelcome = undefined; - }); - } - return this.waitForPrompt<"continue" | Cancelled>("welcome", (resolve) => { - this.activePromptCancel = () => { - this.store.setPrompt(null); - this.activePromptCancel = undefined; - resolve(CANCELLED); - }; - this.store.setPrompt({ - kind: "welcome", - options: sanitizeWelcomeOptions(opts), - resolve: (value) => { - this.store.setPrompt(null); - this.activePromptCancel = undefined; - if (value === null) { - resolve(CANCELLED); - } else { - resolve(value); - } - }, - }); - }); - } - - // ── Disposal ────────────────────────────────────────────────────── - - async [Symbol.asyncDispose](): Promise { - // Keep the completion screen alive until the user acknowledges it, then - // tear down the alternate screen and run any commands they queued (e.g. - // the agent-plugin installer) in their real terminal. - const pendingOutro = this.pendingOutro; - if (pendingOutro && !pendingOutro.settled && !this.torndown) { - await pendingOutro.promise; - } - this.tearDown(); - await this.runPostExitActions(); - } - - /** Resolve the completion-screen handoff so async disposal can proceed. */ - private dismissOutro(): void { - this.pendingOutro?.resolve(); - } - - /** - * Run commands the completion screen queued, now that the alternate screen is - * gone and the real terminal is restored. Interactive installers (e.g. - * `npx @sentry/ai install`) need the real TTY, which the alt-screen denied. - */ - private async runPostExitActions(): Promise { - const actions = this.store.getSnapshot().postExitActions; - if (actions.length > 0) { - // The agent-plugin installer is the only thing the completion screen - // queues, so a non-empty list here means the user finished with it queued - // and we're about to actually run it — the strongest "installed" signal. - setTag("wizard.completion.agent_plugin_installed", "true"); - } - for (const command of actions) { - process.stdout.write(`\n$ ${command}\n`); - await runInheritedCommand(command); - } - } - - /** - * Idempotent teardown. Safe to call from `[Symbol.asyncDispose]`, - * from `requestCancel()`, or from a SIGINT handler racing both. The - * `torndown` guard short-circuits second (and later) entries so we - * never call `unmount()` on an already-unmounted Ink instance or - * `setRawMode(false)` on an already-destroyed stream — both throw - * on some platforms. - * - * Order matters: - * 1. Stop the tip-rotation interval (libuv timer ref). - * 2. Detach SIGINT listener (we don't want a second Ctrl+C - * re-entering this path while we're in the middle of it). - * 3. `instance.clear()` — rewinds Ink's render region so the - * post-dispose chalk summary lands in place of the live - * wizard chrome rather than below it. - * 4. `instance.unmount()` — releases React reconciler resources. - * 5. Restore termios on the fresh `/dev/tty` stream, then - * `pause()` + `destroy()` so libuv can drain the handle and - * the process can exit naturally. - * 6. Emit the post-dispose summary to stdout (success outro or - * failure cancel line, matching the live screen's palette). - * - * Every step is wrapped in try/catch — disposal must never throw. - */ - private tearDown(): void { - if (this.torndown) { - return; - } - this.torndown = true; - if (this.tipTimer) { - clearInterval(this.tipTimer); - this.tipTimer = undefined; - } - this.stopLearnSequence(); - if (this.cancelHandler) { - process.removeListener("SIGINT", this.cancelHandler); - this.cancelHandler = undefined; - } - // Detach the cancel callback from the store so a stale Ctrl+C - // routed through the App after teardown can't re-enter. - this.store.setRequestCancel(undefined); - // 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 { - this.instance.clear(); - } catch { - // best-effort - } - // 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 { - this.instance.unmount(); - } catch { - // best-effort - } - // Leave the alternate screen buffer so the user's original - // scrollback is restored. - // - // When the user queued an interactive post-exit action (the agent-plugin - // installer), also clear the restored screen and home the cursor. Exiting - // the alt buffer returns the cursor to the row where `sentry init` was - // invoked — usually low on the screen — so without this the exit summary - // and the installer's own full-screen UI would render from mid-screen with - // a blank gap above. Clearing gives the handoff the same clean top-of-screen - // start as wizard startup (line ~362). The normal exit (no installer) is - // left untouched so its compact summary flows into scrollback as before. - const hasPostExitActions = - this.store.getSnapshot().postExitActions.length > 0; - // 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 { - process.stdout.write( - hasPostExitActions ? "\x1b[?1049l\x1b[2J\x1b[H" : "\x1b[?1049l", - ); - } catch { - // best-effort — stdout may already be destroyed - } - if (this.freshStdin) { - // 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 { - this.freshStdin.setRawMode(false); - } catch { - // stream already torn down - } - // 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 { - this.freshStdin.pause(); - this.freshStdin.destroy(); - } catch { - // stream already destroyed - } - } - const report = this.buildPostDisposeReport(); - if (report) { - // Write to stdout (not stderr) so the summary lands in the - // same stream as the cleared Ink output. Mixing stderr in - // would risk an extra line break or out-of-order interleave - // depending on shell pipe handling. - process.stdout.write(`${report}\n`); - } - } - - /** - * Cooperative cancellation entry point. Called from three places: - * - * 1. The App's top-level `useInput` Ctrl+C catcher (when no - * prompt is mounted — typically during a spinner / network - * call). Routed via `store.requestCancel()`. - * 2. The SIGINT process listener (covers raw-mode-off windows - * where Node delivers SIGINT instead of `\x03`). - * 3. (Indirectly) prompt cancellation, when an active prompt's - * own `useInput` resolves with `null`. That path doesn't go - * through `requestCancel` directly because the prompt's - * promise resolution drives the wizard runner's - * `WizardCancelledError` flow, which then runs - * `[Symbol.asyncDispose]` → `tearDown()` naturally. - * - * If a prompt IS active, we delegate to its cancel callback and - * return without exiting — the wizard runner will catch the - * resulting `WizardCancelledError` and exit cleanly via the - * `await using` path. - * - * If no prompt is active (spinner case), we tear down immediately - * and `process.exit(130)`. We can't route through the runner - * because it's blocked on `await executeTool(...)` or - * `await run.resumeAsync(...)` — there's nothing waiting to throw - * into. Exit code 130 is the SIGINT convention; the terminal is - * fully restored before exit so the user's shell prompt comes - * back cleanly. - * - * A second Ctrl+C while teardown is in progress force-exits via - * `process.exit(130)` so the user is never trapped by a stuck - * teardown. - */ - requestCancel(): void { - // On the completion screen, Ctrl+C is just "I'm done" — acknowledge the - // handoff and let async disposal exit cleanly (0), not the 130 abort path. - if (this.pendingOutro && !this.pendingOutro.settled) { - this.dismissOutro(); - return; - } - const promptCancel = this.activePromptCancel; - if (promptCancel) { - // Prompt path — let the runner unwind via WizardCancelledError. - // Don't tear down here; the `await using` in the runner will - // call us back through `[Symbol.asyncDispose]`. - promptCancel(); - return; - } - if (this.cancelRequested) { - // Safety valve: teardown already started but hasn't finished - // (or something is stuck). Force-exit so the user isn't trapped. - setTag("wizard.outcome", "abandoned"); - // oxlint-disable-next-line sentry-cli/no-process-exit -- a second Ctrl+C while teardown is stuck must not trap the user. - process.exit(130); - } - this.cancelRequested = true; - this.failureMessage = "Setup cancelled."; - this.feedback("cancelled"); - this.tearDown(); - // Mark as abandoned before exit so the Sentry span carries the - // outcome even though beforeExit never fires for explicit process.exit(). - setTag("wizard.outcome", "abandoned"); - // Match the SIGINT convention so shells (and CI) see a - // distinguishable exit. The runner's `await using` won't get a - // chance to run after this, but tearDown above already did all - // the cleanup that path would have performed. - // Defer exit by one tick so the event loop can flush the - // stdout writes from tearDown (alternate-screen escape + - // cancellation report) before the process terminates. - // oxlint-disable-next-line sentry-cli/no-process-exit -- Ctrl+C cancels the wizard; exit 130 after teardown. - setImmediate(() => process.exit(130)); - } - - /** - * Build a compact final summary echoed to stdout after Ink - * unmounts. Ink's inline rendering means the run's log lines are - * already in the user's scrollback; this report just emphasises - * the outcome so it's the last thing on screen. - * - * Three shapes: - * - Success: outro line + summary fields + changed files. - * - Failure: cancel/error line on its own. - * - Empty: no useful state captured (early abort, etc.) — - * return `undefined` and the caller skips the - * stderr write. - * - * Failure wins over success if both are set. - */ - private buildPostDisposeReport(): string | undefined { - if (this.failureMessage) { - return formatFailureReport( - this.failureMessage, - this.store.getSnapshot().logs, - this.feedbackHint, - ); - } - if (!this.outroMessage) { - return; - } - const summary = this.store.getSnapshot().summary ?? undefined; - // The interactive completion screen already showed the full summary; on - // exit leave only a compact confirmation rather than re-dumping everything. - if (summary?.completion) { - return formatSuccessExitLine(summary); - } - return formatSuccessReport(this.outroMessage, summary, this.feedbackHint); - } - - // ── Internal helpers ────────────────────────────────────────────── - - private appendLog(severity: LogSeverity, message: string): void { - this.store.appendLog(severity, stripAnsi(message)); - } - - private startTipRotation(): void { - if (this.tipTimer) { - return; - } - this.tipTimer = setInterval(() => { - this.tipIndex = (this.tipIndex + 1) % SENTRY_TIPS.length; - this.store.setTipIndex(this.tipIndex); - }, TIP_ROTATE_INTERVAL_MS); - } - - private startLearnSequence(): void { - if (this.learnTimer) { - return; - } - const store = this.store; - this.learnTimer = setInterval(() => { - if (this.torndown) { - this.stopLearnSequence(); - return; - } - const { learnState } = store.getSnapshot(); - if (learnState.complete) { - this.stopLearnSequence(); - if (!this.torndown) { - this.startTipRotation(); - } - return; - } - const next = learnState.blockIndex + 1; - if (next >= LEARN_SEQUENCE.length) { - store.setLearnComplete(); - this.stopLearnSequence(); - if (!this.torndown) { - this.startTipRotation(); - } - } else { - store.advanceLearnBlock(); - } - }, TIP_ROTATE_INTERVAL_MS); - } - - private stopLearnSequence(): void { - if (this.learnTimer) { - clearInterval(this.learnTimer); - this.learnTimer = undefined; - } - } - - private startSidebarTimers(): void { - if (this.torndown) { - return; - } - if (this.store.getSnapshot().learnState.complete) { - this.startTipRotation(); - return; - } - this.startLearnSequence(); - } - - private pauseSidebarTimers(): void { - if (this.tipTimer) { - clearInterval(this.tipTimer); - this.tipTimer = undefined; - } - this.stopLearnSequence(); - } - - /** - * Fallback SIGINT handler for the (rare) windows where raw mode - * is OFF and Node's terminal layer DOES deliver SIGINT for - * Ctrl+C. The primary Ctrl+C handling lives inside Ink's - * `useInput` (see `ink-app.tsx`'s top-level App component): in - * raw mode, Node sends `\x03` as a byte instead of SIGINT. - * - * This handler covers the brief window between InkUI - * construction and the first `useInput` listener being mounted, - * plus any time raw mode flickers off (Ink toggles it in a - * useEffect when the listener count drops to zero). - * - * Both this handler and the App's `useInput` Ctrl+C path funnel - * into `requestCancel()` so the cancellation flow has a single - * implementation. Uses `process.on` so the handler survives a - * prompt-delegation Ctrl+C (where `requestCancel` returns early - * without setting `cancelRequested`). If teardown is already in - * progress, `requestCancel` force-exits — protects against a - * stuck teardown holding the user hostage. - */ - private installCancelHandler(): void { - const handler = () => { - this.requestCancel(); - }; - this.cancelHandler = handler; - process.on("SIGINT", handler); - } -} diff --git a/packages/cli/src/lib/init/ui/learn-content.ts b/packages/cli/src/lib/init/ui/learn-content.ts deleted file mode 100644 index 473f8146f..000000000 --- a/packages/cli/src/lib/init/ui/learn-content.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Educational Content Sequence - * - * Content blocks shown in the sidebar while the wizard runs, - * transforming dead wait time into product education. After all - * blocks complete, the panel falls back to rotating tip cards. - * - * All blocks MUST have exactly `BLOCK_LINE_COUNT` content lines - * (pad with empty strings) so the panel height stays fixed and - * doesn't jump when blocks rotate. - */ - -export type ContentBlock = { - title: string; - lines: string[]; -}; - -/** Fixed line count per block — keeps panel height stable. */ -export const BLOCK_LINE_COUNT = 8; - -export const LEARN_SEQUENCE: ContentBlock[] = [ - { - title: "How Sentry Works", - lines: [ - "App → SDK → Sentry → Issue", - "", - "The SDK runs in your app.", - "It sends errors, traces, and", - "context from production to", - "Sentry, where related events", - "become issues with the clues", - "you need to fix them faster.", - ], - }, - { - title: "Debug With Context", - lines: [ - "Issue → Trace → Replay → Fix", - "", - "Start with the grouped issue.", - "Traces show the request path.", - "Replay shows the user session.", - "Logs show what happened nearby.", - "Releases show what changed.", - "That context points to the fix.", - ], - }, - { - title: "Error Tracking", - lines: [ - "Every crash is captured with:", - "", - " • Full stack trace", - " • Breadcrumbs & context", - " • Release & commit info", - "", - "Errors are grouped into issues", - "so you fix causes, not symptoms.", - ], - }, - { - title: "Performance Tracing", - lines: [ - "Traces show the full journey:", - "", - " Request ─┬─ DB (120ms)", - " ├─ API (340ms)", - " └─ Render (80ms)", - "", - "Find the slow piece without", - "adding manual timers.", - ], - }, - { - title: "Session Replay", - lines: [ - "See what the user saw: DOM", - "mutations, clicks, network", - "calls, and console logs —", - "all synced to the error.", - "", - "Debug by scrubbing a video,", - "not reading a stack trace.", - "", - ], - }, - { - title: "Alerts & Integrations", - lines: [ - "Get notified when it matters:", - "", - " • Error spike after deploy", - " • Slow transaction p95", - " • New regression detected", - "", - "Routes to Slack, PagerDuty,", - "or email automatically.", - ], - }, - { - title: "What's Next?", - lines: [ - "After setup finishes, try:", - "", - " sentry issue list", - " → see your first errors", - " sentry issue explain ", - " → AI root-cause analysis", - " sentry trace list", - " → explore performance", - ], - }, -]; diff --git a/packages/cli/src/lib/init/ui/logging-ui.ts b/packages/cli/src/lib/init/ui/logging-ui.ts deleted file mode 100644 index 64ae5c3dd..000000000 --- a/packages/cli/src/lib/init/ui/logging-ui.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * LoggingUI — non-interactive WizardUI implementation. - * - * Used in CI, with `--yes`, when stdin/stdout is not a TTY, or when the - * user explicitly opts out via `SENTRY_INIT_TUI=0`. Output is plain text - * written directly to stdout/stderr — no ANSI control sequences, no - * spinners, no alternate screen buffer, no prompt rendering. - * - * Prompt methods (`select`, `multiselect`, `confirm`) throw a - * `LoggingUIPromptError`. Callers MUST resolve all interactive choices - * (org, project, team, features, confirmations) up-front through CLI - * flags or `--yes` defaults before invoking any UI prompt method. This - * mirrors PostHog wizard's approach: in CI, the I/O layer cannot fall - * back to stdin reads. - * - * The spinner is a no-op shape — `start`/`message`/`stop` log key - * transitions but do not render an animated indicator. This keeps CI - * logs deterministic and free of carriage returns. - */ - -import { - renderInlineMarkdown, - renderMarkdown, -} from "../../formatters/markdown.js"; -import { renderTextTable } from "../../formatters/text-table.js"; -import { formatFeedbackHint, type InitFeedbackOutcome } from "../feedback.js"; -import { buildFileTree, flattenTree } from "./file-tree.js"; -import type { - ConfirmOptions, - MultiSelectOptions, - SelectOptions, - SpinnerExitCode, - SpinnerHandle, - WizardLog, - WizardSummary, - WizardUI, -} from "./types.js"; - -/** - * Thrown when an interactive prompt is invoked under `LoggingUI`. - * - * The wizard runs in a non-interactive context and the caller did not - * pre-resolve the choice. The message identifies which prompt was - * unexpectedly reached so it can be surfaced as a setup error. - */ -export class LoggingUIPromptError extends Error { - constructor( - promptKind: "select" | "multiselect" | "confirm", - message: string, - ) { - super( - `Cannot show ${promptKind} prompt in non-interactive mode: ${message}. ` + - "Pass --yes or provide the value via CLI flags / environment variables.", - ); - this.name = "LoggingUIPromptError"; - } -} - -/** - * Optional configuration for `LoggingUI`. Mainly used by tests to redirect - * output away from the real `process.stdout`/`process.stderr`. - */ -export type LoggingUIOptions = { - stdout?: NodeJS.WritableStream; - stderr?: NodeJS.WritableStream; -}; - -const DEFAULT_OPTIONS: Required = { - stdout: process.stdout, - stderr: process.stderr, -}; - -/** - * Plain stdout/stderr WizardUI. See module doc for behavior. - */ -export class LoggingUI implements WizardUI { - private readonly stdout: NodeJS.WritableStream; - private readonly stderr: NodeJS.WritableStream; - - constructor(options: LoggingUIOptions = {}) { - this.stdout = options.stdout ?? DEFAULT_OPTIONS.stdout; - this.stderr = options.stderr ?? DEFAULT_OPTIONS.stderr; - } - - // ── Lifecycle ───────────────────────────────────────────────────── - - banner(_art: string): void { - // No-op — LoggingUI is used in non-TTY / CI contexts where the - // large ASCII banner wastes vertical space and adds noise for - // programmatic consumers (agents, scripts, piped output). - } - - intro(title: string): void { - this.writeLine(this.stdout, title); - } - - summary(summary: WizardSummary): void { - if ( - summary.fields.length === 0 && - !summary.changedFiles?.length && - !summary.featureBlurbs?.length - ) { - return; - } - // Compact two-column key/value listing — one line per field. The - // label is right-padded to a stable width so the values align in - // the user's terminal even without a tabulated renderer. - const labelWidth = Math.max( - ...summary.fields.map((field) => field.label.length), - 0, - ); - this.writeLine(this.stdout, ""); - for (const field of summary.fields) { - const padded = field.label.padEnd(labelWidth); - this.writeLine(this.stdout, ` ${padded} ${field.value}`); - } - if (summary.featureBlurbs && summary.featureBlurbs.length > 0) { - this.writeLine(this.stdout, ""); - this.writeLine(this.stdout, " Here's what we set up"); - const tableRows = summary.featureBlurbs.map(({ label, blurb }) => [ - label, - blurb, - ]); - const table = renderTextTable(["", ""], tableRows, { - shrinkable: [false, true], - }); - for (const line of table.trimEnd().split("\n")) { - this.writeLine(this.stdout, ` ${line}`); - } - } - if (summary.changedFiles && summary.changedFiles.length > 0) { - this.writeLine(this.stdout, ""); - this.writeLine(this.stdout, " Changed files:"); - // Render as a directory tree so collapsed common prefixes match - // what the InkUI panel + post-dispose summary report show. - const tree = buildFileTree(summary.changedFiles); - for (const row of flattenTree(tree)) { - this.writeLine(this.stdout, ` ${formatTreeRowPlain(row)}`); - } - } - } - - outro(message: string): void { - this.writeLine(this.stdout, message); - } - - cancel(message: string): void { - this.writeLine(this.stderr, message); - } - - feedback(outcome: InitFeedbackOutcome): void { - this.writeLine(this.stdout, formatFeedbackHint(outcome)); - this.writeLine(this.stdout, ""); - } - - // ── Logging ─────────────────────────────────────────────────────── - - log: WizardLog = { - info: (message: string) => - this.writeLine(this.stdout, `info: ${this.renderInline(message)}`), - warn: (message: string) => - this.writeLine(this.stderr, `warn: ${this.renderInline(message)}`), - error: (message: string) => - this.writeLine(this.stderr, `error: ${this.renderInline(message)}`), - success: (message: string) => - this.writeLine(this.stdout, `ok: ${this.renderInline(message)}`), - message: (message: string) => - this.writeLine(this.stdout, renderMarkdown(message)), - }; - - // ── Spinner (no-op renderer; logs lifecycle transitions) ────────── - - spinner(): SpinnerHandle { - let active = false; - return { - start: (message?: string) => { - active = true; - if (message) { - this.writeLine(this.stdout, this.renderInline(message)); - } - }, - message: (message?: string) => { - if (active && message) { - this.writeLine(this.stdout, this.renderInline(message)); - } - }, - stop: (message?: string, code: SpinnerExitCode = 0) => { - if (!active) { - return; - } - active = false; - if (message) { - const stream = code === 1 ? this.stderr : this.stdout; - const prefix = stopPrefix(code); - this.writeLine(stream, `${prefix} ${this.renderInline(message)}`); - } - }, - }; - } - - // ── Prompts (throw — caller must pre-resolve) ───────────────────── - - select(opts: SelectOptions): Promise { - return Promise.reject(new LoggingUIPromptError("select", opts.message)); - } - - multiselect(opts: MultiSelectOptions): Promise { - return Promise.reject( - new LoggingUIPromptError("multiselect", opts.message), - ); - } - - confirm(opts: ConfirmOptions): Promise { - return Promise.reject(new LoggingUIPromptError("confirm", opts.message)); - } - - // ── Disposal ────────────────────────────────────────────────────── - - [Symbol.asyncDispose](): Promise { - // No teardown needed — LoggingUI holds no resources beyond the - // injected stream references. - return Promise.resolve(); - } - - // ── Internal helpers ────────────────────────────────────────────── - - private writeLine(stream: NodeJS.WritableStream, text: string): void { - stream.write(`${text}\n`); - } - - private renderInline(message: string): string { - return renderInlineMarkdown(message); - } -} - -/** - * Map a change action ("create" | "delete" | "modify" | other) to a - * single-character glyph. Plain ASCII so it stays readable on - * terminals without unicode rendering. - */ -function changedFileGlyph(action: string): string { - if (action === "create") { - return "+"; - } - if (action === "delete") { - return "−"; - } - return "~"; -} - -/** - * Render a single `FileTreeRow` for the LoggingUI's stdout summary. - * No colors — same shape as the InkUI / post-dispose tree, but - * box-drawing characters and glyphs ship as plain text so CI logs - * stay greppable. - */ -function formatTreeRowPlain(row: { - prefix: string; - branch: string; - kind: "file" | "directory"; - label: string; - action?: string; -}): string { - const branchPart = `${row.prefix}${row.branch}`; - if (row.kind === "directory") { - return `${branchPart} ${row.label}`; - } - return `${branchPart} ${changedFileGlyph(row.action ?? "modify")} ${row.label}`; -} - -function stopPrefix(code: SpinnerExitCode): string { - switch (code) { - case 0: - return "ok:"; - case 1: - return "error:"; - default: - return "warn:"; - } -} diff --git a/packages/cli/src/lib/init/ui/sentry-tips.ts b/packages/cli/src/lib/init/ui/sentry-tips.ts deleted file mode 100644 index c490ee2a8..000000000 --- a/packages/cli/src/lib/init/ui/sentry-tips.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Sentry Tips - * - * Curated set of short product facts shown rotating in the sidebar of - * the Ink sidebar while the wizard runs. Each tip should: - * - * - fit comfortably in ~36 columns (the sidebar width) when wrapped - * - mention a concrete capability the user can apply after onboarding - * - avoid sales copy — the wizard isn't a marketing surface - * - * The runner picks one tip on mount and rotates through the rest on a - * fixed interval, so the panel feels alive even during long-running - * tool calls. Tips ARE NOT used by the LoggingUI path. - */ - -export type SentryTip = { - /** Short heading rendered as the section title. */ - title: string; - /** 1–3 sentences of body text. Plain prose, no markdown. */ - body: string; -}; - -/** - * Tip library. Order is the rotation order — keep highest-impact tips - * first so users who only see the wizard for a few seconds catch them. - */ -export const SENTRY_TIPS: SentryTip[] = [ - { - title: "Errors → Traces in one click", - body: "Every error in Sentry is linked to the trace that produced it. From an issue page, jump straight to the full request waterfall to see what slow query or upstream call set off the failure.", - }, - { - title: "Session Replay shows the user's view", - body: "Replay keeps UI changes, clicks, network calls, and console logs next to the related error. When the stack trace says what broke, replay shows what the user did before it.", - }, - { - title: "Tracing finds the slow piece", - body: "Tracing connects the slow or failed request to its spans, so you can see whether the database, an HTTP call, rendering, or your own code caused the wait.", - }, - { - title: "Alerts on real signals", - body: "Configure alert rules on issue frequency, regression after release, or trace duration percentiles. Slack, PagerDuty, and email integrations route alerts to the right team automatically.", - }, - { - title: "Releases tie deploys to errors", - body: "Tag every deploy with a release version and Sentry will tell you which commits introduced new issues, which were resolved by a release, and which are still regressing in production.", - }, - { - title: "Source maps make stack traces readable", - body: "Source maps connect minified production frames back to the code you wrote. Upload them with the release so an issue opens on the useful TypeScript or JSX line.", - }, - { - title: "Cron monitoring catches missed jobs", - body: "Wrap a scheduled job with Sentry's Crons SDK and get an alert when it fails or doesn't run on time — useful for nightly reports, billing rollups, and ETL pipelines.", - }, - { - title: "User Feedback widget", - body: 'Drop a feedback widget on your site and Sentry attaches user reports directly to the matching error. No more triaging vague "the app broke" tickets without context.', - }, - { - title: "Profiling for hot code paths", - body: "Profiling adds function-level cost to the same debugging story. Pair it with a trace to see which code path burned CPU during a slow transaction.", - }, - { - title: "Agent Tracing for LLM apps", - body: "If your app calls an LLM, Sentry's Agent Tracing surfaces token cost, latency, and failure rate per model and per route. Catch a regression in prompt cost before the bill arrives.", - }, - { - title: "Seer: AI-powered debugging", - body: "After setup, run `sentry issue explain `. Seer uses the issue, stack trace, and nearby context to summarize the likely cause and point at a fix.", - }, - { - title: "Self-hosted is a flag away", - body: "Using self-hosted Sentry? Point the CLI at your instance with `SENTRY_URL`. Your SDK setup and debugging workflow stay the same.", - }, -]; diff --git a/packages/cli/src/lib/init/ui/types.ts b/packages/cli/src/lib/init/ui/types.ts deleted file mode 100644 index cb5d04690..000000000 --- a/packages/cli/src/lib/init/ui/types.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * WizardUI Abstraction Layer - * - * Defines the I/O surface used by the init wizard. Concrete implementations - * provide the actual rendering: - * - * - `InkUI` — Ink-based React UI. Default for interactive runs on - * both the Bun binary and the npm/Node distribution. - * The Bun binary embeds Ink via `with { type: "file" }`; - * the npm package ships a self-contained ESM sidecar - * (`dist/ink-app.js`) loaded via dynamic `import()`. - * - `LoggingUI` — plain stdout/stderr writes for CI, `--yes`, non-TTY - * environments, and the `--no-tui` escape hatch. - * Prompts throw — non-interactive callers must supply - * defaults. - * - * The factory in `factory.ts` picks an implementation per run. - * - * Goals: - * 1. Stable prompt API surface so the wizard itself never changes when - * we swap implementations. - * 2. Use a shared cancellation symbol (`CANCELLED`) so all - * implementations can signal cancellation uniformly. Callers wrap - * prompt results with `abortIfCancelled()` (in `clack-utils.ts`) - * which re-throws as `WizardCancelledError`. - * 3. Stay lean — visual look-and-feel inspiration from PostHog wizard's - * `WizardUI` pattern, without the screen router / nanostore / health - * check overlays. - */ - -import type { InitFeedbackOutcome } from "../feedback.js"; - -/** Sentinel symbol returned by prompt methods when the user cancels. */ -export const CANCELLED: unique symbol = Symbol.for( - "sentry-cli:wizard-ui:cancelled", -); -export type Cancelled = typeof CANCELLED; - -/** Type guard for the shared cancellation sentinel. */ -export function isCancelled(value: unknown): value is Cancelled { - return value === CANCELLED; -} - -/** - * Spinner exit status. - * - * - `0` — success (rendered as a green diamond / "Done") - * - `1` — error (rendered as a red square) - * - `2` — warning (rendered as a yellow triangle) - */ -export type SpinnerExitCode = 0 | 1 | 2; - -/** - * Multi-line spinner handle. - * - * Mirrors the existing `WizardSpinner` shape in `src/lib/init/spinner.ts` - * so the long-running suspend/resume loop in `wizard-runner.ts` can swap - * implementations without changing its control flow. - */ -export type SpinnerHandle = { - /** Begin spinning with an optional initial message. */ - start(message?: string): void; - /** Update the message in place while spinning. */ - message(message?: string): void; - /** - * Stop spinning and finalize the block with `message`. The exit `code` - * controls the icon (0 ok, 1 error, 2 warn). - */ - stop(message?: string, code?: SpinnerExitCode): void; -}; - -/** - * Inline log API. Each method renders a single line (or markdown-rendered - * block, in the case of `message`). In `LoggingUI` these go straight to - * stdout/stderr; in TUI implementations they accumulate in a scrollable - * pane. - */ -export type WizardLog = { - /** Informational — neutral icon. */ - info(message: string): void; - /** Warning — yellow icon. */ - warn(message: string): void; - /** Error — red icon. */ - error(message: string): void; - /** Success — green icon. */ - success(message: string): void; - /** Plain markdown-rendered block (no icon). */ - message(message: string): void; -}; - -/** Single option in a `select` / `multiselect` prompt. */ -export type SelectOption = { - /** Machine-readable value returned when the user chooses this option. */ - value: T; - /** User-facing option label. */ - label: string; - /** Optional secondary copy rendered beside the label; omitted by default. */ - hint?: string; -}; - -/** Option in a multiselect prompt, optionally carrying supporting copy or a fixed selection. */ -export type MultiSelectOption = SelectOption & { - /** Product-oriented copy rendered below the label; omitted by default. */ - description?: string; - /** When true, keeps the option selected and skips it during toggles. Defaults to false. */ - locked?: boolean; -}; - -/** Supporting row rendered beneath a prompt title. */ -export type PromptDetail = { - /** Text displayed on its own row. */ - text: string; - /** Semantic color treatment for the row. Defaults to `muted`. */ - tone?: "muted" | "success"; -}; - -/** Args for `select`. */ -export type SelectOptions = { - /** Prompt title shown above the choices. */ - message: string; - /** Supporting rows rendered below the title; omitted by default. */ - details?: PromptDetail[]; - /** Explanatory copy rendered between details and actions; omitted by default. */ - footer?: PromptDetail; - /** Choices presented to the user in display order. */ - options: SelectOption[]; - /** Allow typing to filter entity choices by label, value, or hint. Defaults to false. */ - filterable?: boolean; - /** Initially highlighted value. Defaults to the first option. */ - initialValue?: T; -}; - -/** Args for `multiselect`. */ -export type MultiSelectOptions = { - /** Prompt title shown above the choices. */ - message: string; - /** Supporting rows rendered above a visually separated option list; omitted by default. */ - details?: PromptDetail[]; - /** Choices presented to the user in display order. */ - options: MultiSelectOption[]; - /** Values selected when the prompt mounts. Defaults to an empty list. */ - initialValues?: T[]; - /** Whether at least one value is required before continuing. Defaults to false. */ - required?: boolean; -}; - -/** Args for `confirm`. */ -export type ConfirmOptions = { - /** Confirmation question shown to the user. */ - message: string; - /** Initially highlighted answer. Defaults to false. */ - initialValue?: boolean; -}; - -/** Args for the richer Ink-only welcome screen. */ -export type WelcomeOptions = { - /** Heading shown on the welcome screen. */ - title: string; - /** Supporting paragraphs shown below the heading. */ - body: string[]; - /** Final call-to-action copy shown above the Continue action. */ - punchline: string; -}; - -/** - * Structured completion summary handed to `WizardUI.summary()`. - * - * Keeping this as data (vs. pre-rendered markdown) lets each - * implementation choose its own presentation: - * - `LoggingUI` writes a compact two-column key/value listing to - * stdout, plus a flat list of changed files. - * - `InkUI` mounts a colored panel below the log stream with - * proper alignment and per-action glyphs. - * - * Previously `formatResult` built terminal markdown and called - * `ui.log.message(markdown)` — this leaked literal `` tags - * because the TUI's text renderer had no markdown parser, only a - * `stripAnsi` step. - */ -export type WizardSummary = { - /** Flat list of `