diff --git a/hermes_cli/main.py b/hermes_cli/main.py index bdbf0390a68..7b5834aea6b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -841,6 +841,11 @@ _NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert"}) def _tui_need_npm_install(root: Path) -> bool: """True when @hermes/ink is missing or node_modules is behind package-lock.json. + Prebuilt bundle mode: when ``dist/entry.js`` exists and there is no + ``package-lock.json`` (nix install layout only ships ``dist/`` + + ``package.json``), skip reinstall entirely — the bundle is self-contained + and there is nothing to install. + Compares ``package-lock.json`` against ``node_modules/.package-lock.json`` (npm's hidden lockfile) by **content**, not mtime: git checkouts and npm rewrites can bump the root lockfile's timestamp even when installed deps @@ -858,10 +863,16 @@ def _tui_need_npm_install(root: Path) -> bool: we'd rather not force a reinstall for them. Falls back to mtime comparison if either lockfile is unparseable. """ + lock = root / "package-lock.json" + entry = root / "dist" / "entry.js" + # Prebuilt self-contained bundle (nix / packaged release): no lockfile + # shipped, dist/entry.js is the single runtime artefact. + if entry.is_file() and not lock.is_file(): + return False + ink = root / "node_modules" / "@hermes" / "ink" / "package.json" if not ink.is_file(): return True - lock = root / "package-lock.json" if not lock.is_file(): return False marker = root / "node_modules" / ".package-lock.json" diff --git a/nix/checks.nix b/nix/checks.nix index 8adb56628d2..269699eef66 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -154,8 +154,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) test -f ${hermes-agent}/ui-tui/dist/entry.js || (echo "FAIL: compiled entry.js missing"; exit 1) echo "PASS: compiled entry.js present" - test -d ${hermes-agent}/ui-tui/node_modules || (echo "FAIL: node_modules missing"; exit 1) - echo "PASS: node_modules present" + # self-contained bundle; no runtime node_modules expected grep -q "HERMES_TUI_DIR" ${hermes-agent}/bin/hermes || \ (echo "FAIL: HERMES_TUI_DIR not in wrapper"; exit 1) diff --git a/nix/tui.nix b/nix/tui.nix index 7453fa2673d..eb25d52f905 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -24,16 +24,11 @@ pkgs.buildNpmPackage (npm // { mkdir -p $out/lib/hermes-tui + # Single self-contained bundle built by scripts/build.mjs (esbuild). + # No runtime node_modules needed. cp -r dist $out/lib/hermes-tui/dist - # runtime node_modules - cp -r node_modules $out/lib/hermes-tui/node_modules - - # @hermes/ink is a file: dependency, we need to copy it in fr - rm -f $out/lib/hermes-tui/node_modules/@hermes/ink - cp -r packages/hermes-ink $out/lib/hermes-tui/node_modules/@hermes/ink - - # package.json needed for "type": "module" resolution + # package.json kept for "type": "module" resolution on `node dist/entry.js`. cp package.json $out/lib/hermes-tui/ runHook postInstall diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index e56196e07ed..8e16c5e9dea 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -89,6 +89,12 @@ def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod) assert main_mod._tui_need_npm_install(tmp_path) is False +def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None: + """dist/entry.js present and no package-lock.json → prebuilt bundle, skip npm install.""" + _touch_tui_entry(tmp_path) + assert main_mod._tui_need_npm_install(tmp_path) is False + + def test_build_needed_when_local_ink_bundle_missing(tmp_path: Path, main_mod) -> None: _touch_tui_entry(tmp_path) _touch_ink(tmp_path) diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 017e9913bd9..c6d1e6be49d 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -25,6 +25,7 @@ "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", "babel-plugin-react-compiler": "^1.0.0", + "esbuild": "~0.27.0", "eslint": "^9", "eslint-plugin-perfectionist": "^5", "eslint-plugin-react": "^7", diff --git a/ui-tui/package.json b/ui-tui/package.json index 061e3bc4484..1edee8cabfe 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -6,8 +6,7 @@ "scripts": { "dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx", "start": "tsx src/entry.tsx", - "build": "npm run build --prefix packages/hermes-ink && tsc -p tsconfig.build.json && npm run build:compile && chmod +x dist/entry.js", - "build:compile": "babel dist --out-dir dist --config-file ./babel.compiler.config.cjs --extensions .js --keep-file-extension", + "build": "node scripts/build.mjs", "type-check": "tsc --noEmit -p tsconfig.json", "lint": "eslint src/ packages/", "lint:fix": "eslint src/ packages/ --fix", @@ -34,6 +33,7 @@ "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", "babel-plugin-react-compiler": "^1.0.0", + "esbuild": "~0.27.0", "eslint": "^9", "eslint-plugin-perfectionist": "^5", "eslint-plugin-react": "^7", diff --git a/ui-tui/scripts/build.mjs b/ui-tui/scripts/build.mjs new file mode 100644 index 00000000000..2c7b55f76fc --- /dev/null +++ b/ui-tui/scripts/build.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Bundles src/entry.tsx into a single self-contained dist/entry.js. +// No runtime node_modules needed. +import { build } from 'esbuild' +import { readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '..') +const out = resolve(root, 'dist/entry.js') + +// `react-devtools-core` is only imported when DEV=true at runtime (Ink dev +// mode). Stub it out so the bundle doesn't carry the dep. +const stubDevtools = { + name: 'stub-react-devtools-core', + setup(b) { + b.onResolve({ filter: /^react-devtools-core$/ }, args => ({ + path: args.path, + namespace: 'stub-devtools' + })) + b.onLoad({ filter: /.*/, namespace: 'stub-devtools' }, () => ({ + contents: 'export default { initialize() {}, connectToDevTools() {} }', + loader: 'js' + })) + } +} + +await build({ + entryPoints: [resolve(root, 'src/entry.tsx')], + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + outfile: out, + jsx: 'automatic', + jsxImportSource: 'react', + // Skip the prebuilt @hermes/ink bundle — esbuild's __esm helper doesn't + // await nested async init, which breaks lazy-initialized exports like + // `render`. Bundling from source sidesteps that. + alias: { '@hermes/ink': resolve(root, 'packages/hermes-ink/src/entry-exports.ts') }, + plugins: [stubDevtools], + // Some transitive deps use CommonJS `require(...)` at runtime. ESM bundles + // don't get a `require` binding automatically, so we inject one. + banner: { + js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);" + }, + logLevel: 'info' +}) + +// esbuild preserves the shebang from src/entry.tsx into the bundle, but Nix's +// patchShebangs phase mangles `/usr/bin/env -S node --foo --bar` (it strips +// the `node` token, leaving a broken interpreter). The hermes_cli launcher +// always invokes this file as `node dist/entry.js` anyway, so the shebang is +// redundant — strip it. +const body = readFileSync(out, 'utf8') +if (body.startsWith('#!')) { + writeFileSync(out, body.slice(body.indexOf('\n') + 1)) +} + +console.log(`built ${out}`)