Skip to content

Commit 8aa014d

Browse files
fix(init): pre-bundle Ink sidecar so it loads from $bunfs
The `ink-app.tsx` sidecar embedded via `with { type: "file" }` failed to load in compiled binaries because Bun's `/$bunfs/` virtual FS uses a JavaScript parser (not TypeScript) and has no `node_modules`. Three issues fixed: 1. Raw .tsx syntax (`import { type Foo }`) caused SyntaxError 2. Local sibling imports (ink-frame, ink-shortcuts) were unresolvable 3. npm packages (ink, react) were not available at `/$bunfs/root/` The text-import-plugin now pre-bundles .tsx files via esbuild into self-contained JS with all deps inlined. A `createRequire` banner provides working `require()` for CJS dependencies like signal-exit. A new `mountApp()` export in ink-app.tsx centralises the `ink.render(createElement(App))` call inside the sidecar so only one copy of React exists at runtime, avoiding dual-React hook errors.
1 parent 08f23fd commit 8aa014d

6 files changed

Lines changed: 178 additions & 99 deletions

File tree

AGENTS.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,12 +1068,12 @@ mock.module("./some-module", () => ({
10681068

10691069
### Architecture
10701070

1071+
<!-- lore:019e0455-946a-7f83-8fee-ed0265fe8145 -->
1072+
* **Ink TUI sidecar: mountApp() centralises React/Ink instance to avoid dual-React errors**: \`ink-app.tsx\` exports \`mountApp(store, options)\` which calls \`ink.render(createElement(App, ...))\` internally. The sidecar is fully self-contained (all deps bundled in via esbuild). The main bundle must NOT import \`ink\`/\`react\` separately and call \`render()\` — that creates a second React instance causing "Invalid hook call" errors. \`ink-ui.ts\` calls \`app.mountApp()\` from the sidecar rather than importing ink/react itself.
1073+
10711074
<!-- lore:019da6b7-1d7b-70b0-adf8-769712f5c577 -->
10721075
* **Issue resolve --in grammar: release + @next + @commit sentinels**: \`sentry issue resolve --in\` grammar: (a) omitted→immediate resolve, (b) \`\<version>\`\`inRelease\` (monorepo \`spotlight@1.2.3\` pass-through), (c) \`@next\`\`inNextRelease\`, (d) \`@commit\`→auto-detect git HEAD + match Sentry repos, (e) \`@commit:\<repo>@\<sha>\`→explicit. Sentinel matching case-insensitive; unknown \`@\`-prefixed tokens throw \`ValidationError\`. \`parseResolveSpec\` splits on LAST \`@\` to handle scoped names like \`@acme/web\`. \`resolveCommitSpec\` uses \`getHeadCommit\`/\`getRepositoryName\` from \`src/lib/git.ts\`, matching Sentry repo \`externalSlug\` or \`name\` via \`listRepositoriesCached\`. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA.
10731076

1074-
<!-- lore:2c3eb7ab-1341-4392-89fd-d81095cfe9c4 -->
1075-
* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError.
1076-
10771077
<!-- lore:019da6b7-1d86-702c-a6bc-5cb9a7fd4f6d -->
10781078
* **repo\_cache SQLite table for offline Sentry repo lookups**: Schema v14 adds \`repo\_cache\` table in \`src/lib/db/schema.ts\` + helpers in \`src/lib/db/repo-cache.ts\` (7-day TTL). \`listAllRepositories(org)\` in \`src/lib/api/repositories.ts\` paginates through \`listRepositoriesPaginated\` using \`API\_MAX\_PER\_PAGE\` and \`MAX\_PAGINATION\_PAGES\` — never use the unpaginated \`listRepositories\` for cache-backed lookups (silently caps at ~25). \`listRepositoriesCached(org)\` wraps it with cache-first lookup and a try/catch around \`setCachedRepos\` so read-only databases (macOS \`sudo brew install\`) don't crash commands whose API fetch already succeeded. Used by \`@commit\` resolver to match git origin \`owner/repo\` against Sentry repo \`externalSlug\` or \`name\`.
10791079

@@ -1103,10 +1103,13 @@ mock.module("./some-module", () => ({
11031103
* **Biome noUselessUndefined also rejects () => {} empty arrow callbacks**: Biome lint traps: (1) \`noUselessUndefined\` rejects \`() => undefined\` AND \`noEmptyBlockStatements\` rejects \`() => {}\` — use top-level \`function noop(): void {}\`. (2) \`noExcessiveCognitiveComplexity\` caps at 15. (3) \`expect(() => fn()).toThrow(X)\` must be one line. (4) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. (5) Also enforced: \`useBlockStatements\`, \`noNestedTernary\`, \`useAtIndex\`, \`noStaticOnlyClass\`, \`useSimplifiedLogicExpression\`, \`noShadow\`. Namespace imports forbidden. (6) \`useYield\` fires on \`async \*func()\` with statements but not empty bodies — only add \`biome-ignore\` to generators with statements. \`lint:fix\` differs from CI \`lint\`: auto-fix hides \`noPrecisionLoss\` on >2^53 literals, \`noIncrementDecrement\`, import ordering. Always \`bun run lint\` before pushing.
11041104

11051105
<!-- lore:019dc60a-2c2c-7d07-b583-40c5a9342101 -->
1106-
* **Bun --isolate coverage inflates LF count for files with verbose comments/JSDoc**: Bun --isolate coverage inflates LF count: under \`bun test --isolate --parallel\` (CI's \`test:unit\`), Bun's coverage instrumentation counts comments, blank lines, type annotations, and closing braces as 'executable'. E.g. \`zstd-transport.ts\` LF=165 locally → 210 under --isolate, dropping coverage 99%→78%. Codecov sees inflated number. Workaround: trim verbose inline comments inside function bodies (move rationale to JSDoc above function or module-level doc). Statement coverage stays 100% — 'missing' lines are non-executable.
1106+
* **Bun --isolate coverage inflates LF count for files with verbose comments/JSDoc**: Bun --isolate coverage inflates LF count: under \`bun test --isolate --parallel\` (CI's \`test:unit\`), Bun's coverage instrumentation counts comments, blank lines, type annotations, and closing braces as 'executable'. E.g. \`zstd-transport.ts\` LF=165 locally → 210 under --isolate, dropping coverage 99%→78%. Workaround: trim verbose inline comments inside function bodies; move rationale to JSDoc above the function. Statement coverage stays 100% — 'missing' lines are non-executable.
1107+
1108+
<!-- lore:019e0455-945e-77ac-ac2f-ca206985387a -->
1109+
* **Bun /$bunfs/ virtual FS uses JS parser — embedded .tsx files fail on TS syntax**: Files embedded via \`with { type: "file" }\` run from Bun's \`/$bunfs/root/\` virtual filesystem, which uses a JavaScript parser (not TypeScript). Raw \`.tsx\` files crash with \`SyntaxError\` on \`import { type Foo }\`. Fix: pre-bundle \`.tsx\`\`.js\` via esbuild before embedding. The \`/$bunfs/\` environment also has no \`node\_modules\` — all npm deps must be inlined. A \`createRequire\` banner in the esbuild output provides working \`require()\` for CJS deps like \`signal-exit\`. Only \`node:\*\` builtins should be external. Implemented in \`script/text-import-plugin.ts\` (\`file\` handler). The \`/$bunfs/\` path also doesn't support query strings — \`?bridge=1\` style workarounds cause ENOENT.
11071110

11081111
<!-- lore:019dbabc-1b61-7768-ac90-e62d6464af34 -->
1109-
* **Bun 1.3.11 tty.ReadStream leaks libuv handle — process.stdin.unref is undefined**: Bun 1.3.11 macOS TTY bug: \`process.stdin\` via kqueue \`EVFILT\_READ\` on reopened non-session-leader TTY fd fails to deliver keystrokes when fd 0 inherited via \`exec bin \</dev/tty\` (curl|bash flow). Linux (epoll) works. Workaround: \`openSync('/dev/tty','r')\` + \`new tty.ReadStream(fd)\` routes through libuv threadpool. Lives in \`src/lib/init/stdin-reopen.ts\`, darwin-gated in \`wizard-runner.ts\` via \`using \_tty\`. Leaks libuv handle → safety net in \`init.ts\`: \`setTimeout(process.exit, 100).unref()\`. Skip under \`NODE\_ENV=test\`.
1112+
* **Bun 1.3.11 tty.ReadStream leaks libuv handle — process.stdin.unref is undefined**: Bun 1.3.11 macOS TTY bug: \`process.stdin\` via kqueue \`EVFILT\_READ\` fails to deliver keystrokes when fd 0 is inherited via \`exec bin \</dev/tty\` (curl|bash flow). Linux (epoll) works. Workaround: \`openSync('/dev/tty','r')\` + \`new tty.ReadStream(fd)\` routes through libuv threadpool. Lives in \`src/lib/init/stdin-reopen.ts\`, darwin-gated in \`wizard-runner.ts\` via \`using \_tty\`. Leaks libuv handle → safety net in \`init.ts\`: \`setTimeout(process.exit, 100).unref()\`. Skip under \`NODE\_ENV=test\`.
11101113

11111114
<!-- lore:019db776-111b-73db-b4ad-b762dfd4808f -->
11121115
* **MastraClient has no dispose API — use AbortController for cleanup**: MastraClient has no \`close()\`/\`dispose()\` API — cleanup via \`ClientOptions.abortSignal\` (constructor) or per-prompt \`signal\`. Without explicit abort, Bun's fetch dispatcher keep-alive sockets hold the event loop alive past natural exit. Pattern in \`src/lib/init/wizard-runner.ts\`: create \`AbortController\` per \`runWizard\`, pass \`abortSignal: controller.signal\` to \`new MastraClient(...)\`, abort via \`using \_ = { \[Symbol.dispose]: () => controller.abort() }\`. Custom \`fetch\` wrapper must preserve \`init.signal\` via spread. Tests capture \`ClientOptions\` via \`spyOn(MastraClient.prototype, 'getWorkflow').mockImplementation(function() { capturedOpts.push(this.options); ... })\`.
@@ -1117,13 +1120,13 @@ mock.module("./some-module", () => ({
11171120
### Pattern
11181121

11191122
<!-- lore:019dba09-70b6-7862-b051-362ae1631959 -->
1120-
* **CLI-1D3 Windows download visibility race: poll statSync with exponential backoff**: Windows upgrade download visibility race (CLI-1D3): \`waitForBinaryVisible\` in \`src/lib/upgrade.ts\` polls \`statSync\` with exponential backoff (6 attempts, 5 sleeps: 100+200+400+800+1600 = 3.1s). Loop breaks BEFORE final sleep — \`VERIFY\_MAX\_ATTEMPTS=N\` yields N-1 sleeps (off-by-one trap). Covers Windows + Bun 1.3.9 race where \`Bun.file().writer().end()\` returns before OS surfaces file by path → opaque \`Executable not found in $PATH\` from \`Bun.spawn\`. Safety net \`isEnoentSpawnError()\` in \`src/commands/cli/upgrade.ts\` detects both \`code==='ENOENT'\` and Bun's path-string error → \`UpgradeError('execution\_failed')\`. Race-free delayed-write tests: writer must POLL until bad state exists THEN overwrite.
1123+
* **CLI-1D3 Windows download visibility race: poll statSync with exponential backoff**: Windows upgrade download visibility race: \`waitForBinaryVisible\` in \`src/lib/upgrade.ts\` polls \`statSync\` with exponential backoff (6 attempts, 5 sleeps: 100+200+400+800+1600ms). Loop breaks BEFORE final sleep — \`VERIFY\_MAX\_ATTEMPTS=N\` yields N-1 sleeps (off-by-one trap). Covers Bun 1.3.9 race where \`Bun.file().writer().end()\` returns before OS surfaces file by path. \`isEnoentSpawnError()\` in \`src/commands/cli/upgrade.ts\` catches both \`code==='ENOENT'\` and Bun's path-string error → \`UpgradeError('execution\_failed')\`. Race-free tests: writer must poll until bad state exists, then overwrite.
11211124

11221125
<!-- lore:019dbc24-953f-756c-abed-1aaff0d664e1 -->
1123-
* **Cross-compile sentry-cli with patched Bun: drop compile.target to use selfExePath**: Cross-compile sentry-cli with patched Bun: \`Bun.build({compile})\` downloads stock Bun from npm when \`compile.target\` is set. Workaround in \`script/build.ts\`: omit \`target\` entirely so Bun hits \`isDefault()\` branch → uses \`selfExePath()\` = the running Bun as embed runtime. Only works when host OS/arch matches desired output. Escape hatch: place file at \`$CWD/bun-\<os>-\<arch>-v\<version>\` (e.g. \`bun-darwin-arm64-v1.3.13\`) picked up via \`bun.FD.cwd().existsAt(version\_str)\` in \`src/compile\_target.zig:exePath\`. Build also requires \`SENTRY\_CLIENT\_ID\` env var.
1126+
* **Cross-compile sentry-cli with patched Bun: drop compile.target to use selfExePath**: Cross-compile sentry-cli with patched Bun: \`Bun.build({compile})\` downloads stock Bun from npm when \`compile.target\` is set. Workaround in \`script/build.ts\`: omit \`target\` entirely so Bun uses \`selfExePath()\` as embed runtime. Only works when host OS/arch matches desired output. Escape hatch: place \`bun-\<os>-\<arch>-v\<version>\` in \`$CWD\`. Build requires \`SENTRY\_CLIENT\_ID\` env var.
11241127

11251128
<!-- lore:019da71e-6727-7ba1-9503-66d0ad80ade8 -->
1126-
* **Dedupe resolved entity IDs in batch operations before API call**: Batch issue merge (src/commands/issue/merge.ts): (1) Dedupe by resolved numeric ID after \`Promise.all(args.map(resolveIssue))\`, not raw input (users pass same entity as \`CLI-K9\`, \`my-org/CLI-K9\`, \`123\`). Throw ValidationError if \`new Set(ids).size < 2\`. (2) Reject undefined orgs in cross-org check — bare numeric IDs without DSN/config resolve with \`org: undefined\`; filtering them out lets mixed-org merges slip through. (3) Pass \`--into\` through \`resolveIssue()\` for alias/org-qualified parity; compare by numeric \`id\`, not \`shortId\`. (4) Sentry bulk merge API picks canonical parent by event count — \`--into\` is preference only; warn when API's \`parent\` differs. Empty results return 204.
1129+
* **Dedupe resolved entity IDs in batch operations before API call**: Batch issue merge (\`src/commands/issue/merge.ts\`): (1) Dedupe by resolved numeric ID after \`Promise.all(args.map(resolveIssue))\`users may pass same entity as \`CLI-K9\`, \`my-org/CLI-K9\`, or \`123\`. Throw \`ValidationError\` if \`new Set(ids).size < 2\`. (2) Reject \`undefined\` orgs in cross-org check — bare numeric IDs without DSN/config resolve with \`org: undefined\`. (3) Pass \`--into\` through \`resolveIssue()\`; compare by numeric \`id\`, not \`shortId\`. (4) Sentry bulk merge API picks canonical parent by event count — \`--into\` is preference only; warn when API's \`parent\` differs.
11271130

11281131
<!-- lore:019d0b36-5da2-750c-b26f-630a2927bd79 -->
11291132
* **findProjectsByPattern as fuzzy fallback for exact slug misses**: When \`findProjectsBySlug\` returns empty (no exact match), use \`findProjectsByPattern\` as a fallback to suggest similar projects. \`findProjectsByPattern\` does bidirectional word-boundary matching (\`matchesWordBoundary\`) against all projects in all orgs — the same logic used for directory name inference. In the \`project-search\` handler, call it after the exact miss, format matches as \`\<org>/\<slug>\` suggestions in the \`ResolutionError\`. This avoids a dead-end error for typos like 'patagonai' when 'patagon-ai' exists. Note: \`findProjectsByPattern\` makes additional API calls (lists all projects per org), so only call it on the failure path.

script/build.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,12 @@ async function bundleJs(): Promise<boolean> {
132132
// consumers) external lets Bun's runtime resolve them fresh at
133133
// first invocation, outside the buggy bundler path.
134134
//
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.
135+
// Note: the `with { type: "file" }` sidecar (ink-app.tsx) is
136+
// handled separately by the text-import-plugin, which pre-
137+
// bundles it into a self-contained JS file with ink/react
138+
// inlined. That sidecar runs from `/$bunfs/root/` at runtime
139+
// where `node_modules` is not available, so it MUST be
140+
// self-contained.
139141
external: [
140142
"bun:*",
141143
"ink",
@@ -520,11 +522,11 @@ async function build(): Promise<void> {
520522
await uploadSourcemapToSentry();
521523

522524
// 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`;
525+
// The `ink-app.js` sidecar comes from the text-import-plugin's
526+
// pre-bundle of the `with { type: "file" }` import — it gets
527+
// embedded into the compiled binary, so the copy is no longer
528+
// needed once every target has compiled.
529+
await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE} dist-bin/ink-app.js`;
528530

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

script/bundle.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -300,16 +300,15 @@ await Bun.write("./dist/index.d.cts", TYPE_DECLARATIONS);
300300
console.log(" -> dist/bin.cjs (CLI wrapper)");
301301
console.log(" -> dist/index.d.cts (type declarations)");
302302

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.
303+
// Clean up the `ink-app.js` sidecar that the text-import-plugin
304+
// drops into `dist/` when it pre-bundles the `with { type: "file" }`
305+
// import in `src/lib/init/ui/ink-ui.ts`. The npm distribution
306+
// doesn't run the InkUI factory at all (it's gated to the Bun
307+
// binary because Ink uses top-level await that we can't bundle
308+
// into CJS), so the sidecar is unused — removing it just keeps the
309+
// local `dist/` directory tidy.
311310
try {
312-
await unlink("./dist/ink-app.tsx");
311+
await unlink("./dist/ink-app.js");
313312
} catch {
314313
// Sidecar may not exist (e.g. plugin path not exercised) — fine.
315314
}

0 commit comments

Comments
 (0)