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:
+88
-42
@@ -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]}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user