diff --git a/.changeset/happy-kids-admire.md b/.changeset/happy-kids-admire.md
new file mode 100644
index 000000000..1161033e4
--- /dev/null
+++ b/.changeset/happy-kids-admire.md
@@ -0,0 +1,5 @@
+---
+"hunkdiff": patch
+---
+
+Avoid loading OpenTUI's embedded native library for headless commands. Help, version, session polling, daemon serving, markup rendering, and non-interactive pager paths now stay behind a lightweight CLI entrypoint, preventing Bun from leaking a native temp file for commands that never open the review UI.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c37e209ba..6e16d71cc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -157,10 +157,13 @@ jobs:
strategy:
fail-fast: false
matrix:
- os:
- - ubuntu-latest
- - macos-latest
- - windows-latest
+ include:
+ - os: ubuntu-latest
+ executable: dist/hunk
+ - os: macos-latest
+ executable: dist/hunk
+ - os: windows-latest
+ executable: dist/hunk.exe
steps:
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -181,6 +184,11 @@ jobs:
- name: Stage prebuilt npm packages
run: bun run build:prebuilt:npm
+ - name: Verify compiled headless commands skip OpenTUI
+ run: bun test ./test/cli/compiled-headless-native-lib.test.ts
+ env:
+ HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/${{ matrix.executable }}
+
- name: Verify staged prebuilt packs
run: bun run check:prebuilt-pack
diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml
index 0adb8fae8..75d9d883a 100644
--- a/.github/workflows/pr-ci.yml
+++ b/.github/workflows/pr-ci.yml
@@ -91,6 +91,39 @@ jobs:
- name: Test suite
run: bun test ./src ./packages ./scripts ./test/cli ./test/session
+ compiled-headless-portability:
+ name: Compiled headless portability (${{ matrix.os }})
+ needs: changes
+ if: needs.changes.outputs.code == 'true'
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: macos-latest
+ executable: dist/hunk
+ - os: windows-latest
+ executable: dist/hunk.exe
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ bun-version: 1.3.14
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Build binary
+ run: bun run build:bin
+
+ - name: Verify compiled headless commands skip OpenTUI
+ run: bun test ./test/cli/compiled-headless-native-lib.test.ts
+ env:
+ HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/${{ matrix.executable }}
+
pr-validate:
name: Typecheck + Test + Smoke
needs: changes
@@ -180,6 +213,11 @@ jobs:
- name: Stage prebuilt npm packages
run: bun run build:prebuilt:npm
+ - name: Verify compiled headless commands skip OpenTUI
+ run: bun test ./test/cli/compiled-headless-native-lib.test.ts
+ env:
+ HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/dist/hunk
+
- name: Verify compiled binary watch mode
run: bun test ./test/pty/watch.test.ts
env:
diff --git a/src/core/config.ts b/src/core/config.ts
index 25364e9ed..eea103bac 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -1,7 +1,6 @@
import fs from "node:fs";
import { dirname, join } from "node:path";
-import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes";
-import { normalizeBuiltInThemeId } from "../ui/themes";
+import { BUNDLED_SHIKI_THEME_IDS, resolveBundledShikiThemeId } from "../ui/lib/shikiThemes";
import { LEGACY_CUSTOM_SYNTAX_COLOR_KEYS, resolveSyntaxScopeOverrides } from "./legacySyntaxScopes";
import { resolveGlobalConfigPath } from "./paths";
import { LEGACY_CUSTOM_SYNTAX_NOTICES, type StartupNotice } from "./startupNotice";
@@ -183,7 +182,7 @@ function normalizeCustomThemeBase(value: unknown) {
);
}
- const resolvedThemeId = normalizeBuiltInThemeId(value);
+ const resolvedThemeId = resolveBundledShikiThemeId(value);
if (!resolvedThemeId) {
throw new Error(
`Expected custom_theme.base to be a built-in theme id. Known themes: ${BUILT_IN_THEME_IDS.join(", ")}.`,
diff --git a/src/main.tsx b/src/main.tsx
index 47cf376b1..873830420 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,34 +1,10 @@
#!/usr/bin/env bun
-import { createCliRenderer } from "@opentui/core";
-import { createRoot } from "@opentui/react";
import { formatCliError } from "./core/errors";
-import {
- installJobControlInterruptSupport,
- installJobControlSuspendSupport,
- type JobControlInterruptSupport,
- type JobControlSuspendSupport,
-} from "./core/jobControl";
import { pagePlainText } from "./core/pager";
-import { sanitizeTerminalText } from "./lib/terminalText";
-import { shutdownSession } from "./core/shutdown";
-import { renderStaticDiffPager } from "./ui/staticDiffPager";
import { prepareStartupPlan } from "./core/startup";
-import { shouldUseMouseForApp } from "./core/terminal";
-import { resolveStartupUpdateNotice } from "./core/updateNotice";
-import { AppHost } from "./ui/AppHost";
-import { SessionBrokerClient } from "./session-broker/brokerClient";
+import { sanitizeTerminalText } from "./lib/terminalText";
import { serveSessionBrokerDaemon } from "./session-broker/brokerServer";
-import {
- createInitialSessionSnapshot,
- createSessionRegistration,
-} from "./hunk-session/sessionRegistration";
-import type {
- HunkSessionCommandResult,
- HunkSessionInfo,
- HunkSessionServerMessage,
- HunkSessionState,
-} from "./hunk-session/types";
import { runSessionCommand } from "./session/commands";
async function main() {
@@ -78,6 +54,7 @@ async function main() {
}
if (startupPlan.kind === "static-diff-pager") {
+ const { renderStaticDiffPager } = await import("./ui/staticDiffPager");
process.stdout.write(
await renderStaticDiffPager(startupPlan.text, startupPlan.options, {
customTheme: startupPlan.customTheme,
@@ -91,66 +68,10 @@ async function main() {
throw new Error("Unreachable startup plan.");
}
- const { bootstrap, controllingTerminal } = startupPlan;
- const hostClient = new SessionBrokerClient<
- HunkSessionInfo,
- HunkSessionState,
- HunkSessionServerMessage,
- HunkSessionCommandResult
- >(createSessionRegistration(bootstrap), createInitialSessionSnapshot(bootstrap));
- hostClient.start();
-
- // Keep OpenTUI's platform-safe threading default (enabled on macOS, disabled on Linux).
- const renderer = await createCliRenderer({
- stdin: controllingTerminal?.stdin,
- stdout: process.stdout,
- useMouse: shouldUseMouseForApp({
- hasControllingTerminal: Boolean(controllingTerminal),
- }),
- screenMode: "alternate-screen",
- exitOnCtrlC: false,
- openConsoleOnError: true,
- onDestroy: () => controllingTerminal?.close(),
- });
-
- const appRenderer = renderer;
- const root = createRoot(appRenderer);
- const shutdownSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
- let shuttingDown = false;
- let jobControlSuspendSupport: JobControlSuspendSupport = { dispose: () => undefined };
- let jobControlInterruptSupport: JobControlInterruptSupport = { dispose: () => undefined };
-
- /** Tear down the renderer before exit so the primary terminal screen comes back cleanly. */
- function shutdown() {
- if (shuttingDown) {
- return;
- }
-
- shuttingDown = true;
- for (const signal of shutdownSignals) {
- process.off(signal, shutdown);
- }
- jobControlInterruptSupport.dispose();
- jobControlSuspendSupport.dispose();
- hostClient.stop();
- shutdownSession({ root, renderer: appRenderer });
- }
-
- for (const signal of shutdownSignals) {
- process.once(signal, shutdown);
- }
- jobControlInterruptSupport = installJobControlInterruptSupport(appRenderer, shutdown);
- jobControlSuspendSupport = installJobControlSuspendSupport(appRenderer);
-
- // The app owns the full alternate screen session from this point on.
- root.render(
- ,
- );
+ // OpenTUI stays behind the interactive plan so headless commands never
+ // materialize its embedded native library.
+ const { runInteractiveApp } = await import("./ui/runInteractiveApp");
+ await runInteractiveApp(startupPlan);
}
await main().catch((error) => {
diff --git a/src/ui/lib/shikiThemes.ts b/src/ui/lib/shikiThemes.ts
index 99aa44906..e759568fd 100644
--- a/src/ui/lib/shikiThemes.ts
+++ b/src/ui/lib/shikiThemes.ts
@@ -83,6 +83,16 @@ export function resolveLegacyThemeId(themeId: string | undefined) {
: undefined;
}
+/** Resolve a current or legacy id when it names one bundled theme. */
+export function resolveBundledShikiThemeId(
+ themeId: string | undefined,
+): BundledShikiThemeId | undefined {
+ const resolvedThemeId = resolveLegacyThemeId(themeId);
+ return BUNDLED_SHIKI_THEME_IDS.includes(resolvedThemeId as BundledShikiThemeId)
+ ? (resolvedThemeId as BundledShikiThemeId)
+ : undefined;
+}
+
export interface BundledShikiThemeDiffColors {
added?: string;
removed?: string;
diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts
index 1a01e7776..4f678507f 100644
--- a/src/ui/lib/ui-lib.test.ts
+++ b/src/ui/lib/ui-lib.test.ts
@@ -500,7 +500,7 @@ describe("ui helpers", () => {
).toBe(16);
});
- test("resolveTheme falls back to GitHub defaults while lazily exposing syntax styles", () => {
+ test("resolveTheme falls back to GitHub defaults while exposing semantic syntax colors", () => {
const dracula = resolveTheme("dracula", null);
const missingLight = resolveTheme("missing", "light");
const missingDark = resolveTheme("missing", "dark");
@@ -527,11 +527,6 @@ describe("ui helpers", () => {
expect(custom.accent).toBe("#7755aa");
expect(custom.syntaxScopeOverrides).toEqual({ "keyword.control": "#123456" });
expect(missingCustom.id).toBe("github-dark-default");
- expect(resolveTheme("github-dark-default", null).syntaxStyle).toBeDefined();
- expect(custom.syntaxStyle).toBeDefined();
- expect(resolveTheme("catppuccin-latte", null).syntaxStyle).toBeDefined();
- expect(resolveTheme("catppuccin-frappe", null).syntaxStyle).toBeDefined();
- expect(resolveTheme("catppuccin-macchiato", null).syntaxStyle).toBeDefined();
- expect(resolveTheme("catppuccin-mocha", null).syntaxStyle).toBeDefined();
+ expect(custom.syntaxColors.default).toBe("#1f2328");
});
});
diff --git a/src/ui/runInteractiveApp.tsx b/src/ui/runInteractiveApp.tsx
new file mode 100644
index 000000000..c8d28d4cb
--- /dev/null
+++ b/src/ui/runInteractiveApp.tsx
@@ -0,0 +1,95 @@
+import { createCliRenderer } from "@opentui/core";
+import { createRoot } from "@opentui/react";
+import {
+ installJobControlInterruptSupport,
+ installJobControlSuspendSupport,
+ type JobControlInterruptSupport,
+ type JobControlSuspendSupport,
+} from "../core/jobControl";
+import { shutdownSession } from "../core/shutdown";
+import { shouldUseMouseForApp, type ControllingTerminal } from "../core/terminal";
+import type { AppBootstrap } from "../core/types";
+import { resolveStartupUpdateNotice } from "../core/updateNotice";
+import {
+ createInitialSessionSnapshot,
+ createSessionRegistration,
+} from "../hunk-session/sessionRegistration";
+import type {
+ HunkSessionCommandResult,
+ HunkSessionInfo,
+ HunkSessionServerMessage,
+ HunkSessionState,
+} from "../hunk-session/types";
+import { SessionBrokerClient } from "../session-broker/brokerClient";
+import { AppHost } from "./AppHost";
+
+export interface InteractiveAppInput {
+ bootstrap: AppBootstrap;
+ controllingTerminal: ControllingTerminal | null;
+}
+
+/** Load and run the OpenTUI review app after startup has selected an interactive plan. */
+export async function runInteractiveApp({
+ bootstrap,
+ controllingTerminal,
+}: InteractiveAppInput): Promise {
+ const hostClient = new SessionBrokerClient<
+ HunkSessionInfo,
+ HunkSessionState,
+ HunkSessionServerMessage,
+ HunkSessionCommandResult
+ >(createSessionRegistration(bootstrap), createInitialSessionSnapshot(bootstrap));
+ hostClient.start();
+
+ // Keep OpenTUI's platform-safe threading default (enabled on macOS, disabled on Linux).
+ const renderer = await createCliRenderer({
+ stdin: controllingTerminal?.stdin,
+ stdout: process.stdout,
+ useMouse: shouldUseMouseForApp({
+ hasControllingTerminal: Boolean(controllingTerminal),
+ }),
+ screenMode: "alternate-screen",
+ exitOnCtrlC: false,
+ openConsoleOnError: true,
+ onDestroy: () => controllingTerminal?.close(),
+ });
+
+ const appRenderer = renderer;
+ const root = createRoot(appRenderer);
+ const shutdownSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
+ let shuttingDown = false;
+ let jobControlSuspendSupport: JobControlSuspendSupport = { dispose: () => undefined };
+ let jobControlInterruptSupport: JobControlInterruptSupport = { dispose: () => undefined };
+
+ /** Tear down the renderer before exit so the primary terminal screen comes back cleanly. */
+ function shutdown() {
+ if (shuttingDown) {
+ return;
+ }
+
+ shuttingDown = true;
+ for (const signal of shutdownSignals) {
+ process.off(signal, shutdown);
+ }
+ jobControlInterruptSupport.dispose();
+ jobControlSuspendSupport.dispose();
+ hostClient.stop();
+ shutdownSession({ root, renderer: appRenderer });
+ }
+
+ for (const signal of shutdownSignals) {
+ process.once(signal, shutdown);
+ }
+ jobControlInterruptSupport = installJobControlInterruptSupport(appRenderer, shutdown);
+ jobControlSuspendSupport = installJobControlSuspendSupport(appRenderer);
+
+ // The app owns the full alternate screen session from this point on.
+ root.render(
+ ,
+ );
+}
diff --git a/src/ui/themes.test.ts b/src/ui/themes.test.ts
index 5685f86a3..845f28f1b 100644
--- a/src/ui/themes.test.ts
+++ b/src/ui/themes.test.ts
@@ -65,7 +65,7 @@ describe("themes", () => {
expect(theme.id).toBe(themeId);
expect(theme.label).toBe(themeId);
expect(theme.syntaxTheme).toBe(themeId);
- expect(theme.syntaxStyle).toBeDefined();
+ expect(theme.syntaxColors.default).toBeTruthy();
}
});
diff --git a/src/ui/themes.ts b/src/ui/themes.ts
index 7a68c295f..1244ad0bb 100644
--- a/src/ui/themes.ts
+++ b/src/ui/themes.ts
@@ -4,14 +4,13 @@ import type { CustomThemeConfig } from "../core/types";
import { blendHex, contrastRatio, relativeLuminance } from "./lib/color";
import {
BUNDLED_SHIKI_THEME_IDS,
- resolveLegacyThemeId,
+ resolveBundledShikiThemeId,
getBundledShikiThemeBackground,
getBundledShikiThemeDiffColors,
getBundledShikiThemeForeground,
type BundledShikiThemeDiffColors,
type BundledShikiThemeId,
} from "./lib/shikiThemes";
-import { withLazySyntaxStyle } from "./themes/syntax";
import type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types";
export type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types";
@@ -232,7 +231,7 @@ function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme {
syntaxTheme: themeId,
};
- return withLazySyntaxStyle(themeBase, syntaxColors);
+ return { ...themeBase, syntaxColors };
}
export const THEMES: AppTheme[] = BUNDLED_SHIKI_THEME_IDS.map((themeId) =>
@@ -241,7 +240,7 @@ export const THEMES: AppTheme[] = BUNDLED_SHIKI_THEME_IDS.map((themeId) =>
/** Return the built-in theme by id so config-defined themes can inherit from it. */
function builtInThemeById(themeId: string | undefined) {
- const resolvedThemeId = resolveLegacyThemeId(themeId);
+ const resolvedThemeId = resolveBundledShikiThemeId(themeId);
return THEMES.find((theme) => theme.id === resolvedThemeId);
}
@@ -298,7 +297,7 @@ function buildCustomTheme(customTheme: CustomThemeConfig) {
syntaxScopeOverrides: resolveSyntaxScopeOverrides(customTheme.syntax, customTheme.syntaxScopes),
};
- return withLazySyntaxStyle(themeBase, baseTheme.syntaxColors);
+ return { ...themeBase, syntaxColors: baseTheme.syntaxColors };
}
/** Return the theme ids the app should expose based on whether config defines a custom palette. */
@@ -337,16 +336,6 @@ export function resolveTheme(
return fallbackTheme(themeMode);
}
-/** Return whether a custom theme base id can inherit from a built-in theme. */
-export function isBuiltInThemeId(themeId: string) {
- return builtInThemeById(themeId) !== undefined;
-}
-
-/** Return the canonical built-in theme id, preserving legacy config compatibility. */
-export function normalizeBuiltInThemeId(themeId: string) {
- return isBuiltInThemeId(themeId) ? resolveLegacyThemeId(themeId) : undefined;
-}
-
/** Return known semantic diff colors for a bundled Shiki-backed theme. */
export function bundledThemeDiffColors(themeId: string): BundledShikiThemeDiffColors | undefined {
return getBundledShikiThemeDiffColors(themeId);
diff --git a/src/ui/themes/syntax.ts b/src/ui/themes/syntax.ts
deleted file mode 100644
index 8a9d06995..000000000
--- a/src/ui/themes/syntax.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { RGBA, SyntaxStyle } from "@opentui/core";
-import type { AppTheme, SyntaxColors, ThemeBase } from "./types";
-
-/** Build the syntax palette OpenTUI should use for in-terminal code rendering. */
-export function createSyntaxStyle(colors: SyntaxColors) {
- return SyntaxStyle.fromStyles({
- default: { fg: RGBA.fromHex(colors.default) },
- keyword: { fg: RGBA.fromHex(colors.keyword), bold: true },
- string: { fg: RGBA.fromHex(colors.string) },
- comment: { fg: RGBA.fromHex(colors.comment), italic: true },
- number: { fg: RGBA.fromHex(colors.number) },
- function: { fg: RGBA.fromHex(colors.function) },
- method: { fg: RGBA.fromHex(colors.function) },
- property: { fg: RGBA.fromHex(colors.property) },
- variable: { fg: RGBA.fromHex(colors.variable ?? colors.default) },
- constant: { fg: RGBA.fromHex(colors.number), bold: true },
- type: { fg: RGBA.fromHex(colors.type) },
- class: { fg: RGBA.fromHex(colors.type) },
- operator: { fg: RGBA.fromHex(colors.operator ?? colors.punctuation) },
- punctuation: { fg: RGBA.fromHex(colors.punctuation) },
- });
-}
-
-/** Lazily attach syntax colors so startup only pays for the active theme's token style. */
-export function withLazySyntaxStyle(theme: ThemeBase, syntaxColors: SyntaxColors): AppTheme {
- let syntaxStyle: SyntaxStyle | null = null;
-
- return {
- ...theme,
- syntaxColors,
- get syntaxStyle() {
- syntaxStyle ??= createSyntaxStyle(syntaxColors);
- return syntaxStyle;
- },
- };
-}
diff --git a/src/ui/themes/types.ts b/src/ui/themes/types.ts
index 1b9d879e5..0b5b0da84 100644
--- a/src/ui/themes/types.ts
+++ b/src/ui/themes/types.ts
@@ -1,5 +1,3 @@
-import type { SyntaxStyle } from "@opentui/core";
-
export interface AppTheme {
id: string;
label: string;
@@ -42,7 +40,6 @@ export interface AppTheme {
/** Exact Shiki/TextMate scope colors layered onto the base syntax theme. */
syntaxScopeOverrides?: Record;
syntaxColors: SyntaxColors;
- syntaxStyle: SyntaxStyle;
}
export type SyntaxColors = {
@@ -59,4 +56,4 @@ export type SyntaxColors = {
punctuation: string;
};
-export type ThemeBase = Omit;
+export type ThemeBase = Omit;
diff --git a/test/cli/compiled-headless-native-lib.test.ts b/test/cli/compiled-headless-native-lib.test.ts
new file mode 100644
index 000000000..d513f2591
--- /dev/null
+++ b/test/cli/compiled-headless-native-lib.test.ts
@@ -0,0 +1,231 @@
+import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
+import { createServer } from "node:net";
+import { mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+
+const executable = process.env.HUNK_TEST_EXECUTABLE
+ ? resolve(process.env.HUNK_TEST_EXECUTABLE)
+ : undefined;
+const compiledTest = executable ? test : test.skip;
+const compiledLinuxTest = executable && process.platform === "linux" ? test : test.skip;
+const BUN_NATIVE_ARTIFACT_PATTERN = /^\.[0-9a-f]{16}-[0-9a-f]{8}\.(?:so|dylib|dll)$/;
+const positiveControlBuildRoot = executable
+ ? mkdtempSync(resolve(tmpdir(), "hunk-compiled-opentui-control-"))
+ : undefined;
+const positiveControlExecutable = positiveControlBuildRoot
+ ? resolve(
+ positiveControlBuildRoot,
+ process.platform === "win32" ? "opentui-control.exe" : "opentui-control",
+ )
+ : undefined;
+
+let rootsToClean: string[] = [];
+
+beforeAll(() => {
+ if (!positiveControlExecutable) {
+ return;
+ }
+
+ const source = resolve(import.meta.dir, "fixtures", "compiled-opentui-positive-control.ts");
+ const build = Bun.spawnSync(
+ [
+ process.execPath,
+ "build",
+ "--compile",
+ "--no-compile-autoload-bunfig",
+ source,
+ "--outfile",
+ positiveControlExecutable,
+ ],
+ {
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ },
+ );
+ if (build.exitCode !== 0) {
+ throw new Error(
+ `Failed to build the OpenTUI positive control: ${Buffer.from(build.stderr).toString("utf8")}`,
+ );
+ }
+});
+
+afterAll(() => {
+ if (positiveControlBuildRoot) {
+ rmSync(positiveControlBuildRoot, { recursive: true, force: true });
+ }
+});
+
+afterEach(() => {
+ for (const root of rootsToClean) {
+ rmSync(root, { recursive: true, force: true });
+ }
+ rootsToClean = [];
+});
+
+/** Create isolated home, cache, runtime, and temp directories for one compiled-binary test. */
+function createTestEnvironment(port?: number) {
+ const root = mkdtempSync(resolve(tmpdir(), "hunk-compiled-headless-test-"));
+ rootsToClean.push(root);
+ const home = resolve(root, "home");
+ const cache = resolve(root, "cache");
+ const runtime = resolve(root, "runtime");
+ const temp = resolve(root, "tmp");
+ for (const dir of [home, cache, runtime, temp]) {
+ mkdirSync(dir, { recursive: true });
+ }
+
+ return {
+ temp,
+ env: {
+ ...process.env,
+ HOME: home,
+ USERPROFILE: home,
+ XDG_CACHE_HOME: cache,
+ XDG_RUNTIME_DIR: runtime,
+ TMPDIR: temp,
+ BUN_TMPDIR: temp,
+ TEMP: temp,
+ TMP: temp,
+ ...(port === undefined ? {} : { HUNK_MCP_PORT: String(port) }),
+ },
+ };
+}
+
+/** Return Bun's hidden native-library extraction artifacts from an isolated temp directory. */
+function nativeArtifacts(temp: string) {
+ return readdirSync(temp).filter((name) => BUN_NATIVE_ARTIFACT_PATTERN.test(name));
+}
+
+/** Quote one path for the Bash command used to provide file-backed pager stdin. */
+function shellQuote(value: string) {
+ return `'${value.replaceAll("'", `'\\''`)}'`;
+}
+
+/** Reserve and release one loopback port for the compiled daemon test. */
+async function reserveFreePort() {
+ const listener = createServer(() => undefined);
+ await new Promise((resolveListen, reject) => {
+ listener.once("error", reject);
+ listener.listen(0, "127.0.0.1", resolveListen);
+ });
+ const address = listener.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+ await new Promise((resolveClose) => listener.close(() => resolveClose()));
+ return port;
+}
+
+/** Wait until the compiled session daemon responds to its health endpoint. */
+async function waitForDaemon(port: number) {
+ const deadline = Date.now() + 5_000;
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(`http://127.0.0.1:${port}/health`);
+ if (response.ok) {
+ return;
+ }
+ } catch {
+ // The daemon may still be binding its loopback listener.
+ }
+ await Bun.sleep(50);
+ }
+ throw new Error("Timed out waiting for the compiled Hunk daemon.");
+}
+
+describe("compiled headless native-library loading", () => {
+ // Calibrate the platform temp path and filename matcher before trusting negative assertions.
+ compiledTest("detects extraction from an eager OpenTUI positive control", () => {
+ const { env, temp } = createTestEnvironment();
+ const proc = Bun.spawnSync([positiveControlExecutable!], {
+ env,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+
+ expect(proc.exitCode).toBe(0);
+ expect(nativeArtifacts(temp).length).toBeGreaterThan(0);
+ });
+
+ compiledTest("does not extract OpenTUI for short-lived headless commands", () => {
+ const { env, temp } = createTestEnvironment();
+ const commands: Array<{ args: string[]; stdin?: string }> = [
+ { args: ["--help"] },
+ { args: ["--version"] },
+ { args: ["session", "--help"] },
+ { args: ["skill", "path"] },
+ { args: ["markup", "guide"] },
+ { args: ["markup", "render", "-"], stdin: "Hello\n" },
+ { args: ["pager"], stdin: "plain pager text\n" },
+ {
+ args: ["pager"],
+ stdin: "diff --git a/a.txt b/a.txt\n@@ -1 +1 @@\n-old\n+new\n",
+ },
+ ];
+
+ for (const command of commands) {
+ const proc = Bun.spawnSync([executable!, ...command.args], {
+ env,
+ stdin: command.stdin === undefined ? "ignore" : Buffer.from(command.stdin),
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ expect(proc.exitCode).toBe(0);
+ expect(nativeArtifacts(temp)).toEqual([]);
+ }
+ });
+
+ compiledLinuxTest("keeps captured-host static pager rendering OpenTUI-free", () => {
+ const { env, temp } = createTestEnvironment();
+ const patch =
+ "diff --git a/a.txt b/a.txt\nindex 7898192..6178079 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n";
+ const proc = Bun.spawnSync(
+ ["script", "-qec", `${shellQuote(executable!)} pager`, "/dev/null"],
+ {
+ env: {
+ ...env,
+ TERM: "dumb",
+ LAZYGIT_CONFIG_DIR: temp,
+ },
+ stdin: Buffer.from(patch),
+ stdout: "pipe",
+ stderr: "pipe",
+ },
+ );
+
+ expect(proc.exitCode).toBe(0);
+ expect(Buffer.from(proc.stdout).toString("utf8")).toContain("a.txt");
+ expect(nativeArtifacts(temp)).toEqual([]);
+ });
+
+ compiledTest("keeps the daemon and session polling paths OpenTUI-free", async () => {
+ const port = await reserveFreePort();
+ const { env, temp } = createTestEnvironment(port);
+ const daemon = Bun.spawn([executable!, "daemon", "serve"], {
+ env,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+
+ try {
+ await waitForDaemon(port);
+ const sessionList = Bun.spawnSync([executable!, "session", "list"], {
+ env,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+
+ expect(sessionList.exitCode).toBe(0);
+ expect(Buffer.from(sessionList.stdout).toString("utf8")).toContain(
+ "No active Hunk sessions.",
+ );
+ expect(nativeArtifacts(temp)).toEqual([]);
+ } finally {
+ daemon.kill("SIGTERM");
+ await daemon.exited;
+ }
+ });
+});
diff --git a/test/cli/fixtures/compiled-opentui-positive-control.ts b/test/cli/fixtures/compiled-opentui-positive-control.ts
new file mode 100644
index 000000000..761a1aa78
--- /dev/null
+++ b/test/cli/fixtures/compiled-opentui-positive-control.ts
@@ -0,0 +1,3 @@
+import { RGBA } from "@opentui/core";
+
+process.stdout.write(RGBA.fromHex("#ffffff").toString());