fix(node/nix): consolidate workspace lockfile + update all consumers

Consolidate per-package package-lock.json files into a single root-level
workspace lockfile.  Update all consumers:

- Nix: shared src/npmDeps/npmDepsHash in lib.nix; devshell hook stamps
  package.json paths then runs npm ci from root; individual .nix files
  use mkNpmPassthru attrs instead of per-package fetchNpmDeps.
- Python CLI: new _workspace_root() helper so _tui_need_npm_install,
  _make_tui_argv, _build_web_ui resolve lockfile/node_modules from the
  workspace root.
- Desktop: replace --force-build/mtime heuristic with content-hash build
  stamp (_compute_desktop_content_hash via pathspec).  Remove --force-build
  flag.
- Dockerfile: single root npm install; no per-directory lockfile copies.
- CI: nix-lockfile-fix and osv-scanner reference root package-lock.json;
  apps/dashboard → apps/desktop.
- Tests: new test_tui_npm_install.py; desktop stamp tests in
  test_gui_command.py; updated assertions in test_cmd_update.py,
  test_web_ui_build.py, test_dockerfile_pid1_reaping.py.
- Docs: remove --force-build from desktop flag table.

Deleted: apps/desktop/package-lock.json, ui-tui/package-lock.json,
ui-tui/packages/hermes-ink/package-lock.json, web/package-lock.json.
This commit is contained in:
ethernet
2026-05-31 19:48:07 -04:00
parent 01eaba7061
commit a51a7b9b92
21 changed files with 2765 additions and 37028 deletions

2
.envrc
View File

@ -1,5 +1,5 @@
watch_file pyproject.toml uv.lock
watch_file ui-tui/package-lock.json ui-tui/package.json
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
use flake

View File

@ -4,10 +4,10 @@ on:
push:
branches: [main]
paths:
- 'ui-tui/package-lock.json'
- 'package-lock.json'
- 'package.json'
- 'ui-tui/package.json'
- 'apps/dashboard/package-lock.json'
- 'apps/dashboard/package.json'
- 'apps/desktop/package.json'
workflow_dispatch:
inputs:
pr_number:
@ -27,9 +27,9 @@ concurrency:
jobs:
# ── Auto-fix on main ───────────────────────────────────────────────
# Fires when a push to main touches package.json or package-lock.json
# in ui-tui/ or apps/dashboard/. Runs fix-lockfiles and pushes the hash
# update commit directly to main so Nix builds never stay broken.
# Fires when a push to main touches package.json or package-lock.json.
# Runs fix-lockfiles and pushes the hash update commit directly to main
# so Nix builds never stay broken.
#
# Safety invariants:
# 1. The fix commit only touches nix/*.nix files, which are NOT in
@ -109,8 +109,8 @@ jobs:
# our computed hashes are stale. Abort and let the next triggered
# run recompute from the correct package-lock state.
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
'ui-tui/package-lock.json' 'ui-tui/package.json' \
'apps/dashboard/package-lock.json' 'apps/dashboard/package.json' || true)"
'package-lock.json' 'package.json' \
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
if [ -n "$pkg_changed" ]; then
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
exit 0

View File

@ -28,7 +28,6 @@ on:
- 'package.json'
- 'package-lock.json'
- 'ui-tui/package.json'
- 'ui-tui/package-lock.json'
- 'website/package.json'
- 'website/package-lock.json'
- '.github/workflows/osv-scanner.yml'
@ -39,7 +38,6 @@ on:
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'ui-tui/package-lock.json'
- 'website/package-lock.json'
schedule:
# Weekly scan against main — catches CVEs published after merge for
@ -62,6 +60,6 @@ jobs:
# the three sources of truth and skip vendored / test / worktree dirs.
scan-args: |-
--lockfile=uv.lock
--lockfile=ui-tui/package-lock.json
--lockfile=package-lock.json
--lockfile=website/package-lock.json
fail-on-vuln: false

View File

@ -113,8 +113,8 @@ WORKDIR /opt/hermes
# ui-tui/package.json. Copying the tree up front lets npm resolve the
# workspace to real content instead of stopping at a bare package.json.
COPY package.json package-lock.json ./
COPY web/package.json web/package-lock.json web/
COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/
COPY web/package.json web/
COPY ui-tui/package.json ui-tui/
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
# `npm_config_install_links=false` forces npm to install `file:` deps as
@ -131,8 +131,6 @@ ENV npm_config_install_links=false
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
(cd web && npm install --prefer-offline --no-audit) && \
(cd ui-tui && npm install --prefer-offline --no-audit) && \
npm cache clean --force
# ---------- Layer-cached Python dependency install ----------

File diff suppressed because it is too large Load Diff

View File

