Skip to content

Commit 08f23fd

Browse files
MathurAditya724github-actions[bot]getsentry-botbetegonclaude
authored
feat(init): replace OpenTUI with Ink for the wizard UI (#885)
> **Stacked on top of #862** (OpenTUI / WizardUI scaffold). Once #862 merges, GitHub auto-retargets this PR to `main`. ## Summary Replaces the OpenTUI implementation (Zig-compiled native binary, ~10.7 MB of binary cost, Bun-only) with [Ink](https://github.com/vadimdemedes/ink) — pure JS + React, no native bindings. Same `WizardUI` surface, same `WizardStore`, same sidebar layout, same step checklist — different render primitives. ## Why - **No native binary cost.** OpenTUI ships per-platform `.so`/`.dylib`/`.dll` files via `bun:ffi`, inflating the binary by ~10.7 MB. Ink is pure JS, so the binary drops from ~118 MB → ~109 MB (-9.4 MB). - **No alternate-screen flicker.** OpenTUI took over the alternate-screen buffer; on dispose every trace of the run was wiped. We had to replay a stripped-down transcript to stderr so users had any scrollback. Ink renders inline. - **Mature ecosystem.** `ink` + `ink-spinner` cover most of what we hand-rolled in OpenTUI. Used by Wrangler, Gatsby, GitHub Copilot CLI, and others. ## What's in this PR Four commits, in order: 1. **`d1ca5f7a` feat(init): replace OpenTUI with Ink** — initial port. Includes the `with { type: "file" }` workaround for Bun.compile bundling React's CJS dev wrappers (otherwise hits a `__promiseAll` SyntaxError at startup). 2. **`59900dbd` fix(init): make Ink select prompt actually respond to arrow keys** — replaced `ink-select-input` with a hand-rolled `useInput` implementation. The third-party component races with our store-driven re-renders. 3. **`b4a591e2` fix(init): make Ink useInput actually deliver keystrokes in Bun** — pass a fresh `/dev/tty` `ReadStream` to Ink's `stdin` option to work around [oven-sh/bun#6862](oven-sh/bun#6862) + [vadimdemedes/ink#636](vadimdemedes/ink#636) (Bun's `process.stdin` doesn't deliver `readable` events). 4. **`4a3e8354` fix(init): clear screen on dispose + tighten sidebar layout** — `instance.clear()` before unmount so the wizard chrome doesn't linger above the post-dispose summary; removed wasted rows between sidebar panels. ## Things that stayed the same - `WizardUI` interface (banner / intro / log / spinner / select / multiselect / confirm / summary / cancel / outro / setStep / recordFilesReading / markFilesAnalyzed) - The external `WizardStore` + `useSyncExternalStore` subscription pattern (renamed `opentui-store.ts` → `wizard-store.ts`) - `file-tree.ts`, `sentry-tips.ts`, `types.ts` (unchanged in shape) - Sidebar layout: tip card on top, step checklist in the middle, files-read tree on the bottom - Step progress checklist with implicit-skip back-fill - Post-dispose chalk summary echoed after Ink unmounts ## Things that changed - **Sidebar tree window vs. scrollbox.** Ink doesn't ship a scrollbox primitive. The files-read panel now shows the *last* N rows that fit, with a `↑ N earlier (scrolled)` hint when truncated. The tail-`f` UX (newly-read files always visible) comes for free since the panel re-renders to the bottom. - **Multi-select.** Built directly on Ink's `useInput` (no third-party multiselect component). - **Cancellation.** OpenTUI's `keyHandler` was global; Ink's `useInput` is per-component. Cancellation now hooks into: - Each prompt's own `useInput` (handles `key.escape` and `key.ctrl && input === "c"` in raw mode where Node doesn't emit SIGINT). - A top-level App-component `useInput` that intercepts Ctrl+C during spinners (no prompt mounted). - A `process.on("SIGINT", …)` fallback inside `InkUI` for the brief window where raw mode flickers off. ## Bun-binary-only (same as OpenTUI was) Ink's reconciler and the `yoga-layout` dependency use top-level await, which esbuild can't emit in our CJS npm bundle. So Ink is bundled into the Bun binary via the `with { type: "file" }` trick (same as OpenTUI used) but excluded from `dist/index.cjs` entirely. Node users continue to get `LoggingUI` — unchanged from before. This preserves AGENTS.md's "no runtime dependencies" rule. `bun run check:deps` passes. ## Bun.compile workarounds (unavoidable) - **`with { type: "file" }`** keeps `ink-app.tsx` out of esbuild's and Bun.compile's static bundle graph. Without this, Bun.compile mangles Ink's and React's CJS dev wrappers (it injects `__promiseAll` runtime helpers in positions the IIFEs can't parse, producing `SyntaxError: Unexpected identifier '__promiseAll'` at startup inside `parse-keypress.js` or `react-jsx-runtime.development.js`). - **`?bridge=1` query string** on the dynamic import bypasses Bun's module-cache collision between the file-resource import and `await import(path)` of the same absolute path. - **`define: { 'process.env.NODE_ENV': '"production"' }`** on `Bun.build` forces React to use its production builds. - **`react-devtools-core`** installed as a devDep so Bun.compile can resolve Ink's static reference (gated behind `process.env.DEV === "true"` at runtime → dead code in production). ## Files changed **Added** - `src/lib/init/ui/ink-app.tsx` — Ink React tree - `src/lib/init/ui/ink-ui.ts` — `InkUI` bridge class **Renamed** - `src/lib/init/ui/opentui-store.ts` → `wizard-store.ts` (no logic changes) - `test/lib/init/ui/opentui-store.test.ts` → `wizard-store.test.ts` **Deleted** - `src/lib/init/ui/opentui-app.tsx` - `src/lib/init/ui/opentui-ui.ts` **Dep changes** - Removed: `@opentui/core`, `@opentui/react` - Added: `ink`, `ink-spinner`, `react-devtools-core` (all devDependencies) ## Verification - `bun run typecheck` (clean) - `bun x ultracite check` (1 pre-existing warning, no new ones) - `bun test --isolate test/lib/init/` (227 pass) - `bun run check:deps` (no runtime dependencies) - `SENTRY_CLIENT_ID=test bun run build` (binary 108.79 MB, **-9.4 MB** vs OpenTUI's 118.23 MB) - `SENTRY_CLIENT_ID=test bun run bundle` (npm 3.29 MB, unchanged) - `./dist-bin/sentry-linux-x64 init --help` (renders cleanly) - `node ./dist/bin.cjs init --help` (Node path renders cleanly) --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Sentry Bot <bot@sentry.io> Co-authored-by: Miguel Betegón <miguelbetegongarcia@gmail.com> Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 0307aad commit 08f23fd

50 files changed

Lines changed: 8996 additions & 846 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

biome.jsonc

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,26 @@
3333
}
3434
},
3535
"overrides": [
36+
{
37+
// The React-hook lint rules infer "this is a hook" from the
38+
// `use*` naming convention. We have a couple of test helpers
39+
// (`useTestConfigDir`, `useEnvSandbox`) that share the prefix
40+
// by coincidence — they register `beforeEach`/`afterEach` and
41+
// have nothing to do with React. Without these overrides every
42+
// call site lights up `useHookAtTopLevel` since making the
43+
// tsconfig JSX-aware (for `OpenTuiUI`) flipped the rule on.
44+
// The actual React tree lives in `src/lib/init/ui/opentui-app.tsx`
45+
// and keeps the rule active.
46+
"includes": ["test/**/*.ts", "src/**/*.ts", "!src/**/*.tsx"],
47+
"linter": {
48+
"rules": {
49+
"correctness": {
50+
"useHookAtTopLevel": "off",
51+
"useExhaustiveDependencies": "off"
52+
}
53+
}
54+
}
55+
},
3656
{
3757
"includes": ["test/**/*.ts"],
3858
"linter": {

bun.lock

Lines changed: 87 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/src/fragments/commands/init.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
# Interactive setup
1111
sentry init
1212

13-
# Non-interactive with auto-yes
14-
sentry init -y
13+
# Non-interactive agent/CI setup
14+
sentry init --yes --features errors,tracing,replay
1515

1616
# Dry run to preview changes
1717
sentry init --dry-run

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
"devDependencies": {
1010
"@anthropic-ai/sdk": "^0.39.0",
1111
"@biomejs/biome": "2.3.8",
12-
"@clack/prompts": "^0.11.0",
1312
"@mastra/client-js": "^1.4.0",
1413
"@sentry/api": "^0.141.0",
1514
"@sentry/node-core": "10.50.0",
@@ -21,6 +20,7 @@
2120
"@types/node": "^22",
2221
"@types/picomatch": "^4.0.3",
2322
"@types/qrcode-terminal": "^0.12.2",
23+
"@types/react": "^19.2.14",
2424
"@types/semver": "^7.7.1",
2525
"binpunch": "^1.0.0",
2626
"chalk": "^5.6.2",
@@ -30,12 +30,16 @@
3030
"fast-check": "^4.5.3",
3131
"http-cache-semantics": "^4.2.0",
3232
"ignore": "^7.0.5",
33+
"ink": "^7.0.1",
34+
"ink-spinner": "^5.0.0",
3335
"marked": "^15",
3436
"p-limit": "^7.2.0",
3537
"peggy": "^5.1.0",
3638
"picomatch": "^4.0.3",
3739
"pretty-ms": "^9.3.0",
3840
"qrcode-terminal": "^0.12.0",
41+
"react": "^19.2.5",
42+
"react-devtools-core": "^7.0.1",
3943
"semver": "^7.7.3",
4044
"string-width": "^8.2.0",
4145
"tinyglobby": "^0.2.15",

plugins/sentry-cli/skills/sentry-cli/references/init.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,20 @@ Initialize Sentry in your project (experimental)
1616
Initialize Sentry in your project (experimental)
1717

1818
**Flags:**
19-
- `-y, --yes - Non-interactive mode (accept defaults)`
19+
- `-y, --yes - Accept non-interactive defaults (requires --features outside a TTY)`
2020
- `-n, --dry-run - Show what would happen without making changes`
21-
- `--features <value>... - Features to enable: errors,tracing,logs,replay,profiling,ai-monitoring,user-feedback`
21+
- `--features <value>... - Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring,user-feedback`
2222
- `-t, --team <value> - Team slug to create the project under`
23+
- `--tui - Use the Ink-based interactive UI (default on the Bun binary). Pass --no-tui to fall back to plain log output.`
2324

2425
**Examples:**
2526

2627
```bash
2728
# Interactive setup
2829
sentry init
2930

30-
# Non-interactive with auto-yes
31-
sentry init -y
31+
# Non-interactive agent/CI setup
32+
sentry init --yes --features errors,tracing,replay
3233

3334
# Dry run to preview changes
3435
sentry init --dry-run

script/build.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,27 @@ async function bundleJs(): Promise<boolean> {
124124
platform: "node",
125125
target: "esnext",
126126
format: "esm",
127-
external: ["bun:*"],
127+
// Externalize the Ink + React stack from the esbuild bundling
128+
// step. `react`'s CJS jsx-runtime, when pulled into esbuild's
129+
// `__commonJS` wrappers and re-bundled by Bun.compile, produces
130+
// malformed output containing a TDZ `init_react` symbol
131+
// embedded in the wrong scope. Keeping React (and its
132+
// consumers) external lets Bun's runtime resolve them fresh at
133+
// first invocation, outside the buggy bundler path.
134+
//
135+
// The npm bundle (`script/bundle.ts`) externalizes the same
136+
// packages for the same reason — bundling Ink's React tree
137+
// through esbuild produces a CJS wrapper that hits a TDZ at
138+
// runtime when React is first touched.
139+
external: [
140+
"bun:*",
141+
"ink",
142+
"ink-spinner",
143+
"react",
144+
"react/*",
145+
"react-reconciler",
146+
"react-reconciler/*",
147+
],
128148
sourcemap: "linked",
129149
// Minify syntax and whitespace but NOT identifiers. Bun.build
130150
minify: true,
@@ -295,6 +315,25 @@ async function compileTarget(target: BuildTarget): Promise<boolean> {
295315
try {
296316
const result = await Bun.build({
297317
entrypoints: [BUNDLE_JS],
318+
// Force React to load its production builds. React's CJS
319+
// entry switches at runtime via
320+
// `if (process.env.NODE_ENV === "production")`
321+
// — leaving NODE_ENV unset would drag in the development
322+
// builds, whose CJS wrappers Bun.compile can't bundle cleanly
323+
// (it injects `__promiseAll` runtime helpers in positions the
324+
// dev-build's IIFE doesn't tolerate, causing a SyntaxError at
325+
// startup). Production builds parse fine.
326+
//
327+
// `react-devtools-core` is gated behind `process.env.DEV ===
328+
// "true"` inside Ink's reconciler — never reached in our
329+
// production binary. We still install it as a devDep so
330+
// Bun.compile can resolve the static `import devtools from
331+
// "react-devtools-core"` reference; without it the build
332+
// fails with "Could not resolve". The inlined module gets
333+
// dead-code-eliminated by the DEV gate at runtime.
334+
define: {
335+
"process.env.NODE_ENV": JSON.stringify("production"),
336+
},
298337
compile: {
299338
target: getBunTarget(target) as
300339
| "bun-darwin-arm64"
@@ -480,8 +519,12 @@ async function build(): Promise<void> {
480519
// Step 3: Upload the composed sourcemap to Sentry (after compilation)
481520
await uploadSourcemapToSentry();
482521

483-
// Clean up intermediate bundle (only the binaries are artifacts)
484-
await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE}`;
522+
// Clean up intermediate bundle (only the binaries are artifacts).
523+
// The `ink-app.tsx` copy comes from the text-import-plugin's
524+
// `with { type: "file" }` handling — it gets embedded into the
525+
// compiled binary, so the sidecar copy is no longer needed once
526+
// every target has compiled.
527+
await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE} dist-bin/ink-app.tsx`;
485528

486529
// Summary
487530
console.log(`\n${"=".repeat(40)}`);

script/bundle.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,30 @@ const result = await build({
215215
// Replace import.meta.url with the injected shim variable for CJS
216216
"import.meta.url": "import_meta_url",
217217
},
218-
// Only externalize Node.js built-ins - bundle all npm packages
219-
external: ["node:*"],
218+
// Externalize Node.js built-ins, plus Ink + React + companions.
219+
// Ink uses top-level await (in `node_modules/ink/build/reconciler.js`
220+
// and `yoga-layout/dist/src/index.js`) which esbuild can't emit in
221+
// a CJS bundle, so the packages must stay external for the
222+
// npm/Node distribution. The factory in `factory.ts` lazy-imports
223+
// the Ink path via `with { type: "file" }` and falls back to
224+
// `LoggingUI` on import failure, so a Node user without Ink
225+
// installed simply gets the non-TUI flow without a crash.
226+
//
227+
// The Bun compile (`script/build.ts`) embeds `ink-app.tsx` as a
228+
// file resource — at runtime Bun's loader resolves Ink + React
229+
// fresh, sidestepping the same CJS-wrapping bug that'd hit if
230+
// these were bundled into the binary's pre-compiled JS.
231+
external: [
232+
"node:*",
233+
"ink",
234+
"ink-spinner",
235+
"react",
236+
"react/*",
237+
"react-reconciler",
238+
"react-reconciler/*",
239+
"react-devtools-core",
240+
"yoga-layout",
241+
],
220242
metafile: true,
221243
plugins,
222244
});
@@ -278,6 +300,20 @@ await Bun.write("./dist/index.d.cts", TYPE_DECLARATIONS);
278300
console.log(" -> dist/bin.cjs (CLI wrapper)");
279301
console.log(" -> dist/index.d.cts (type declarations)");
280302

303+
// Clean up the `ink-app.tsx` sidecar that the text-import-plugin
304+
// drops into `dist/` when it sees the `with { type: "file" }` import
305+
// in `src/lib/init/ui/ink-ui.ts`. The npm distribution doesn't run
306+
// the InkUI factory at all (it's gated to the Bun binary because
307+
// Ink uses top-level await that we can't bundle into CJS), so the
308+
// sidecar is unused — and it's not in `package.json#files` either,
309+
// so it wouldn't ship even without this cleanup. Removing it just
310+
// keeps the local `dist/` directory tidy.
311+
try {
312+
await unlink("./dist/ink-app.tsx");
313+
} catch {
314+
// Sidecar may not exist (e.g. plugin path not exercised) — fine.
315+
}
316+
281317
// Calculate bundle size (only the main bundle, not source maps)
282318
const bundleOutput = result.metafile?.outputs["dist/index.cjs"];
283319
const bundleSize = bundleOutput?.bytes ?? 0;

script/text-import-plugin.ts

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
/**
2-
* esbuild plugin that polyfills Bun's `with { type: "text" }` import
3-
* attribute (esbuild only supports `json`). Intercepts matching
4-
* imports, reads the file, and default-exports its contents as a
5-
* string. Runtime behavior matches Bun's native handling.
2+
* esbuild plugin that polyfills Bun's `with { type: "text" }` and
3+
* `with { type: "file" }` import attributes (esbuild only supports
4+
* `json`).
5+
*
6+
* - `text` — intercepts the import, reads the file, and default-
7+
* exports its contents as a string. Runtime behavior matches Bun's
8+
* native handling.
9+
* - `file` — copies the source file into the esbuild output
10+
* directory, then marks the import external so the original
11+
* `import path from "./foo" with { type: "file" }` clause
12+
* survives in the bundled JS. Bun.compile downstream understands
13+
* the attribute natively, embeds the file as a binary asset, and
14+
* resolves the import to a virtual-filesystem path string at
15+
* runtime.
616
*
717
* Used by `script/build.ts` (single-file executable) and
818
* `script/bundle.ts` (CJS library bundle) so the grep-worker source
919
* in `src/lib/scan/worker-pool.ts` loads correctly in both dev and
10-
* compiled builds.
20+
* compiled builds (`text` branch). The `file` branch is kept for
21+
* future use; today no source file goes through it.
1122
*/
1223

13-
import { readFileSync } from "node:fs";
14-
import { resolve as resolvePath } from "node:path";
24+
import { copyFileSync, mkdirSync, readFileSync } from "node:fs";
25+
import { basename, dirname, resolve as resolvePath } from "node:path";
1526
import type { Plugin } from "esbuild";
1627

1728
const TEXT_IMPORT_NS = "text-import";
@@ -21,13 +32,48 @@ export const textImportPlugin: Plugin = {
2132
name: "text-import",
2233
setup(build) {
2334
build.onResolve({ filter: ANY_FILTER }, (args) => {
24-
if (args.with?.type !== "text") {
25-
return null;
35+
if (args.with?.type === "text") {
36+
return {
37+
path: resolvePath(args.resolveDir, args.path),
38+
namespace: TEXT_IMPORT_NS,
39+
};
2640
}
27-
return {
28-
path: resolvePath(args.resolveDir, args.path),
29-
namespace: TEXT_IMPORT_NS,
30-
};
41+
if (args.with?.type === "file") {
42+
// Copy the source into the bundle's output directory and
43+
// rewrite the import path so it sits next to the bundle.
44+
// esbuild keeps the import external (preserving the
45+
// `with { type: "file" }` clause) so Bun.compile can pick
46+
// it up from the new location. The copy is needed because
47+
// Bun.compile resolves imports relative to the bundle file's
48+
// directory at compile time, not the original source.
49+
//
50+
// `mkdirSync` guards against the bundle's `outdir` not yet
51+
// existing when the plugin fires — esbuild creates the
52+
// outdir lazily on first write.
53+
const sourcePath = resolvePath(args.resolveDir, args.path);
54+
const outdir = build.initialOptions.outdir
55+
? resolvePath(build.initialOptions.outdir)
56+
: dirname(resolvePath(build.initialOptions.outfile ?? "."));
57+
const filename = basename(sourcePath);
58+
const copyPath = resolvePath(outdir, filename);
59+
try {
60+
mkdirSync(outdir, { recursive: true });
61+
copyFileSync(sourcePath, copyPath);
62+
} catch (err) {
63+
// Surface the failure so the build fails visibly rather
64+
// than producing a binary that crashes at startup.
65+
throw new Error(
66+
`text-import-plugin: failed to copy ${sourcePath}${copyPath}: ${
67+
err instanceof Error ? err.message : String(err)
68+
}`
69+
);
70+
}
71+
return {
72+
path: `./${filename}`,
73+
external: true,
74+
};
75+
}
76+
return null;
3177
});
3278
build.onLoad({ filter: ANY_FILTER, namespace: TEXT_IMPORT_NS }, (args) => {
3379
const content = readFileSync(args.path, "utf-8");

0 commit comments

Comments
 (0)