From 42627b4eafbe632fb663238b7f595f1cdb67acd4 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 30 Apr 2026 13:14:19 -0400 Subject: [PATCH 1/7] refactor(tui): bundle with esbuild, drop runtime node_modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the tsc + babel pipeline with a single esbuild invocation that produces a self-contained dist/entry.js. The nix TUI derivation no longer copies node_modules — only dist/ + package.json ship, shrinking the output from hundreds of MB to ~2.9 MB. - ui-tui/scripts/build.mjs: new esbuild bundler. Aliases @hermes/ink to source (esbuild's __esm helper doesn't await nested async init, which breaks lazy-assigned exports like 'render' when re-exporting through a prebuilt submodule). Stubs react-devtools-core (dev-only). Injects a createRequire shim for transitive CJS deps. Strips the shebang from src/entry.tsx because Nix patchShebangs mangles '/usr/bin/env -S node --max-old-space-size=8192 --expose-gc' — it drops the 'node' token. The Python launcher always invokes node explicitly, so the shebang is redundant. - nix/tui.nix: installPhase no longer copies node_modules or the @hermes/ink packages dir. - nix/checks.nix: drop the 'node_modules present' assertion. - hermes_cli/main.py: _tui_need_npm_install short-circuits when dist/entry.js exists and no package-lock.json is present. That is the prebuilt-bundle layout (nix / packaged release) and there is nothing to install. Without this, the launcher tried to npm install in a non-existent site-packages/ui-tui path. --- hermes_cli/main.py | 13 ++++- nix/checks.nix | 3 +- nix/tui.nix | 11 ++--- tests/hermes_cli/test_tui_npm_install.py | 6 +++ ui-tui/package-lock.json | 1 + ui-tui/package.json | 4 +- ui-tui/scripts/build.mjs | 61 ++++++++++++++++++++++++ 7 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 ui-tui/scripts/build.mjs 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}`) From 279504d5b887ad57a7da4c3ca8d7d534d7ca47fe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:49:01 +0000 Subject: [PATCH 2/7] fix(nix): refresh npm lockfile hashes --- nix/tui.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/tui.nix b/nix/tui.nix index eb25d52f905..52875074b44 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-Chz+NW9NXqboXHOa6PKwf5bhAkkcFtKNhvKWwg2XSPc="; + hash = "sha256-hxBD2zsPwdSoUL57feFFGqZ2Z1xIHxERwmQa/jIqNZw="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; }; From 42e166c7ea2e00e86c0e77f6a6536140b3707827 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 30 Apr 2026 16:57:02 -0400 Subject: [PATCH 3/7] refactor(docker): drop manual @hermes/ink build, rely on esbuild bundle the esbuild pipeline (scripts/build.mjs) already bundles ink into a single self-contained dist/entry.js. remove the Dockerfile steps that manually copied packages/hermes-ink into node_modules/@hermes/ink and ran a nested npm install there. - Dockerfile: simplify TUI build step to just 'npm run build' - hermes_cli/main.py: _tui_build_needed now checks dist/entry.js staleness against source files before falling back to the old ink-bundle.js logic - tests: update TUI npm install tests and drop the Dockerfile contract test for the removed ink materialization step --- Dockerfile | 8 +----- hermes_cli/main.py | 32 ++++++++++++++++++++- tests/hermes_cli/test_tui_npm_install.py | 17 +++++++++-- tests/tools/test_dockerfile_pid1_reaping.py | 14 --------- 4 files changed, 47 insertions(+), 24 deletions(-) diff --git a/Dockerfile b/Dockerfile index 18177cc1aca..be147b6eac6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,13 +45,7 @@ COPY --chown=hermes:hermes . . # Build browser dashboard and terminal UI assets. RUN cd web && npm run build && \ - cd ../ui-tui && npm run build && \ - rm -rf node_modules/@hermes/ink && \ - rm -rf packages/hermes-ink/node_modules && \ - cp -R packages/hermes-ink node_modules/@hermes/ink && \ - npm install --omit=dev --prefer-offline --no-audit --prefix node_modules/@hermes/ink && \ - rm -rf node_modules/@hermes/ink/node_modules/react && \ - node --input-type=module -e "await import('@hermes/ink')" + cd ../ui-tui && npm run build # ---------- Permissions ---------- # Make install dir world-readable so any HERMES_UID can read it at runtime. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7b5834aea6b..831cd762579 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -922,9 +922,39 @@ def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: def _tui_build_needed(tui_dir: Path) -> bool: + entry = tui_dir / "dist" / "entry.js" + # In the esbuild pipeline, ink is bundled into dist/entry.js directly. + # If the main bundle exists and is up to date with all source files, + # no separate ink rebuild is needed. + if entry.exists(): + dist_m = entry.stat().st_mtime + skip = frozenset({"node_modules", "dist"}) + stale = False + for dirpath, dirnames, filenames in os.walk(tui_dir, topdown=True): + dirnames[:] = [d for d in dirnames if d not in skip] + for fn in filenames: + if fn.endswith((".ts", ".tsx")): + if os.path.getmtime(os.path.join(dirpath, fn)) > dist_m: + stale = True + break + if stale: + break + if not stale: + for meta in ( + "package.json", + "package-lock.json", + "tsconfig.json", + "tsconfig.build.json", + ): + mp = tui_dir / meta + if mp.exists() and mp.stat().st_mtime > dist_m: + stale = True + break + if not stale: + return False + if _hermes_ink_bundle_stale(tui_dir): return True - entry = tui_dir / "dist" / "entry.js" if not entry.exists(): return True dist_m = entry.stat().st_mtime diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index 8e16c5e9dea..0ef98c9ea67 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -95,12 +95,25 @@ def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None: 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: +def test_build_needed_when_source_newer_than_entry(tmp_path: Path, main_mod) -> None: + _touch_tui_entry(tmp_path) + _touch_ink(tmp_path) + src = tmp_path / "src" / "entry.tsx" + src.parent.mkdir(parents=True, exist_ok=True) + src.write_text("console.log('newer')") + os.utime(src, (200, 200)) + os.utime(tmp_path / "dist" / "entry.js", (100, 100)) + + assert main_mod._tui_need_npm_install(tmp_path) is False + assert main_mod._tui_build_needed(tmp_path) is True + + +def test_build_not_needed_when_entry_exists_and_sources_unchanged(tmp_path: Path, main_mod) -> None: _touch_tui_entry(tmp_path) _touch_ink(tmp_path) assert main_mod._tui_need_npm_install(tmp_path) is False - assert main_mod._tui_build_needed(tmp_path) is True + assert main_mod._tui_build_needed(tmp_path) is False def test_build_not_needed_when_entry_and_ink_bundle_present(tmp_path: Path, main_mod) -> None: diff --git a/tests/tools/test_dockerfile_pid1_reaping.py b/tests/tools/test_dockerfile_pid1_reaping.py index 52532a78dd2..960415d417b 100644 --- a/tests/tools/test_dockerfile_pid1_reaping.py +++ b/tests/tools/test_dockerfile_pid1_reaping.py @@ -121,20 +121,6 @@ def test_dockerfile_builds_tui_assets(dockerfile_text): ) -def test_dockerfile_materializes_local_tui_ink_package(dockerfile_text): - assert any( - "ui-tui" in step - and "node_modules/@hermes/ink" in step - and "packages/hermes-ink" in step - and "rm -rf packages/hermes-ink/node_modules" in step - and "npm install --omit=dev" in step - and "--prefix node_modules/@hermes/ink" in step - and "rm -rf node_modules/@hermes/ink/node_modules/react" in step - and "await import('@hermes/ink')" in step - for step in _run_steps(dockerfile_text) - ) - - def test_dockerignore_excludes_nested_dependency_dirs(): if not DOCKERIGNORE.exists(): pytest.skip(".dockerignore not present in this checkout") From 42df7ec597e0ddd96edc7c0cb0e1c805a7cfcf6b Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 30 Apr 2026 17:43:59 -0400 Subject: [PATCH 4/7] fix(tui): update comments --- nix/tui.nix | 1 - website/docs/user-guide/tui.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/nix/tui.nix b/nix/tui.nix index 52875074b44..55f24375bfb 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -25,7 +25,6 @@ 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 # package.json kept for "type": "module" resolution on `node dist/entry.js`. diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index c7f0eeb8442..c8b5aa72881 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -66,7 +66,7 @@ export HERMES_TUI_DIR=/path/to/prebuilt/ui-tui hermes --tui ``` -The directory must contain `dist/entry.js` and an up-to-date `node_modules`. +The directory must contain `dist/entry.js`. ## Keybindings From 242659f5af3121ef9bb397c2559f67fdfbce13a4 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 30 Apr 2026 18:22:21 -0400 Subject: [PATCH 5/7] fix(tui): don't hardcode /home/bb --- scripts/profile-tui.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/profile-tui.py b/scripts/profile-tui.py index 18cbbc74d76..b55febb9d9a 100755 --- a/scripts/profile-tui.py +++ b/scripts/profile-tui.py @@ -15,7 +15,7 @@ session-picker flow. Environment overrides: HERMES_PERF_LOG (default ~/.hermes/perf.log) HERMES_PERF_NODE (default node from $PATH) - HERMES_TUI_DIR (default /home/bb/hermes-agent/ui-tui) + HERMES_TUI_DIR (default: /ui-tui relative to this script) Exit code is 0 if the harness ran and parsed results, 2 if the TUI crashed or produced no perf data (suggests HERMES_DEV_PERF wiring is broken). @@ -36,7 +36,10 @@ from pathlib import Path from typing import Any -DEFAULT_TUI_DIR = Path(os.environ.get("HERMES_TUI_DIR", "/home/bb/hermes-agent/ui-tui")) +DEFAULT_TUI_DIR = Path( + os.environ.get("HERMES_TUI_DIR") + or str(Path(__file__).resolve().parent.parent / "ui-tui") +) DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(Path.home() / ".hermes" / "perf.log"))) DEFAULT_STATE_DB = Path.home() / ".hermes" / "state.db" From 9d645d98c445c94826eb0d88ba5fe8ab62faf483 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 30 Apr 2026 18:23:15 -0400 Subject: [PATCH 6/7] fix(tui): update README --- ui-tui/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/README.md b/ui-tui/README.md index 17d57f08afe..60ded94fd84 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -41,7 +41,7 @@ From the repo root, the normal path is: hermes --tui ``` -The CLI expects `ui-tui/node_modules` to exist. If the TUI deps are missing: +The CLI expects `ui-tui/dist/entry.js` to exist, or the whole source code available in which to run `npm install` and `npm run dev`. ```bash cd ui-tui From c6ca11618a87c6b12e9a4025d339eb905a03ac8c Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 11 May 2026 17:04:34 -0400 Subject: [PATCH 7/7] refactor(tui): simplify TUI build logic, remove stale staleness checks The old mtime-tracking staleness machinery (_tui_build_needed, _hermes_ink_bundle_stale, _find_bundled_tui) tried to avoid rebuilding by comparing source timestamps to dist/entry.js. This was fragile and added ~100 lines of code. Replace with three clear paths: 1. HERMES_TUI_DIR set (prebuilt/nix): just node dist/entry.js, no build 2. --dev mode: tsx src/entry.tsx, no build, hot reload 3. Normal: always npm run build (esbuild is ~1s, correctness > caching) Also error when HERMES_TUI_DIR is set with --dev (footgun: prebuilt bundle has no source code to hot-reload). --- hermes_cli/main.py | 136 ++++++----------------- tests/hermes_cli/test_tui_npm_install.py | 35 ------ 2 files changed, 33 insertions(+), 138 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 15246a88ab5..009af00b6ee 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -967,66 +967,6 @@ def _tui_need_npm_install(root: Path) -> bool: return False -def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: - """Directory whose dist/entry.js we should run: HERMES_TUI_DIR first, else repo ui-tui.""" - env = os.environ.get("HERMES_TUI_DIR") - if env: - p = Path(env) - if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p): - return p - if (tui_dir / "dist" / "entry.js").exists() and not _tui_need_npm_install(tui_dir): - return tui_dir - return None - - -def _tui_build_needed(tui_dir: Path) -> bool: - # esbuild bundles @hermes/ink + source directly into dist/entry.js; - # the old ink-bundle.js check only fires when entry.js hasn't been - # produced yet (a dev checkout that hasn't been built at all). Once - # entry.js exists, the mtime walk below covers all source trees. - entry = tui_dir / "dist" / "entry.js" - if not entry.exists(): - # Nothing built yet — signal that a build is needed. - return True - dist_m = entry.stat().st_mtime - skip = frozenset({"node_modules", "dist"}) - for dirpath, dirnames, filenames in os.walk(tui_dir, topdown=True): - dirnames[:] = [d for d in dirnames if d not in skip] - for fn in filenames: - if fn.endswith((".ts", ".tsx")): - if os.path.getmtime(os.path.join(dirpath, fn)) > dist_m: - return True - for meta in ( - "package.json", - "package-lock.json", - "tsconfig.json", - "tsconfig.build.json", - ): - mp = tui_dir / meta - if mp.exists() and mp.stat().st_mtime > dist_m: - return True - return False - - -def _hermes_ink_bundle_stale(tui_dir: Path) -> bool: - ink_root = tui_dir / "packages" / "hermes-ink" - bundle = ink_root / "dist" / "ink-bundle.js" - if not bundle.exists(): - return True - bm = bundle.stat().st_mtime - skip = frozenset({"node_modules", "dist"}) - for dirpath, dirnames, filenames in os.walk(ink_root, topdown=True): - dirnames[:] = [d for d in dirnames if d not in skip] - for fn in filenames: - if fn.endswith((".ts", ".tsx")): - if os.path.getmtime(os.path.join(dirpath, fn)) > bm: - return True - mp = ink_root / "package.json" - if mp.exists() and mp.stat().st_mtime > bm: - return True - return False - - def _ensure_tui_node() -> None: """Make sure `node` + `npm` are on PATH for the TUI. @@ -1085,7 +1025,7 @@ def _ensure_tui_node() -> None: def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: - """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR or ui-tui, build when stale).""" + """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).""" _ensure_tui_node() def _node_bin(bin: str) -> str: @@ -1099,23 +1039,31 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: sys.exit(1) return path - # pre-built dist + node_modules (nix / full HERMES_TUI_DIR) skips npm. + # Footgun: --dev against a prebuilt bundle that has no source/node_modules. + ext_dir = os.environ.get("HERMES_TUI_DIR") + if tui_dev and ext_dir: + print( + f"Error: --dev is incompatible with HERMES_TUI_DIR={ext_dir}\n" + f"The prebuilt TUI has no source code to hot-reload.\n" + f"Unset HERMES_TUI_DIR (e.g. `unset HERMES_TUI_DIR`) to use --dev from a checkout.", + file=sys.stderr, + ) + sys.exit(1) + + # 1. Prebuilt bundle (nix / packaged release): just run it. if not tui_dev: - ext_dir = os.environ.get("HERMES_TUI_DIR") if ext_dir: p = Path(ext_dir) - if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p): + if (p / "dist" / "entry.js").is_file(): node = _node_bin("node") return [node, str(p / "dist" / "entry.js")], p - npm = _node_bin("npm") + # 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js. + # --dev flow: npm install if needed, then tsx src/entry.tsx (no build). if _tui_need_npm_install(tui_dir): + npm = _node_bin("npm") if not os.environ.get("HERMES_QUIET"): print("Installing TUI dependencies…") - # Capture stdout as well as stderr — some npm errors (notably EACCES on a - # root-owned node_modules in containers) are emitted on stdout, and a - # bare "npm install failed." with no preview defeats debugging. We keep - # the failure-only print path so a successful install stays silent. result = subprocess.run( [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], cwd=str(tui_dir), @@ -1133,47 +1081,30 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: sys.exit(1) if tui_dev: - if _hermes_ink_bundle_stale(tui_dir): - result = subprocess.run( - [npm, "run", "build", "--prefix", "packages/hermes-ink"], - cwd=str(tui_dir), - capture_output=True, - text=True, - ) - if result.returncode != 0: - combined = f"{result.stdout or ''}{result.stderr or ''}".strip() - preview = "\n".join(combined.splitlines()[-30:]) - print("@hermes/ink build failed.") - if preview: - print(preview) - sys.exit(1) tsx = tui_dir / "node_modules" / ".bin" / "tsx" if tsx.exists(): return [str(tsx), "src/entry.tsx"], tui_dir + npm = _node_bin("npm") return [npm, "start"], tui_dir - if _tui_build_needed(tui_dir): - result = subprocess.run( - [npm, "run", "build"], - cwd=str(tui_dir), - capture_output=True, - text=True, - ) - if result.returncode != 0: - combined = f"{result.stdout or ''}{result.stderr or ''}".strip() - preview = "\n".join(combined.splitlines()[-30:]) - print("TUI build failed.") - if preview: - print(preview) - sys.exit(1) - - root = _find_bundled_tui(tui_dir) - if not root: - print("TUI build did not produce dist/entry.js") + # Always rebuild — esbuild is fast and this avoids staleness-edge-case bugs. + npm = _node_bin("npm") + result = subprocess.run( + [npm, "run", "build"], + cwd=str(tui_dir), + capture_output=True, + text=True, + ) + if result.returncode != 0: + combined = f"{result.stdout or ''}{result.stderr or ''}".strip() + preview = "\n".join(combined.splitlines()[-30:]) + print("TUI build failed.") + if preview: + print(preview) sys.exit(1) node = _node_bin("node") - return [node, str(root / "dist" / "entry.js")], root + return [node, str(tui_dir / "dist" / "entry.js")], tui_dir def _normalize_tui_toolsets(toolsets: object) -> list[str]: @@ -5503,7 +5434,6 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) def _web_ui_build_needed(web_dir: Path) -> bool: """Return True if the web UI dist is missing or stale. - Mirrors the staleness logic used by ``_tui_build_needed()`` for the TUI. The Vite build outputs to ``hermes_cli/web_dist/`` (per vite.config.ts outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``. Uses the Vite manifest as the sentinel because it is written last and therefore has the diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index f17ed5a0744..efad281565b 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -25,12 +25,6 @@ def _touch_tui_entry(root: Path) -> None: entry.write_text("console.log('tui')") -def _touch_ink_bundle(root: Path) -> None: - bundle = root / "packages" / "hermes-ink" / "dist" / "ink-bundle.js" - bundle.parent.mkdir(parents=True, exist_ok=True) - bundle.write_text("export {}") - - def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None: (tmp_path / "package-lock.json").write_text("{}") assert main_mod._tui_need_npm_install(tmp_path) is True @@ -126,32 +120,3 @@ 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_source_newer_than_entry(tmp_path: Path, main_mod) -> None: - _touch_tui_entry(tmp_path) - _touch_ink(tmp_path) - src = tmp_path / "src" / "entry.tsx" - src.parent.mkdir(parents=True, exist_ok=True) - src.write_text("console.log('newer')") - os.utime(src, (200, 200)) - os.utime(tmp_path / "dist" / "entry.js", (100, 100)) - - assert main_mod._tui_need_npm_install(tmp_path) is False - assert main_mod._tui_build_needed(tmp_path) is True - - -def test_build_not_needed_when_entry_exists_and_sources_unchanged(tmp_path: Path, main_mod) -> None: - _touch_tui_entry(tmp_path) - _touch_ink(tmp_path) - - assert main_mod._tui_need_npm_install(tmp_path) is False - assert main_mod._tui_build_needed(tmp_path) is False - - -def test_build_not_needed_when_entry_and_ink_bundle_present(tmp_path: Path, main_mod) -> None: - _touch_tui_entry(tmp_path) - _touch_ink(tmp_path) - _touch_ink_bundle(tmp_path) - - assert main_mod._tui_build_needed(tmp_path) is False