@ -1184,6 +1184,33 @@ to avoid false-positive reinstalls on every launch.
"""
def _workspace_root(dir: Path) -> Path:
"""Return the npm workspace root for *dir*.
In a workspace checkout the single ``package-lock.json`` and hoisted
``node_modules/`` live at the workspace root (the parent of the
sub-package directory). Heuristic: if *dir* has a ``package.json``
but **no** ``package-lock.json``, and its **parent** has a
``package-lock.json``, the parent is the workspace root.
Otherwise *dir* itself is the root (standalone project or
prebuilt-bundle layout).
Used by ``_tui_need_npm_install``, ``_make_tui_argv``, and
``_build_web_ui`` so that lockfile/node_modules resolution and
``npm install`` cwd stay consistent — a single helper prevents
the checks from diverging if someone accidentally creates a
sub-package lockfile (e.g. running ``npm install`` in the wrong
directory).
"""
if (
(dir / "package.json").is_file()
and not (dir / "package-lock.json").is_file()
and (dir.parent / "package-lock.json").is_file()
):
return dir.parent
return dir
def _tui_need_npm_install(root: Path) -> bool:
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
@ -1192,6 +1219,12 @@ def _tui_need_npm_install(root: Path) -> bool:
``package.json``), skip reinstall entirely — the bundle is self-contained
and there is nothing to install.
With npm workspaces the single ``package-lock.json`` and the hoisted
``node_modules/`` live at the workspace root (the parent of the
``ui-tui/`` directory). The lockfile / ink / marker checks use that
workspace root; only the prebuilt-bundle sentinel stays relative to
*root* (``ui-tui/dist/entry.js``).
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
@ -1209,19 +1242,21 @@ 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.
entry = root / "dist" / "entry.js"
# With npm workspaces the lockfile lives at the workspace root.
ws_root = _workspace_root(root)
lock = ws_root / "package-lock.json"
if entry.is_file() and not lock.is_file():
return False
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
ink = ws_root / "node_modules" / "@hermes" / "ink" / "package.json"
if not ink.is_file():
return True
if not lock.is_file():
return False
marker = root / "node_modules" / ".package-lock.json"
marker = ws_root / "node_modules" / ".package-lock.json"
if not marker.is_file():
return True
@ -1270,7 +1305,6 @@ _TUI_BUILD_INPUT_FILES = (
"babel.compiler.config.cjs",
"scripts/build.mjs",
"packages/hermes-ink/package.json",
"packages/hermes-ink/package-lock.json",
"packages/hermes-ink/index.js",
"packages/hermes-ink/text-input.js",
)
@ -1437,6 +1471,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
# 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.
# npm install runs from the workspace root (where package-lock.json lives);
# npm workspaces resolves ui-tui deps automatically.
did_install = False
if _tui_need_npm_install(tui_dir):
npm = _node_bin("npm")
@ -1444,7 +1480,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
print("Installing TUI dependencies…")
result = subprocess.run(
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
cwd=str(tui_dir),
cwd=str(_workspace_root(tui_dir)),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
@ -6604,7 +6640,6 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
return True
for meta in (
"package.json",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"vite.config.ts",
@ -6613,6 +6648,10 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
mp = web_dir / meta
if mp.exists() and mp.stat().st_mtime > dist_mtime:
return True
# Workspace root lockfile (single package-lock.json covers all workspaces).
root_lock = project_root / "package-lock.json"
if root_lock.exists() and root_lock.stat().st_mtime > dist_mtime:
return True
return False
@ -6809,7 +6848,11 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
if text:
_say(text)
r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",))
r1 = _run_npm_install_deterministic(
npm,
_workspace_root(web_dir),
extra_args=("--silent",),
)
if r1.returncode != 0:
_say(
f" {'' if fatal else ''} Web UI npm install failed"
@ -8754,45 +8797,48 @@ def _update_node_dependencies() -> None:
if not npm:
return
paths = (
("repo root", PROJECT_ROOT),
("ui-tui", PROJECT_ROOT / "ui-tui"),
)
if not any((path / "package.json").exists() for _, path in paths):
if not (PROJECT_ROOT / "package.json").exists():
return
# With a single workspace lockfile the root install would cover ALL
# workspaces — but apps/desktop pulls in Electron as a devDependency,
# and its postinstall downloads a ~200MB binary. Most users don't
# need desktop during `hermes update`, so we install root-only first
# then add just the workspaces the CLI/TUI/web build actually requires.
# Desktop deps are installed on demand by the desktop launcher
# (see _desktop_build_needed).
print("→ Updating Node.js dependencies...")
for label, path in paths:
if not (path / "package.json").exists():
continue
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
# Stream npm output (no `--silent`, no `capture_output`) so any
# optional dependency postinstall scripts (e.g. `agent-browser`'s
# Chromium fetch on first install) print progress instead of
# appearing to hang silently for minutes (#18840). The
# `_UpdateOutputStream` wrapper installed by the updater mirrors
# streamed output to ``~/.hermes/logs/update.log`` so nothing is lost.
#
# The repo root install also passes `--workspaces=false` so npm
# does not recursively install every `apps/*` workspace (dashboard,
# desktop, shared) — those are installed/built on demand via
# `_build_web_ui()` and the desktop launchers.
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
if path == PROJECT_ROOT:
extra_args.append("--workspaces=false")
# Step 1: root install (no workspace recursion).
root_args = [*extra_args, "--workspaces=false"]
root_result = _run_npm_install_deterministic(
npm,
PROJECT_ROOT,
extra_args=tuple(root_args),
capture_output=False,
)
if root_result.returncode != 0:
print(" ⚠ npm install failed in repo root")
stderr = (root_result.stderr or "").strip() if root_result.stderr else ""
if stderr:
print(f" {stderr.splitlines()[-1]}")
return
result = _run_npm_install_deterministic(
npm,
path,
extra_args=tuple(extra_args),
capture_output=False,
)
if result.returncode == 0:
print(f"{label}")
continue
print(f" ⚠ npm install failed in {label}")
stderr = (result.stderr or "").strip() if result.stderr else ""
# Step 2: install only the workspaces update needs (ui-tui, web).
# --workspace selects specific workspaces; the rest (desktop) are skipped.
ws_args = [*extra_args, "--workspace", "ui-tui", "--workspace", "web"]
ws_result = _run_npm_install_deterministic(
npm,
PROJECT_ROOT,
extra_args=tuple(ws_args),
capture_output=False,
)
if ws_result.returncode == 0:
print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)")
else:
print(" ⚠ npm workspace install failed")
stderr = (ws_result.stderr or "").strip() if ws_result.stderr else ""
if stderr:
print(f" {stderr.splitlines()[-1]}")

View File

@ -8,37 +8,20 @@
# No reimplementation of the agent resolution in this wrapper.
{ pkgs, lib, stdenv, makeWrapper, hermesNpmLib, electron, hermesAgent, ... }:
let
src = ../apps;
npmDeps = pkgs.fetchNpmDeps {
src = ../apps/desktop;
# buildNpmPackage uses `npm ci` which is strict — peer deps not in the
# lockfile cause network fetch attempts. Fetcher v2 stages the full
# cache (including peer-only deps) so `npm ci` can resolve them offline.
fetcherVersion = 2;
hash = "sha256-7W9ObYz08yDMtybY8+RkUXkKVsJXINLl0qBUB91hpao=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "apps/desktop"; attr = "desktop"; pname = "hermes-desktop"; };
packageJson = builtins.fromJSON (builtins.readFile (src + "/desktop/package.json"));
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
version = packageJson.version;
# Build the renderer (dist/ + electron/ + package.json).
renderer = pkgs.buildNpmPackage (npm // {
pname = "hermes-desktop-renderer";
inherit src npmDeps version;
sourceRoot = "apps/desktop";
inherit version;
doCheck = false;
# buildNpmPackage uses `npm ci` which fails on peer deps not in the
# lockfile. npmDepsFetcherVersion=2 stages the full cache (peer deps
# included) so the offline `npm ci` resolves them.
npmDepsFetcherVersion = 2;
# `--ignore-scripts` skips the electron prebuild download (we use nixpkgs
# electron instead). `--legacy-peer-deps` matches the dev workflow —
# apps/desktop has conflicting peer deps (zod, @testing-library) that
# the package.json relies on npm 7+ to relax.
npmFlags = [ "--ignore-scripts" "--legacy-peer-deps" ];
# The workspace lockfile resolves all peer deps
# correctly so --legacy-peer-deps is not needed.
# --ignore-scripts comes from mkNpmPassthru (shared).
makeCacheWritable = true;
buildPhase = ''
@ -47,21 +30,23 @@ let
# write-build-stamp.cjs replacement. Packaged Electron reads this
# at first-launch to pin the install.ps1 git ref; informational in
# nix builds (the backend comes from the derivation directly).
mkdir -p build
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > build/install-stamp.json
mkdir -p apps/desktop/build
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
# The vite config aliases react/react-dom to ../../node_modules/react
# (workspace root, where npm dedups them in dev). In the standalone
# nix build there is no workspace root, so the deps are installed
# locally rewrite the aliases to point at the local copy.
substituteInPlace vite.config.ts \
--replace-quiet '../../node_modules/' './node_modules/'
# Build from apps/desktop/ so vite.config.ts resolves correctly.
# The workspace root's node_modules/ is accessible as ../../node_modules/.
cd apps/desktop
# vite handles TS transpilation via esbuild no type-checking.
# We skip `tsc -b` to avoid type errors in test files that don't
# ship in the bundle (real upstream peer-dep version mismatches
# in @testing-library/react v16 not blocking the build).
npx vite build --outDir dist
# Call vite directly from root node_modules to avoid npx resolving
# through unpatched workspace symlinks.
node ../../node_modules/vite/bin/vite.js build --outDir dist
# Return to source root so installPhase paths are correct.
cd ../..
runHook postBuild
'';
@ -69,8 +54,12 @@ let
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r dist electron build $out/
cp package.json $out/
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
# apps/desktop/build was created before the cd. electron/ is source.
cp -r apps/desktop/dist $out/
cp -r apps/desktop/electron $out/
cp -r apps/desktop/build $out/
cp apps/desktop/package.json $out/
runHook postInstall
'';
});
@ -106,6 +95,10 @@ stdenv.mkDerivation {
runHook postInstall
'';
passthru = {
inherit (renderer.passthru) packageJsonPath;
};
meta = with lib; {
description = "Native Electron desktop shell for Hermes Agent";
homepage = "https://github.com/NousResearch/hermes-agent";

View File

@ -1,13 +1,28 @@
# nix/devShell.nix — Dev shell that delegates setup to each package
#
# Each package in inputsFrom might expose passthru.devShellHook — a bash snippet
# with stamp-checked setup logic. This file collects and runs them all.
# Each npm workspace package exposes passthru.packageJsonPath (e.g.
# "ui-tui/package.json"). This file collects them all and passes the
# list to mkNpmDevShellHook, which stamps all package.jsons at once,
# then runs a single `npm i --package-lock-only` if any changed and
# `npm ci` if the lockfile changed.
{ ... }:
{
perSystem =
{ pkgs, self', ... }:
let
packages = builtins.attrValues self'.packages;
hermesNpmLib = self'.packages.default.passthru.hermesNpmLib;
fixLockfilesExe = pkgs.lib.getExe self'.packages.fix-lockfiles;
# Collect all packageJsonPath values from npm workspace packages.
npmPackageJsonPaths = builtins.filter (p: p != null) (
map (p: p.passthru.packageJsonPath or null) packages
);
# Non-npm packages may have their own devShellHook (e.g. hermes-agent
# stamps pyproject.toml + uv.lock for Python venv setup).
nonNpmHooks = map (p: p.passthru.devShellHook or "") packages;
combinedNonNpm = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") nonNpmHooks);
in
{
devShells.default = pkgs.mkShell {
@ -15,16 +30,12 @@
packages = with pkgs; [
uv
];
shellHook =
let
hooks = map (p: p.passthru.devShellHook or "") packages;
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
in
''
echo "Hermes Agent dev shell"
${combined}
echo "Ready. Run 'hermes' to start."
'';
shellHook = ''
echo "Hermes Agent dev shell"
${combinedNonNpm}
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths fixLockfilesExe}
echo "Ready. Run 'hermes' to start."
'';
};
};
}

View File

@ -1,15 +1,40 @@
# nix/lib.nix — Shared helpers for nix stuff
#
# All npm packages in this repo are workspace members sharing a single
# root package-lock.json. mkNpmPassthru provides the shared src, npmDeps,
# npmRoot, and npmDepsFetcherVersion so individual .nix files don't
# duplicate them. One hash to rule them all.
#
# mkNpmPassthru returns packageJsonPath (e.g. "ui-tui/package.json")
# instead of a per-package devShellHook. The root devshell hook
# (mkNpmDevShellHook) collects all package.json paths, stamps them,
# and if any changed, runs a single `npm i --package-lock-only` from
# root to update the lockfile, then `npm ci` if the lockfile changed.
{
pkgs,
npm-lockfile-fix,
nodejs,
}:
let
# The workspace root — where the single package-lock.json lives.
src = ../.;
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-UaHsgwUag/WFQAvkjy4p6tXS55MVBSX6DnISJeLqoH8=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;
fetcherVersion = 2;
hash = npmDepsHash;
};
in
{
# Returns a buildNpmPackage-compatible attrs set that provides:
# patchPhase — ensures lockfile has exactly one trailing newline
# src, npmDeps, npmRoot, npmDepsFetcherVersion
# patchPhase — ensures root lockfile has exactly one trailing newline
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
# passthru.devShellHook — stamp-checked npm install + hash auto-update
# passthru.npmLockfile — metadata for mkFixLockfiles
# passthru.packageJsonPath — relative path to this workspace's package.json
# nodejs — fixed nodejs version for all packages we use in the repo
#
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
@ -19,22 +44,38 @@
#
# Usage:
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
# pkgs.buildNpmPackage (npm // { ... } # or:
# pkgs.buildNpmPackage ({ ... } // npm)
# pkgs.buildNpmPackage (npm // {
# sourceRoot = "ui-tui";
# buildPhase = '' ... '';
# installPhase = '' ... '';
# })
mkNpmPassthru =
{
folder, # repo-relative folder with package.json, e.g. "ui-tui"
attr, # flake package attr, e.g. "tui"
pname, # e.g. "hermes-tui"
nixFile ? "nix/${attr}.nix", # defaults to nix/<attr>.nix
}:
let
# No sourceRoot — the workspace root (with the single package-lock.json)
# is auto-detected as sourceRoot by nix. npmRoot stays at "."
# so npmConfigHook finds the lockfile there.
in
{
inherit nodejs;
inherit src npmDeps nodejs;
npmRoot = ".";
npmDepsFetcherVersion = 2;
# --ignore-scripts: the workspace includes electron (apps/desktop)
# which has a postinstall that tries to download from github.com.
# nix builds are offline, so all scripts must be skipped. Each
# package sets up its own build commands in buildPhase instead.
npmFlags = [ "--ignore-scripts" ];
patchPhase = ''
runHook prePatch
# Normalize trailing newlines so source and npm-deps always match,
# regardless of what fetchNpmDeps preserves.
sed -i -z 's/\n*$/\n/' package-lock.json
# Normalize trailing newlines on the root lockfile so source and
# npm-deps always match, regardless of what fetchNpmDeps preserves.
sed -i -z 's/\\n*$/\\n/' package-lock.json
# Make npmConfigHook's byte-for-byte diff newline-agnostic by
# replacing its hardcoded /nix/store/.../diff with a wrapper that
@ -42,11 +83,11 @@
mkdir -p "$TMPDIR/bin"
cat > "$TMPDIR/bin/diff" << DIFFWRAP
#!/bin/sh
f1=\$(mktemp) && sed -z 's/\n*$/\n/' "\$1" > "\$f1"
f2=\$(mktemp) && sed -z 's/\n*$/\n/' "\$2" > "\$f2"
${pkgs.diffutils}/bin/diff "\$f1" "\$f2" && rc=0 || rc=\$?
rm -f "\$f1" "\$f2"
exit \$rc
f1=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$1" > "\\$f1"
f2=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$2" > "\\$f2"
${pkgs.diffutils}/bin/diff "\\$f1" "\\$f2" && rc=0 || rc=\\$?
rm -f "\\$f1" "\\$f2"
exit \\$rc
DIFFWRAP
chmod +x "$TMPDIR/bin/diff"
export PATH="$TMPDIR/bin:$PATH"
@ -60,62 +101,71 @@
REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT/${folder}"
# All workspace packages share the root lockfile.
cd "$REPO_ROOT"
rm -rf node_modules/
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
CI=true ${pkgs.lib.getExe' nodejs "npm"} install
CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
NIX_FILE="$REPO_ROOT/${nixFile}"
sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE
NIX_OUTPUT=$(nix build .#${attr} 2>&1 || true)
NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}')
echo got new hash $NEW_HASH
sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE
# Hash lives in lib.nix just rebuild to verify.
nix build .#${attr}
echo "Updated npm hash in $NIX_FILE to $NEW_HASH"
echo "Lockfile updated and build verified for .#${attr}"
'')
];
passthru = {
devShellHook = pkgs.writeShellScript "npm-dev-hook-${pname}" ''
REPO_ROOT=$(git rev-parse --show-toplevel)
_hermes_npm_stamp() {
sha256sum "${folder}/package.json" "${folder}/package-lock.json" \
2>/dev/null | sha256sum | awk '{print $1}'
}
STAMP=".nix-stamps/${pname}"
STAMP_VALUE="$(_hermes_npm_stamp)"
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
echo "${pname}: installing npm dependencies..."
( cd ${folder} && CI=true ${pkgs.lib.getExe' nodejs "npm"} install --silent --no-fund --no-audit 2>/dev/null )
# Auto-update the nix hash so it stays in sync with the lockfile
echo "${pname}: prefetching npm deps..."
NIX_FILE="$REPO_ROOT/${nixFile}"
if NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "${folder}/package-lock.json" 2>/dev/null); then
sed -i "s|hash = \"sha256-[A-Za-z0-9+/=]+\"|hash = \"$NEW_HASH\";|" "$NIX_FILE"
echo "${pname}: updated hash to $NEW_HASH"
else
echo "${pname}: warning: prefetch failed, run 'nix run .#fix-lockfiles' manually" >&2
fi
mkdir -p .nix-stamps
_hermes_npm_stamp > "$STAMP"
fi
unset -f _hermes_npm_stamp
'';
npmLockfile = {
inherit attr folder nixFile;
};
packageJsonPath = "${folder}/package.json";
};
};
# Aggregate `fix-lockfiles` bin from a list of packages carrying
# passthru.npmLockfile = { attr; folder; nixFile; };
# Invocations:
# Single devshell hook for all npm workspace packages.
#
# Takes a list of package.json relative paths (from mkNpmPassthru .passthru.packageJsonPath),
# stamps all of them, and if any changed:
# 1. Runs `npm i --package-lock-only` from root to update the lockfile
# 2. If the lockfile changed, runs `npm ci` + fix-lockfiles
#
# fixLockfilesExe: absolute path to the fix-lockfiles binary
# (from pkgs.lib.getExe self'.packages.fix-lockfiles in devShell.nix).
mkNpmDevShellHook =
packageJsonPaths: fixLockfilesExe:
pkgs.writeShellScript "npm-dev-hook" ''
REPO_ROOT=$(git rev-parse --show-toplevel)
# Stamp all workspace package.jsons into one file.
STAMP_DIR=".nix-stamps"
STAMP="$STAMP_DIR/npm-package-jsons"
STAMP_VALUE=$(
${pkgs.coreutils}/bin/sha256sum ${
pkgs.lib.concatMapStringsSep " " (p: "\"$REPO_ROOT/${p}\"") packageJsonPaths
} 2>/dev/null | ${pkgs.coreutils}/bin/sort | ${pkgs.coreutils}/bin/sha256sum | awk '{print $1}'
)
PKG_CHANGED=false
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
PKG_CHANGED=true
echo "npm: package.json changed, updating lockfile..."
( cd "$REPO_ROOT" && ${pkgs.lib.getExe' nodejs "npm"} i --package-lock-only --silent --no-fund --no-audit 2>/dev/null )
mkdir -p "$STAMP_DIR"
echo "$STAMP_VALUE" > "$STAMP"
fi
# Check if lockfile changed (either from the npm i above or from an
# external edit). Runs npm ci + fix-lockfiles if so.
LOCK_STAMP="$STAMP_DIR/root-lockfile"
LOCK_STAMP_VALUE=$(sha256sum "$REPO_ROOT/package-lock.json" 2>/dev/null | awk '{print $1}')
if [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$LOCK_STAMP_VALUE" ]; then
echo "npm: package-lock.json changed, running npm ci..."
( cd "$REPO_ROOT" && CI=true ${pkgs.lib.getExe' nodejs "npm"} ci --silent --no-fund --no-audit 2>/dev/null )
echo "npm: updating nix hash..."
${fixLockfilesExe} || echo "npm: warning: fix-lockfiles failed, run it manually" >&2
mkdir -p "$STAMP_DIR"
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
fi
'';
# Build `fix-lockfiles` bin that checks/updates the single npmDepsHash
# fix-lockfiles --check # exit 1 if any hash is stale
# fix-lockfiles --apply # rewrite stale hashes in place
# fix-lockfiles # alias of --apply
@ -123,12 +173,8 @@
# when set, so CI workflows can post a sticky PR comment directly.
mkFixLockfiles =
{
packages, # list of packages with passthru.npmLockfile
attr, # flake package attr for fallback verification build, e.g. "tui"
}:
let
entries = map (p: p.passthru.npmLockfile) packages;
entryArgs = pkgs.lib.concatMapStringsSep " " (e: "\"${e.attr}:${e.folder}:${e.nixFile}\"") entries;
in
pkgs.writeShellScriptBin "fix-lockfiles" ''
set -uox pipefail
MODE="''${1:---apply}"
@ -142,8 +188,6 @@
exit 2 ;;
esac
ENTRIES=(${entryArgs})
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
@ -160,66 +204,76 @@
FIXED=0
REPORT=""
for entry in "''${ENTRIES[@]}"; do
IFS=":" read -r ATTR FOLDER NIX_FILE <<< "$entry"
echo "==> .#$ATTR ($FOLDER -> $NIX_FILE)"
# Compute the actual hash from the lockfile directly using
# prefetch-npm-deps. This avoids false "ok" from nix build when
# an old derivation is cached in a substituter (cachix/cache.nixos.org).
LOCK_FILE="$FOLDER/package-lock.json"
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
# All workspace packages share the root package-lock.json, so
# we only need to check the hash once.
LOCK_FILE="package-lock.json"
LIB_FILE="nix/lib.nix"
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
if [ -z "$NEW_HASH" ]; then
echo "prefetch-npm-deps failed, falling back to nix build" >&2
OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "ok (via nix build)"
exit 0
fi
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -z "$NEW_HASH" ]; then
echo " prefetch-npm-deps failed, falling back to nix build" >&2
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo " ok (via nix build)"
continue
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo "skipped (transient cache failure see primary nix build for real status)" >&2
echo "$OUTPUT" | tail -8 >&2
exit 0
fi
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -z "$NEW_HASH" ]; then
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo " skipped (transient cache failure see primary nix build for real status)" >&2
echo "$OUTPUT" | tail -8 >&2
continue
echo "build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2
exit 1
fi
fi
OLD_HASH=$(grep -oE 'npmDepsHash = "sha256-[^"]+"' "$LIB_FILE" | head -1 \
| sed -E 's/npmDepsHash = "(.*)"/\1/')
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
echo "ok"
exit 0
fi
HASH_LINE=$(grep -n 'npmDepsHash = "sha256-' "$LIB_FILE" | head -1 | cut -d: -f1)
echo "stale: $LIB_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
STALE=1
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
LIB_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LIB_FILE#L$HASH_LINE"
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
REPORT="- [\`$LIB_FILE:$HASH_LINE\`]($LIB_URL): \`$OLD_HASH\` \`$NEW_HASH\` lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\\n'
else
REPORT="- \`$LIB_FILE:$HASH_LINE\`: \`$OLD_HASH\` \`$NEW_HASH\`"$'\\n'
fi
if [ "$MODE" = "--apply" ]; then
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$NEW_HASH\";|" "$LIB_FILE"
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>/dev/null; then
# prefetch-npm-deps may disagree with fetchNpmDeps (it hashes
# the lockfile contents, not the full source tree). Extract the
# correct hash from the nix build error and retry.
RETRY_OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
CORRECT_HASH=$(echo "$RETRY_OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -n "$CORRECT_HASH" ]; then
echo "prefetch-npm-deps gave $NEW_HASH but nix wants $CORRECT_HASH retrying" >&2
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$CORRECT_HASH\";|" "$LIB_FILE"
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs; then
echo "verification build failed after hash retry" >&2
exit 1
fi
echo " build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2
NEW_HASH="$CORRECT_HASH"
else
echo "verification build failed after hash update" >&2
exit 1
fi
fi
OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \
| sed -E 's/hash = "(.*)"/\1/')
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
echo " ok"
continue
fi
HASH_LINE=$(grep -n 'hash = "sha256-' "$NIX_FILE" | head -1 | cut -d: -f1)
echo " stale: $NIX_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
STALE=1
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
NIX_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$NIX_FILE#L$HASH_LINE"
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
REPORT+="- [\`$NIX_FILE:$HASH_LINE\`]($NIX_URL) (\`.#$ATTR\`): \`$OLD_HASH\` \`$NEW_HASH\` lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\n'
else
REPORT+="- \`$NIX_FILE:$HASH_LINE\` (\`.#$ATTR\`): \`$OLD_HASH\` \`$NEW_HASH\`"$'\n'
fi
if [ "$MODE" = "--apply" ]; then
sed -i "s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_HASH\";|" "$NIX_FILE"
if ! nix build ".#$ATTR.npmDeps" --no-link --print-build-logs; then
echo " verification build failed after hash update" >&2
exit 1
fi
FIXED=1
echo " fixed"
fi
done
FIXED=1
echo "fixed"
fi
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
{
@ -235,7 +289,7 @@
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
echo
echo "Stale lockfile hashes detected. Run:"
echo "Stale lockfile hash detected. Run:"
echo " nix run .#fix-lockfiles"
exit 1
fi

View File

@ -51,9 +51,7 @@
web = hermesAgent.hermesWeb;
desktop = hermesAgent.hermesDesktop;
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles {
packages = [ hermesAgent.hermesTui hermesAgent.hermesWeb hermesAgent.hermesDesktop ];
};
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles { attr = "tui"; };
};
};
}

View File

@ -1,34 +1,32 @@
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
{ pkgs, hermesNpmLib, ... }:
let
src = ../ui-tui;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-F6/MzZOWc0zhW9mIfnaY+PrllPvJcsA/OdFdEM+NpLY=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/ui-tui/package.json"));
version = packageJson.version;
in
pkgs.buildNpmPackage (npm // {
pname = "hermes-tui";
inherit src npmDeps version;
inherit version;
doCheck = false;
npmFlags = [ "--legacy-peer-deps" ];
buildPhase = ''
# esbuild bundles everything no need for tsc or vite.
# Run from the workspace root where node_modules/ lives.
node ui-tui/scripts/build.mjs
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/hermes-tui
# Single self-contained bundle built by scripts/build.mjs (esbuild).
cp -r dist $out/lib/hermes-tui/dist
# esbuild writes to ui-tui/dist/ from the source root (no cd).
cp -r ui-tui/dist $out/lib/hermes-tui/dist
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
cp package.json $out/lib/hermes-tui/
cp ui-tui/package.json $out/lib/hermes-tui/
runHook postInstall
'';

View File

@ -1,31 +1,34 @@
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
{ pkgs, hermesNpmLib, ... }:
let
src = ../web;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-HV0aISBVjwbGqDj8qQynSxGFrrZDzuYAW3D3lB/x3zo=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/web/package.json"));
version = packageJson.version;
in
pkgs.buildNpmPackage (npm // {
pname = "hermes-web";
inherit src npmDeps version;
inherit version;
doCheck = false;
buildPhase = ''
npx tsc -b
npx vite build --outDir dist
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
# The workspace root's node_modules/ is at ../node_modules/.
cd web
node ../node_modules/typescript/bin/tsc -b
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
# monorepo layout. Override with --outDir dist for the nix build.
node ../node_modules/vite/bin/vite.js build --outDir dist
# Return to source root so installPhase paths are correct.
cd ..
'';
installPhase = ''
runHook preInstall
cp -r dist $out
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
cp -r web/dist $out
runHook postInstall
'';
})

3015
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,10 @@
"description": "An AI agent with advanced tool-calling capabilities, featuring a flexible toolsets system for organizing and managing tools.",
"private": true,
"workspaces": [
"apps/*"
"apps/*",
"ui-tui",
"ui-tui/packages/*",
"web"
],
"scripts": {
"postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'"

View File

@ -198,36 +198,50 @@ class TestCmdUpdateBranchFallback:
if call.args and call.args[0][0] == "/usr/bin/npm"
]
# cmd_update runs npm commands in four locations:
# 1. repo root — slash-command / TUI bridge deps (subprocess.run)
# 2. ui-tui/ — Ink TUI deps (subprocess.run)
# 3. web/ — npm install (subprocess.run)
# 4. web/ — npm run build (_run_with_idle_timeout)
# cmd_update runs npm commands in these locations:
# 1. repo root — root-only install (--workspaces=false)
# 2. repo root — workspace install (--workspace ui-tui --workspace web)
# 3. web/ — npm ci --silent (if lockfile not at root)
# via _build_web_ui (subprocess.run)
# 4. web/ — npm run build (_run_with_idle_timeout)
#
# Repo-root and ui-tui installs intentionally omit `--silent` and run
# without `capture_output` so optional postinstall scripts (e.g.
# With a single workspace lockfile at the repo root, the root
# install covers all workspaces. The web/ ci call runs from the
# workspace root too (parent of web_dir) when the root lockfile
# exists.
#
# The root install omits `--silent` and runs without
# `capture_output` so optional postinstall scripts (e.g.
# `@askjo/camofox-browser`'s browser-binary fetch) print progress —
# otherwise long downloads look like a hang (#18840). The web/ install
# keeps `--silent` because its build step is short and noisy.
update_flags = [
# otherwise long downloads look like a hang (#18840).
root_flags = [
"/usr/bin/npm",
"ci",
"--no-fund",
"--no-audit",
"--progress=false",
"--workspaces=false",
]
ws_flags = [
"/usr/bin/npm",
"ci",
"--no-fund",
"--no-audit",
"--progress=false",
"--workspace",
"ui-tui",
"--workspace",
"web",
]
# Repo root additionally passes --workspaces=false so npm does not
# recursively install every apps/* workspace (desktop, shared).
repo_flags = [*update_flags, "--workspaces=false"]
assert npm_calls[:2] == [
(repo_flags, PROJECT_ROOT),
(update_flags, PROJECT_ROOT / "ui-tui"),
(root_flags, PROJECT_ROOT),
(ws_flags, PROJECT_ROOT),
]
if len(npm_calls) > 2:
# Only the web/ install is left in subprocess.run; the build moved
# to _run_with_idle_timeout to make Vite progress visible (#33788).
# The web/ install runs from the workspace root when the root
# lockfile exists (npm workspaces hoist node_modules upward).
assert npm_calls[2:] == [
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT / "web"),
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT),
]
# The web UI build itself went through the streaming helper.
@ -236,21 +250,23 @@ class TestCmdUpdateBranchFallback:
assert idle_args[0] == ["/usr/bin/npm", "run", "build"]
assert idle_kwargs["cwd"] == PROJECT_ROOT / "web"
# Regression for #18840: repo root + ui-tui installs must stream
# output (capture_output=False) so postinstall progress is visible
# to the user.
repo_and_tui_calls = [
# Regression for #18840: root npm installs must stream output
# (capture_output=False) so postinstall progress is visible
# to the user. The _build_web_ui install uses --silent and
# capture_output=True, so exclude it.
root_install_calls = [
call
for call in mock_run.call_args_list
if call.args
and call.args[0][0] == "/usr/bin/npm"
and call.args[0][1] == "ci"
and call.kwargs.get("cwd") in {PROJECT_ROOT, PROJECT_ROOT / "ui-tui"}
and call.kwargs.get("cwd") == PROJECT_ROOT
and "--silent" not in call.args[0]
]
assert len(repo_and_tui_calls) == 2
for call in repo_and_tui_calls:
assert len(root_install_calls) == 2 # root-only + workspace install
for call in root_install_calls:
assert call.kwargs.get("capture_output") is False, (
"repo-root / ui-tui npm install must stream output "
"repo-root npm install must stream output "
"(no capture_output) so postinstall progress is visible"
)

View File

@ -193,3 +193,109 @@ def test_make_tui_argv_keeps_desktop_always_build_behaviour(
assert calls
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
# ── _workspace_root helper ──────────────────────────────────────────
def test_workspace_root_returns_parent_when_subpackage(tmp_path: Path, main_mod) -> None:
"""Sub-package has package.json, no lockfile; parent has lockfile → parent."""
sub = tmp_path / "ui-tui"
sub.mkdir()
(sub / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
assert main_mod._workspace_root(sub) == tmp_path
def test_workspace_root_returns_dir_when_standalone(tmp_path: Path, main_mod) -> None:
"""No package.json → not a sub-package, return dir itself."""
assert main_mod._workspace_root(tmp_path) == tmp_path
def test_workspace_root_returns_dir_when_own_lockfile(tmp_path: Path, main_mod) -> None:
"""Has package.json AND its own lockfile → standalone, return dir."""
(tmp_path / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
(tmp_path.parent / "package-lock.json").write_text("{}")
assert main_mod._workspace_root(tmp_path) == tmp_path
def test_workspace_root_returns_dir_when_no_parent_lockfile(
tmp_path: Path, main_mod
) -> None:
"""Has package.json, no own lockfile, but parent also has no lockfile → standalone."""
sub = tmp_path / "ui-tui"
sub.mkdir()
(sub / "package.json").write_text("{}")
# tmp_path has no package-lock.json either
assert main_mod._workspace_root(sub) == sub
def test_workspace_root_consistent_with_need_npm_install(
tmp_path: Path, main_mod
) -> None:
"""Divergence regression: if someone creates ui-tui/package-lock.json
by accident, _workspace_root (used by both _tui_need_npm_install AND
the npm install cwd) returns ui-tui/ for both, so they never disagree.
Before the shared helper, _tui_need_npm_install used a 3-condition
check (falling back to ui-tui/ when its own lockfile exists) while
the npm install cwd used a simpler check (still going to the parent
because the parent lockfile still exists). The shared helper
eliminates the split.
"""
sub = tmp_path / "ui-tui"
sub.mkdir()
(sub / "package.json").write_text("{}")
# Both sub and parent have lockfiles — accidental state
(sub / "package-lock.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
ws = main_mod._workspace_root(sub)
# _workspace_root sees sub has its own lockfile → treats it as standalone
assert ws == sub
# _tui_need_npm_install also uses _workspace_root, so both agree
assert main_mod._tui_need_npm_install.__code__.co_names
# (Smoke test: just confirm _tui_need_npm_install doesn't crash)
# It won't need install because the lockfile exists and there's no
# hidden lockfile to compare against, and ink is missing → True.
# But the key invariant is: ws_root for the need-check == ws_root
# for the install cwd — both use _workspace_root(sub).
def test_no_stray_lockfiles_in_workspace_subdirs(main_mod) -> None:
"""Workspace sub-directories must not contain their own package-lock.json.
With a single workspace root lockfile, per-directory lockfiles are
always accidental (typically from running ``npm install`` inside the
wrong directory). They cause ``_workspace_root`` to treat the
sub-package as standalone, which breaks hoisted ``node_modules``
resolution and can silently diverge the install cwd from the
lockfile-check root.
This is an invariant, not a change-detector: the workspace structure
is not expected to gain per-dir lockfiles.
"""
root = main_mod.PROJECT_ROOT
# Workspace members that live one level below the root and should
# NOT have their own lockfile. (ui-tui/packages/* members are
# two levels deep and even less likely to get accidental lockfiles,
# but we check them too for completeness.)
subdirs = [
root / "ui-tui",
root / "web",
root / "apps" / "desktop",
root / "apps" / "shared",
]
# Also sweep ui-tui/packages/* (hermes-ink etc.)
tui_pkgs = root / "ui-tui" / "packages"
if tui_pkgs.is_dir():
subdirs.extend(d for d in tui_pkgs.iterdir() if d.is_dir())
stray = [d for d in subdirs if (d / "package-lock.json").is_file()]
assert not stray, (
"stray package-lock.json found in workspace sub-directory(es); "
"delete them and run `npm install` from the repo root instead: "
+ ", ".join(str(d / "package-lock.json") for d in stray)
)

View File

@ -69,7 +69,9 @@ class TestWebUIBuildNeeded:
def test_returns_true_when_package_lock_newer_than_dist(self, tmp_path):
web_dir, dist_dir = _make_web_dir(tmp_path)
_touch(dist_dir / ".vite" / "manifest.json", offset=-10)
_touch(web_dir / "package-lock.json")
# With a single workspace root lockfile, the lockfile lives at the
# project root (tmp_path), not inside web_dir.
_touch(tmp_path / "package-lock.json")
assert _web_ui_build_needed(web_dir) is True
def test_returns_true_when_vite_config_newer_than_dist(self, tmp_path):

View File

@ -147,11 +147,13 @@ def test_dockerfile_installs_tui_dependencies(dockerfile_text):
# because it's referenced as a ``file:`` workspace dependency from
# ``ui-tui/package.json`` — copying the tree avoids npm stopping at a
# bare ``package.json`` shell.
# With a single workspace root lockfile, only the root package-lock.json
# is copied; per-workspace lockfiles no longer exist.
assert "ui-tui/package.json" in dockerfile_text
assert "ui-tui/package-lock.json" in dockerfile_text
assert "ui-tui/packages/hermes-ink/" in dockerfile_text
assert "package-lock.json" in dockerfile_text
assert any(
"ui-tui" in step and "npm" in step and (" install" in step or " ci" in step)
"npm" in step and (" install" in step or " ci" in step)
for step in _run_steps(dockerfile_text)
)

7449
ui-tui/package-lock.json generated

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

8887
web/package-lock.json generated

File diff suppressed because it is too large Load Diff