Skip to content

Commit e247010

Browse files
fix(init): enable interactive Ink UI for npx/Node via ESM sidecar
Ship the pre-bundled ink-app.js sidecar in the npm package so `npx sentry@latest init` gets the full interactive Ink UI on Node, not just LoggingUI. The text-import-plugin now emits a path string (not an external require) for the `with { type: "file" }` import in CJS bundles. `createInkUI` resolves it via import.meta.url and loads the self-contained ESM sidecar via dynamic import(). The factory no longer gates on isBunRuntime() — Ink works on both runtimes. Fixes #937
1 parent a4da8bd commit e247010

7 files changed

Lines changed: 112 additions & 82 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@
6666
"files": [
6767
"dist/bin.cjs",
6868
"dist/index.cjs",
69-
"dist/index.d.cts"
69+
"dist/index.d.cts",
70+
"dist/ink-app.js"
7071
],
7172
"license": "FSL-1.1-Apache-2.0",
7273
"packageManager": "bun@1.3.13",

script/bundle.ts

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -216,18 +216,14 @@ const result = await build({
216216
"import.meta.url": "import_meta_url",
217217
},
218218
// 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.
219+
// These packages are NOT bundled into the main CJS output because
220+
// they use top-level await (esbuild can't emit that in CJS).
221+
// Instead, the Ink UI lives in a separate self-contained ESM
222+
// sidecar (`dist/ink-app.js`) that the text-import-plugin
223+
// pre-bundles with all deps inlined. The main bundle references
224+
// the sidecar via a path string and loads it lazily via dynamic
225+
// `import()` at runtime. The external list here prevents esbuild
226+
// from trying to resolve these packages in the main bundle graph.
231227
external: [
232228
"node:*",
233229
"ink",
@@ -300,18 +296,11 @@ await Bun.write("./dist/index.d.cts", TYPE_DECLARATIONS);
300296
console.log(" -> dist/bin.cjs (CLI wrapper)");
301297
console.log(" -> dist/index.d.cts (type declarations)");
302298

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.
310-
try {
311-
await unlink("./dist/ink-app.js");
312-
} catch {
313-
// Sidecar may not exist (e.g. plugin path not exercised) — fine.
314-
}
299+
// The `ink-app.js` sidecar (pre-bundled by text-import-plugin) ships
300+
// with the npm package so `npx sentry@latest init` can load the
301+
// interactive Ink UI on Node via dynamic import(). The sidecar is
302+
// self-contained ESM with all deps inlined — no runtime dependencies
303+
// needed.
315304

316305
// Calculate bundle size (only the main bundle, not source maps)
317306
const bundleOutput = result.metafile?.outputs["dist/index.cjs"];

script/text-import-plugin.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { basename, dirname, extname, resolve as resolvePath } from "node:path";
4747
import { build as esbuildBuild, type Plugin } from "esbuild";
4848

4949
const TEXT_IMPORT_NS = "text-import";
50+
const FILE_PATH_NS = "file-path-import";
5051
const ANY_FILTER = /.*/;
5152

5253
/** Extensions that need TypeScript/JSX transpilation before embedding. */
@@ -127,10 +128,24 @@ export const textImportPlugin: Plugin = {
127128
);
128129
}
129130

131+
// For CJS bundles (npm distribution), emit a virtual module that
132+
// exports the sidecar filename as a string. The consumer resolves
133+
// the full path at runtime using import.meta.url. For ESM bundles
134+
// (Bun binary build), mark external so Bun.compile embeds the file.
135+
if (build.initialOptions.format === "cjs") {
136+
return {
137+
path: outFilename,
138+
namespace: FILE_PATH_NS,
139+
};
140+
}
130141
return { path: `./${outFilename}`, external: true };
131142
}
132143
return null;
133144
});
145+
build.onLoad({ filter: ANY_FILTER, namespace: FILE_PATH_NS }, (args) => ({
146+
contents: `export default ${JSON.stringify(`./${args.path}`)};`,
147+
loader: "js",
148+
}));
134149
build.onLoad({ filter: ANY_FILTER, namespace: TEXT_IMPORT_NS }, (args) => {
135150
const content = readFileSync(args.path, "utf-8");
136151
return {

src/lib/init/ui/factory.ts

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,17 @@
1616
* context this means the wizard becomes effectively non-interactive
1717
* (any prompt aborts), so users hitting this path will need to set
1818
* every choice via flags or rely on auto-detection.
19-
* 3. Running outside the Bun-compiled binary (i.e. on Node) — also
20-
* `LoggingUI`. Ink uses top-level await in its reconciler and the
21-
* `yoga-layout` dependency, which esbuild can't emit in our CJS
22-
* bundle, so the npm distribution can't load Ink at runtime. The
23-
* Bun binary embeds Ink + React + ink-app.tsx via
24-
* `with { type: "file" }`, sidestepping the bundler entirely. The
25-
* npm package's `--help` output and onboarding docs direct users
26-
* to the Bun binary for the interactive `sentry init` experience.
27-
* 4. Default (Bun binary, interactive, no opt-out) — `InkUI`.
19+
* 3. Default (interactive, no opt-out) — `InkUI`. Works on both the
20+
* Bun binary (Ink embedded via `with { type: "file" }`) and the
21+
* npm/Node distribution (self-contained ESM sidecar loaded via
22+
* dynamic `import()`). Falls back to `LoggingUI` if the import
23+
* fails for any reason.
2824
*
2925
* Implementation history:
3026
* - PR 4: replaced `ClackUI` with `OpenTuiUI` as the default.
3127
* - This PR: replaced `OpenTuiUI` with `InkUI`. OpenTUI's Zig
32-
* bindings added ~10.7 MB to the binary; Ink + React + companions
33-
* add a fraction of that and use no native code.
28+
* bindings added ~10.7 MB to the compiled binary; Ink + React +
29+
* companions add a fraction of that and use no native code.
3430
*/
3531

3632
import { LoggingUI } from "./logging-ui.js";
@@ -85,8 +81,8 @@ export function isInteractiveTerminal(): boolean {
8581

8682
/**
8783
* Returns `true` when the `LoggingUI` should be used — i.e. we're in
88-
* a non-interactive context, the user opted out of the TUI, the env
89-
* var override is set, or the runtime can't load Ink.
84+
* a non-interactive context, the user opted out of the TUI, or the
85+
* env var override is set.
9086
*/
9187
function shouldUseLogging(opts: UIFactoryOptions): boolean {
9288
if (process.env.SENTRY_INIT_TUI === "0") {
@@ -101,18 +97,15 @@ function shouldUseLogging(opts: UIFactoryOptions): boolean {
10197
if (!isInteractiveTerminal()) {
10298
return true;
10399
}
104-
if (!isBunRuntime()) {
105-
return true;
106-
}
107100
return false;
108101
}
109102

110103
/**
111-
* Async factory — picks `InkUI` for interactive runs on the Bun
112-
* binary, otherwise `LoggingUI`. The async form exists because
113-
* instantiating `InkUI` requires a lazy `import("ink")` (the package
114-
* isn't bundled into the npm/Node distribution and would fail to
115-
* resolve if statically imported there).
104+
* Async factory — picks `InkUI` for interactive runs, otherwise
105+
* `LoggingUI`. Works on both the Bun binary and the npm/Node
106+
* distribution (the Ink sidecar is self-contained ESM loaded via
107+
* dynamic `import()`). Falls back to `LoggingUI` if the Ink import
108+
* fails for any reason.
116109
*
117110
* Callers should treat the return value as an `AsyncDisposable` and
118111
* use `await using ui = await getUIAsync(...)` to guarantee teardown

src/lib/init/ui/ink-ui.ts

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@
3737
* process. We close the stream on dispose to release the libuv
3838
* handle.
3939
*
40-
* **Lazy import.** `ink`, `ink-spinner`, and `react` are all
41-
* dynamically imported by `createInkUI()` so the npm bundle (which
42-
* excludes them from the bundle graph) never sees the imports at
43-
* module-load time. This keeps the `LoggingUI` path cheap to
44-
* instantiate when interactive UI is not needed.
40+
* **Lazy import.** The Ink app sidecar (`ink-app.js`) is a
41+
* self-contained ESM bundle with all deps (ink, react, yoga-layout)
42+
* inlined. It's loaded lazily by `createInkUI()` via dynamic
43+
* `import()` so the `LoggingUI` path stays cheap to instantiate
44+
* when interactive UI is not needed. On the Bun binary the sidecar
45+
* is embedded in `/$bunfs/`; on the npm/Node distribution it ships
46+
* as `dist/ink-app.js` alongside the CJS bundle.
4547
*/
4648

4749
import { openSync } from "node:fs";
@@ -170,20 +172,19 @@ function severityForStopCode(code: SpinnerExitCode): LogSeverity {
170172
* `text-import-plugin` in `script/build.ts` intercepts this during
171173
* esbuild: it pre-bundles the .tsx source into self-contained JS
172174
* (stripping TypeScript, inlining local deps and npm packages,
173-
* injecting a `createRequire` banner for CJS deps), then marks the
174-
* import external so Bun.compile picks up the resulting .js file.
175+
* injecting a `createRequire` banner for CJS deps).
175176
*
176177
* Why pre-bundle? Bun's `/$bunfs/` virtual FS uses a JavaScript
177178
* parser, not TypeScript — raw .tsx fails on `import { type Foo }`.
178179
* The `/$bunfs/` environment also has no `node_modules`, so all
179180
* deps (ink, react, local modules) must be inlined.
180181
*
181-
* The npm/Node distribution never reaches `createInkUI()` (the
182-
* factory routes there only on the Bun binary because Ink uses
183-
* top-level await that esbuild can't emit in our CJS bundle), so
184-
* the embedded file is unused on Node. We still produce it because
185-
* the static import is unconditional; the bundle.ts cleanup step
186-
* `unlink`s the unused sidecar after bundling.
182+
* For the Bun binary build (ESM), the import is marked external so
183+
* Bun.compile embeds the file. For the npm CJS bundle, the plugin
184+
* emits a virtual module that exports the sidecar filename as a
185+
* string — `createInkUI` then resolves it relative to the bundle
186+
* and loads it via dynamic `import()`. The sidecar ships as
187+
* `dist/ink-app.js` in the npm package.
187188
*/
188189
// @ts-expect-error: `with { type: "file" }` is Bun-specific and not yet typed in @types/bun
189190
import inkAppPath from "./ink-app.tsx" with { type: "file" };
@@ -213,24 +214,32 @@ function openFreshTtyForInk(): ReadStream | null {
213214
export async function createInkUI(
214215
opts: CreateInkUIOptions = {}
215216
): Promise<InkUI> {
216-
// Import the Ink App sidecar. In compiled binaries the path
217-
// points to `/$bunfs/root/ink-app-xxx.js` (pre-bundled by
218-
// text-import-plugin). In dev mode it's the raw filesystem path
219-
// to `ink-app.tsx`.
217+
// Import the Ink App sidecar. Three runtime contexts:
220218
//
221-
// Query-string cache-bust: Bun's module loader caches the
222-
// `with { type: "file" }` import result (a path string) under
223-
// the same specifier key. A bare `await import(inkAppPath)` in
224-
// dev mode returns `{ default: "/abs/path" }` instead of the
225-
// module's actual exports. Appending `?bridge=1` forces a
226-
// distinct cache key so the .tsx is evaluated as a module.
219+
// 1. Bun binary: inkAppPath is "/$bunfs/root/ink-app-xxx.js"
220+
// (embedded by Bun.compile). Import directly — no query string
221+
// (/$bunfs/ doesn't support them).
227222
//
228-
// In compiled binaries the path lives under `/$bunfs/` which
229-
// does NOT support query strings (ENOENT). There the cache
230-
// collision doesn't occur because Bun.compile resolves the
231-
// `with { type: "file" }` import differently.
232-
const isEmbedded = inkAppPath.startsWith("/$bunfs/");
233-
const importPath = isEmbedded ? inkAppPath : `${inkAppPath}?bridge=1`;
223+
// 2. Dev mode (bun run src/bin.ts): inkAppPath is the absolute
224+
// filesystem path to ink-app.tsx. Append ?bridge=1 to bust
225+
// Bun's module cache (otherwise the import returns the path
226+
// string instead of the module's exports).
227+
//
228+
// 3. Node/npm (npx sentry@latest): inkAppPath is a relative path
229+
// like "./ink-app.js" (emitted by text-import-plugin as a
230+
// string literal). Resolve it to an absolute file:// URL using
231+
// import.meta.url so Node's dynamic import() can load the
232+
// self-contained ESM sidecar from the dist/ directory.
233+
let importPath: string;
234+
if (inkAppPath.startsWith("/$bunfs/")) {
235+
importPath = inkAppPath;
236+
} else if (inkAppPath.startsWith("./")) {
237+
// Node/npm bundle — resolve relative to the bundle location
238+
importPath = new URL(inkAppPath, import.meta.url).href;
239+
} else {
240+
// Dev mode — absolute filesystem path, cache-bust for Bun
241+
importPath = `${inkAppPath}?bridge=1`;
242+
}
234243
const app = (await import(importPath)) as typeof import("./ink-app.js");
235244

236245
const store = new WizardStore({

src/lib/init/ui/types.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
* provide the actual rendering:
66
*
77
* - `InkUI` — Ink-based React UI. Default for interactive runs on
8-
* the Bun-compiled binary. Ink is pure JS but uses
9-
* top-level await internally, which esbuild can't emit
10-
* in our CJS npm bundle — so the npm/Node distribution
11-
* falls back to `LoggingUI` instead.
8+
* both the Bun binary and the npm/Node distribution.
9+
* The Bun binary embeds Ink via `with { type: "file" }`;
10+
* the npm package ships a self-contained ESM sidecar
11+
* (`dist/ink-app.js`) loaded via dynamic `import()`.
1212
* - `LoggingUI` — plain stdout/stderr writes for CI, `--yes`, non-TTY
13-
* environments, the npm/Node distribution, and the
14-
* `--no-tui` escape hatch. Prompts throw —
15-
* non-interactive callers must supply defaults.
13+
* environments, and the `--no-tui` escape hatch.
14+
* Prompts throw — non-interactive callers must supply
15+
* defaults.
1616
*
1717
* The factory in `factory.ts` picks an implementation per run.
1818
*

test/lib/init/wizard-runner.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,29 @@ describe("runWizard", () => {
775775

776776
expect(spinnerMock.stop).toHaveBeenCalledWith("Using existing project");
777777
});
778+
779+
test("shows --yes hint when LoggingUI prompt fails", async () => {
780+
const { LoggingUIPromptError } = await import(
781+
"../../../src/lib/init/ui/logging-ui.js"
782+
);
783+
const { ui } = createMockUI();
784+
const failingUI: WizardUI = {
785+
...ui,
786+
spinner: () => spinnerMock,
787+
select: () =>
788+
Promise.reject(
789+
new LoggingUIPromptError(
790+
"select",
791+
"This is experimental and will modify files"
792+
)
793+
),
794+
};
795+
getUISpy.mockResolvedValue(failingUI);
796+
797+
await expect(
798+
forceStdinTty(() => runWizard(makeOptions({ yes: false })))
799+
).rejects.toThrow("Run with --yes for non-interactive mode.");
800+
});
778801
});
779802

780803
describe("runWizard — MastraClient lifecycle", () => {

0 commit comments

Comments
 (0)