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:
2
.envrc
2
.envrc
@ -1,5 +1,5 @@
|
|||||||
watch_file pyproject.toml uv.lock
|
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
|
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
|
||||||
|
|
||||||
use flake
|
use flake
|
||||||
|
|||||||
16
.github/workflows/nix-lockfile-fix.yml
vendored
16
.github/workflows/nix-lockfile-fix.yml
vendored
@ -4,10 +4,10 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'ui-tui/package-lock.json'
|
- 'package-lock.json'
|
||||||
|
- 'package.json'
|
||||||
- 'ui-tui/package.json'
|
- 'ui-tui/package.json'
|
||||||
- 'apps/dashboard/package-lock.json'
|
- 'apps/desktop/package.json'
|
||||||
- 'apps/dashboard/package.json'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
pr_number:
|
pr_number:
|
||||||
@ -27,9 +27,9 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||||
# Fires when a push to main touches package.json or package-lock.json
|
# 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
|
# Runs fix-lockfiles and pushes the hash update commit directly to main
|
||||||
# update commit directly to main so Nix builds never stay broken.
|
# so Nix builds never stay broken.
|
||||||
#
|
#
|
||||||
# Safety invariants:
|
# Safety invariants:
|
||||||
# 1. The fix commit only touches nix/*.nix files, which are NOT in
|
# 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
|
# our computed hashes are stale. Abort and let the next triggered
|
||||||
# run recompute from the correct package-lock state.
|
# run recompute from the correct package-lock state.
|
||||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||||
'ui-tui/package-lock.json' 'ui-tui/package.json' \
|
'package-lock.json' 'package.json' \
|
||||||
'apps/dashboard/package-lock.json' 'apps/dashboard/package.json' || true)"
|
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
|
||||||
if [ -n "$pkg_changed" ]; then
|
if [ -n "$pkg_changed" ]; then
|
||||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
4
.github/workflows/osv-scanner.yml
vendored
4
.github/workflows/osv-scanner.yml
vendored
@ -28,7 +28,6 @@ on:
|
|||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'package-lock.json'
|
- 'package-lock.json'
|
||||||
- 'ui-tui/package.json'
|
- 'ui-tui/package.json'
|
||||||
- 'ui-tui/package-lock.json'
|
|
||||||
- 'website/package.json'
|
- 'website/package.json'
|
||||||
- 'website/package-lock.json'
|
- 'website/package-lock.json'
|
||||||
- '.github/workflows/osv-scanner.yml'
|
- '.github/workflows/osv-scanner.yml'
|
||||||
@ -39,7 +38,6 @@ on:
|
|||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'package-lock.json'
|
- 'package-lock.json'
|
||||||
- 'ui-tui/package-lock.json'
|
|
||||||
- 'website/package-lock.json'
|
- 'website/package-lock.json'
|
||||||
schedule:
|
schedule:
|
||||||
# Weekly scan against main — catches CVEs published after merge for
|
# 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.
|
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
--lockfile=uv.lock
|
--lockfile=uv.lock
|
||||||
--lockfile=ui-tui/package-lock.json
|
--lockfile=package-lock.json
|
||||||
--lockfile=website/package-lock.json
|
--lockfile=website/package-lock.json
|
||||||
fail-on-vuln: false
|
fail-on-vuln: false
|
||||||
|
|||||||
@ -113,8 +113,8 @@ WORKDIR /opt/hermes
|
|||||||
# ui-tui/package.json. Copying the tree up front lets npm resolve the
|
# 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.
|
# workspace to real content instead of stopping at a bare package.json.
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY web/package.json web/package-lock.json web/
|
COPY web/package.json web/
|
||||||
COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/
|
COPY ui-tui/package.json ui-tui/
|
||||||
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||||
|
|
||||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
# `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 && \
|
RUN npm install --prefer-offline --no-audit && \
|
||||||
npx playwright install --with-deps chromium --only-shell && \
|
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
|
npm cache clean --force
|
||||||
|
|
||||||
# ---------- Layer-cached Python dependency install ----------
|
# ---------- Layer-cached Python dependency install ----------
|
||||||
|
|||||||
18363
apps/desktop/package-lock.json
generated
18363
apps/desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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:
|
def _tui_need_npm_install(root: Path) -> bool:
|
||||||
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
"""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
|
``package.json``), skip reinstall entirely — the bundle is self-contained
|
||||||
and there is nothing to install.
|
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``
|
Compares ``package-lock.json`` against ``node_modules/.package-lock.json``
|
||||||
(npm's hidden lockfile) by **content**, not mtime: git checkouts and npm
|
(npm's hidden lockfile) by **content**, not mtime: git checkouts and npm
|
||||||
rewrites can bump the root lockfile's timestamp even when installed deps
|
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
|
we'd rather not force a reinstall for them. Falls back to mtime
|
||||||
comparison if either lockfile is unparseable.
|
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
|
# Prebuilt self-contained bundle (nix / packaged release): no lockfile
|
||||||
# shipped, dist/entry.js is the single runtime artefact.
|
# 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():
|
if entry.is_file() and not lock.is_file():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
ink = ws_root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||||
if not ink.is_file():
|
if not ink.is_file():
|
||||||
return True
|
return True
|
||||||
if not lock.is_file():
|
if not lock.is_file():
|
||||||
return False
|
return False
|
||||||
marker = root / "node_modules" / ".package-lock.json"
|
marker = ws_root / "node_modules" / ".package-lock.json"
|
||||||
if not marker.is_file():
|
if not marker.is_file():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -1270,7 +1305,6 @@ _TUI_BUILD_INPUT_FILES = (
|
|||||||
"babel.compiler.config.cjs",
|
"babel.compiler.config.cjs",
|
||||||
"scripts/build.mjs",
|
"scripts/build.mjs",
|
||||||
"packages/hermes-ink/package.json",
|
"packages/hermes-ink/package.json",
|
||||||
"packages/hermes-ink/package-lock.json",
|
|
||||||
"packages/hermes-ink/index.js",
|
"packages/hermes-ink/index.js",
|
||||||
"packages/hermes-ink/text-input.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.
|
# 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.
|
# --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
|
did_install = False
|
||||||
if _tui_need_npm_install(tui_dir):
|
if _tui_need_npm_install(tui_dir):
|
||||||
npm = _node_bin("npm")
|
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…")
|
print("Installing TUI dependencies…")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
||||||
cwd=str(tui_dir),
|
cwd=str(_workspace_root(tui_dir)),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
@ -6604,7 +6640,6 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
|
|||||||
return True
|
return True
|
||||||
for meta in (
|
for meta in (
|
||||||
"package.json",
|
"package.json",
|
||||||
"package-lock.json",
|
|
||||||
"yarn.lock",
|
"yarn.lock",
|
||||||
"pnpm-lock.yaml",
|
"pnpm-lock.yaml",
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
@ -6613,6 +6648,10 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
|
|||||||
mp = web_dir / meta
|
mp = web_dir / meta
|
||||||
if mp.exists() and mp.stat().st_mtime > dist_mtime:
|
if mp.exists() and mp.stat().st_mtime > dist_mtime:
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
@ -6809,7 +6848,11 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
|||||||
if text:
|
if text:
|
||||||
_say(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:
|
if r1.returncode != 0:
|
||||||
_say(
|
_say(
|
||||||
f" {'✗' if fatal else '⚠'} Web UI npm install failed"
|
f" {'✗' if fatal else '⚠'} Web UI npm install failed"
|
||||||
@ -8754,45 +8797,48 @@ def _update_node_dependencies() -> None:
|
|||||||
if not npm:
|
if not npm:
|
||||||
return
|
return
|
||||||
|
|
||||||
paths = (
|
if not (PROJECT_ROOT / "package.json").exists():
|
||||||
("repo root", PROJECT_ROOT),
|
|
||||||
("ui-tui", PROJECT_ROOT / "ui-tui"),
|
|
||||||
)
|
|
||||||
if not any((path / "package.json").exists() for _, path in paths):
|
|
||||||
return
|
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...")
|
print("→ Updating Node.js dependencies...")
|
||||||
for label, path in paths:
|
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
|
||||||
if not (path / "package.json").exists():
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Stream npm output (no `--silent`, no `capture_output`) so any
|
# Step 1: root install (no workspace recursion).
|
||||||
# optional dependency postinstall scripts (e.g. `agent-browser`'s
|
root_args = [*extra_args, "--workspaces=false"]
|
||||||
# Chromium fetch on first install) print progress instead of
|
root_result = _run_npm_install_deterministic(
|
||||||
# appearing to hang silently for minutes (#18840). The
|
npm,
|
||||||
# `_UpdateOutputStream` wrapper installed by the updater mirrors
|
PROJECT_ROOT,
|
||||||
# streamed output to ``~/.hermes/logs/update.log`` so nothing is lost.
|
extra_args=tuple(root_args),
|
||||||
#
|
capture_output=False,
|
||||||
# The repo root install also passes `--workspaces=false` so npm
|
)
|
||||||
# does not recursively install every `apps/*` workspace (dashboard,
|
if root_result.returncode != 0:
|
||||||
# desktop, shared) — those are installed/built on demand via
|
print(" ⚠ npm install failed in repo root")
|
||||||
# `_build_web_ui()` and the desktop launchers.
|
stderr = (root_result.stderr or "").strip() if root_result.stderr else ""
|
||||||
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
|
if stderr:
|
||||||
if path == PROJECT_ROOT:
|
print(f" {stderr.splitlines()[-1]}")
|
||||||
extra_args.append("--workspaces=false")
|
return
|
||||||
|
|
||||||
result = _run_npm_install_deterministic(
|
# Step 2: install only the workspaces update needs (ui-tui, web).
|
||||||
npm,
|
# --workspace selects specific workspaces; the rest (desktop) are skipped.
|
||||||
path,
|
ws_args = [*extra_args, "--workspace", "ui-tui", "--workspace", "web"]
|
||||||
extra_args=tuple(extra_args),
|
ws_result = _run_npm_install_deterministic(
|
||||||
capture_output=False,
|
npm,
|
||||||
)
|
PROJECT_ROOT,
|
||||||
if result.returncode == 0:
|
extra_args=tuple(ws_args),
|
||||||
print(f" ✓ {label}")
|
capture_output=False,
|
||||||
continue
|
)
|
||||||
|
if ws_result.returncode == 0:
|
||||||
print(f" ⚠ npm install failed in {label}")
|
print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)")
|
||||||
stderr = (result.stderr or "").strip() if result.stderr else ""
|
else:
|
||||||
|
print(" ⚠ npm workspace install failed")
|
||||||
|
stderr = (ws_result.stderr or "").strip() if ws_result.stderr else ""
|
||||||
if stderr:
|
if stderr:
|
||||||
print(f" {stderr.splitlines()[-1]}")
|
print(f" {stderr.splitlines()[-1]}")
|
||||||
|
|
||||||
|
|||||||
@ -8,37 +8,20 @@
|
|||||||
# No reimplementation of the agent resolution in this wrapper.
|
# No reimplementation of the agent resolution in this wrapper.
|
||||||
{ pkgs, lib, stdenv, makeWrapper, hermesNpmLib, electron, hermesAgent, ... }:
|
{ pkgs, lib, stdenv, makeWrapper, hermesNpmLib, electron, hermesAgent, ... }:
|
||||||
let
|
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"; };
|
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;
|
version = packageJson.version;
|
||||||
|
|
||||||
# Build the renderer (dist/ + electron/ + package.json).
|
# Build the renderer (dist/ + electron/ + package.json).
|
||||||
renderer = pkgs.buildNpmPackage (npm // {
|
renderer = pkgs.buildNpmPackage (npm // {
|
||||||
pname = "hermes-desktop-renderer";
|
pname = "hermes-desktop-renderer";
|
||||||
inherit src npmDeps version;
|
inherit version;
|
||||||
sourceRoot = "apps/desktop";
|
|
||||||
|
|
||||||
doCheck = false;
|
doCheck = false;
|
||||||
# buildNpmPackage uses `npm ci` which fails on peer deps not in the
|
# The workspace lockfile resolves all peer deps
|
||||||
# lockfile. npmDepsFetcherVersion=2 stages the full cache (peer deps
|
# correctly so --legacy-peer-deps is not needed.
|
||||||
# included) so the offline `npm ci` resolves them.
|
# --ignore-scripts comes from mkNpmPassthru (shared).
|
||||||
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" ];
|
|
||||||
makeCacheWritable = true;
|
makeCacheWritable = true;
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
@ -47,21 +30,23 @@ let
|
|||||||
# write-build-stamp.cjs replacement. Packaged Electron reads this
|
# write-build-stamp.cjs replacement. Packaged Electron reads this
|
||||||
# at first-launch to pin the install.ps1 git ref; informational in
|
# at first-launch to pin the install.ps1 git ref; informational in
|
||||||
# nix builds (the backend comes from the derivation directly).
|
# nix builds (the backend comes from the derivation directly).
|
||||||
mkdir -p build
|
mkdir -p apps/desktop/build
|
||||||
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > build/install-stamp.json
|
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
|
# Build from apps/desktop/ so vite.config.ts resolves correctly.
|
||||||
# (workspace root, where npm dedups them in dev). In the standalone
|
# The workspace root's node_modules/ is accessible as ../../node_modules/.
|
||||||
# nix build there is no workspace root, so the deps are installed
|
cd apps/desktop
|
||||||
# locally — rewrite the aliases to point at the local copy.
|
|
||||||
substituteInPlace vite.config.ts \
|
|
||||||
--replace-quiet '../../node_modules/' './node_modules/'
|
|
||||||
|
|
||||||
# vite handles TS transpilation via esbuild — no type-checking.
|
# vite handles TS transpilation via esbuild — no type-checking.
|
||||||
# We skip `tsc -b` to avoid type errors in test files that don't
|
# We skip `tsc -b` to avoid type errors in test files that don't
|
||||||
# ship in the bundle (real upstream peer-dep version mismatches
|
# ship in the bundle (real upstream peer-dep version mismatches
|
||||||
# in @testing-library/react v16 — not blocking the build).
|
# 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
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
@ -69,8 +54,12 @@ let
|
|||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
mkdir -p $out
|
mkdir -p $out
|
||||||
cp -r dist electron build $out/
|
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
|
||||||
cp package.json $out/
|
# 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
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
@ -106,6 +95,10 @@ stdenv.mkDerivation {
|
|||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
passthru = {
|
||||||
|
inherit (renderer.passthru) packageJsonPath;
|
||||||
|
};
|
||||||
|
|
||||||
meta = with lib; {
|
meta = with lib; {
|
||||||
description = "Native Electron desktop shell for Hermes Agent";
|
description = "Native Electron desktop shell for Hermes Agent";
|
||||||
homepage = "https://github.com/NousResearch/hermes-agent";
|
homepage = "https://github.com/NousResearch/hermes-agent";
|
||||||
|
|||||||
@ -1,13 +1,28 @@
|
|||||||
# nix/devShell.nix — Dev shell that delegates setup to each package
|
# nix/devShell.nix — Dev shell that delegates setup to each package
|
||||||
#
|
#
|
||||||
# Each package in inputsFrom might expose passthru.devShellHook — a bash snippet
|
# Each npm workspace package exposes passthru.packageJsonPath (e.g.
|
||||||
# with stamp-checked setup logic. This file collects and runs them all.
|
# "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 =
|
perSystem =
|
||||||
{ pkgs, self', ... }:
|
{ pkgs, self', ... }:
|
||||||
let
|
let
|
||||||
packages = builtins.attrValues self'.packages;
|
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
|
in
|
||||||
{
|
{
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
@ -15,16 +30,12 @@
|
|||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
uv
|
uv
|
||||||
];
|
];
|
||||||
shellHook =
|
shellHook = ''
|
||||||
let
|
echo "Hermes Agent dev shell"
|
||||||
hooks = map (p: p.passthru.devShellHook or "") packages;
|
${combinedNonNpm}
|
||||||
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
|
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths fixLockfilesExe}
|
||||||
in
|
echo "Ready. Run 'hermes' to start."
|
||||||
''
|
'';
|
||||||
echo "Hermes Agent dev shell"
|
|
||||||
${combined}
|
|
||||||
echo "Ready. Run 'hermes' to start."
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
296
nix/lib.nix
296
nix/lib.nix
@ -1,15 +1,40 @@
|
|||||||
# nix/lib.nix — Shared helpers for nix stuff
|
# 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,
|
pkgs,
|
||||||
npm-lockfile-fix,
|
npm-lockfile-fix,
|
||||||
nodejs,
|
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:
|
# 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)
|
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
|
||||||
# passthru.devShellHook — stamp-checked npm install + hash auto-update
|
# passthru.packageJsonPath — relative path to this workspace's package.json
|
||||||
# passthru.npmLockfile — metadata for mkFixLockfiles
|
|
||||||
# nodejs — fixed nodejs version for all packages we use in the repo
|
# nodejs — fixed nodejs version for all packages we use in the repo
|
||||||
#
|
#
|
||||||
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
|
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
|
||||||
@ -19,22 +44,38 @@
|
|||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
# 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 =
|
mkNpmPassthru =
|
||||||
{
|
{
|
||||||
folder, # repo-relative folder with package.json, e.g. "ui-tui"
|
folder, # repo-relative folder with package.json, e.g. "ui-tui"
|
||||||
attr, # flake package attr, e.g. "tui"
|
attr, # flake package attr, e.g. "tui"
|
||||||
pname, # e.g. "hermes-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 = ''
|
patchPhase = ''
|
||||||
runHook prePatch
|
runHook prePatch
|
||||||
# Normalize trailing newlines so source and npm-deps always match,
|
# Normalize trailing newlines on the root lockfile so source and
|
||||||
# regardless of what fetchNpmDeps preserves.
|
# npm-deps always match, regardless of what fetchNpmDeps preserves.
|
||||||
sed -i -z 's/\n*$/\n/' package-lock.json
|
sed -i -z 's/\\n*$/\\n/' package-lock.json
|
||||||
|
|
||||||
# Make npmConfigHook's byte-for-byte diff newline-agnostic by
|
# Make npmConfigHook's byte-for-byte diff newline-agnostic by
|
||||||
# replacing its hardcoded /nix/store/.../diff with a wrapper that
|
# replacing its hardcoded /nix/store/.../diff with a wrapper that
|
||||||
@ -42,11 +83,11 @@
|
|||||||
mkdir -p "$TMPDIR/bin"
|
mkdir -p "$TMPDIR/bin"
|
||||||
cat > "$TMPDIR/bin/diff" << DIFFWRAP
|
cat > "$TMPDIR/bin/diff" << DIFFWRAP
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
f1=\$(mktemp) && sed -z 's/\n*$/\n/' "\$1" > "\$f1"
|
f1=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$1" > "\\$f1"
|
||||||
f2=\$(mktemp) && sed -z 's/\n*$/\n/' "\$2" > "\$f2"
|
f2=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$2" > "\\$f2"
|
||||||
${pkgs.diffutils}/bin/diff "\$f1" "\$f2" && rc=0 || rc=\$?
|
${pkgs.diffutils}/bin/diff "\\$f1" "\\$f2" && rc=0 || rc=\\$?
|
||||||
rm -f "\$f1" "\$f2"
|
rm -f "\\$f1" "\\$f2"
|
||||||
exit \$rc
|
exit \\$rc
|
||||||
DIFFWRAP
|
DIFFWRAP
|
||||||
chmod +x "$TMPDIR/bin/diff"
|
chmod +x "$TMPDIR/bin/diff"
|
||||||
export PATH="$TMPDIR/bin:$PATH"
|
export PATH="$TMPDIR/bin:$PATH"
|
||||||
@ -60,62 +101,71 @@
|
|||||||
|
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
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/
|
rm -rf node_modules/
|
||||||
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
|
${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
|
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
|
||||||
|
|
||||||
NIX_FILE="$REPO_ROOT/${nixFile}"
|
# Hash lives in lib.nix — just rebuild to verify.
|
||||||
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
|
|
||||||
nix build .#${attr}
|
nix build .#${attr}
|
||||||
echo "Updated npm hash in $NIX_FILE to $NEW_HASH"
|
echo "Lockfile updated and build verified for .#${attr}"
|
||||||
'')
|
'')
|
||||||
];
|
];
|
||||||
|
|
||||||
passthru = {
|
passthru = {
|
||||||
devShellHook = pkgs.writeShellScript "npm-dev-hook-${pname}" ''
|
packageJsonPath = "${folder}/package.json";
|
||||||
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;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Aggregate `fix-lockfiles` bin from a list of packages carrying
|
# Single devshell hook for all npm workspace packages.
|
||||||
# passthru.npmLockfile = { attr; folder; nixFile; };
|
#
|
||||||
# Invocations:
|
# 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 --check # exit 1 if any hash is stale
|
||||||
# fix-lockfiles --apply # rewrite stale hashes in place
|
# fix-lockfiles --apply # rewrite stale hashes in place
|
||||||
# fix-lockfiles # alias of --apply
|
# fix-lockfiles # alias of --apply
|
||||||
@ -123,12 +173,8 @@
|
|||||||
# when set, so CI workflows can post a sticky PR comment directly.
|
# when set, so CI workflows can post a sticky PR comment directly.
|
||||||
mkFixLockfiles =
|
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" ''
|
pkgs.writeShellScriptBin "fix-lockfiles" ''
|
||||||
set -uox pipefail
|
set -uox pipefail
|
||||||
MODE="''${1:---apply}"
|
MODE="''${1:---apply}"
|
||||||
@ -142,8 +188,6 @@
|
|||||||
exit 2 ;;
|
exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
ENTRIES=(${entryArgs})
|
|
||||||
|
|
||||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
@ -160,66 +204,76 @@
|
|||||||
FIXED=0
|
FIXED=0
|
||||||
REPORT=""
|
REPORT=""
|
||||||
|
|
||||||
for entry in "''${ENTRIES[@]}"; do
|
# All workspace packages share the root package-lock.json, so
|
||||||
IFS=":" read -r ATTR FOLDER NIX_FILE <<< "$entry"
|
# we only need to check the hash once.
|
||||||
echo "==> .#$ATTR ($FOLDER -> $NIX_FILE)"
|
LOCK_FILE="package-lock.json"
|
||||||
|
LIB_FILE="nix/lib.nix"
|
||||||
# Compute the actual hash from the lockfile directly using
|
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
|
||||||
# prefetch-npm-deps. This avoids false "ok" from nix build when
|
if [ -z "$NEW_HASH" ]; then
|
||||||
# an old derivation is cached in a substituter (cachix/cache.nixos.org).
|
echo "prefetch-npm-deps failed, falling back to nix build" >&2
|
||||||
LOCK_FILE="$FOLDER/package-lock.json"
|
OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
|
||||||
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
|
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
|
if [ -z "$NEW_HASH" ]; then
|
||||||
echo " prefetch-npm-deps failed, falling back to nix build" >&2
|
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
|
||||||
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1)
|
echo "skipped (transient cache failure — see primary nix build for real status)" >&2
|
||||||
STATUS=$?
|
echo "$OUTPUT" | tail -8 >&2
|
||||||
if [ "$STATUS" -eq 0 ]; then
|
exit 0
|
||||||
echo " ok (via nix build)"
|
|
||||||
continue
|
|
||||||
fi
|
fi
|
||||||
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
|
echo "build failed with no hash mismatch:" >&2
|
||||||
if [ -z "$NEW_HASH" ]; then
|
echo "$OUTPUT" | tail -40 >&2
|
||||||
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
|
exit 1
|
||||||
echo " skipped (transient cache failure — see primary nix build for real status)" >&2
|
fi
|
||||||
echo "$OUTPUT" | tail -8 >&2
|
fi
|
||||||
continue
|
|
||||||
|
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
|
fi
|
||||||
echo " build failed with no hash mismatch:" >&2
|
NEW_HASH="$CORRECT_HASH"
|
||||||
echo "$OUTPUT" | tail -40 >&2
|
else
|
||||||
|
echo "verification build failed after hash update" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
FIXED=1
|
||||||
OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \
|
echo "fixed"
|
||||||
| sed -E 's/hash = "(.*)"/\1/')
|
fi
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
|
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
|
||||||
{
|
{
|
||||||
@ -235,7 +289,7 @@
|
|||||||
|
|
||||||
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
|
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
|
||||||
echo
|
echo
|
||||||
echo "Stale lockfile hashes detected. Run:"
|
echo "Stale lockfile hash detected. Run:"
|
||||||
echo " nix run .#fix-lockfiles"
|
echo " nix run .#fix-lockfiles"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -51,9 +51,7 @@
|
|||||||
web = hermesAgent.hermesWeb;
|
web = hermesAgent.hermesWeb;
|
||||||
desktop = hermesAgent.hermesDesktop;
|
desktop = hermesAgent.hermesDesktop;
|
||||||
|
|
||||||
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles {
|
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles { attr = "tui"; };
|
||||||
packages = [ hermesAgent.hermesTui hermesAgent.hermesWeb hermesAgent.hermesDesktop ];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
24
nix/tui.nix
24
nix/tui.nix
@ -1,34 +1,32 @@
|
|||||||
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
|
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
|
||||||
{ pkgs, hermesNpmLib, ... }:
|
{ pkgs, hermesNpmLib, ... }:
|
||||||
let
|
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"; };
|
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;
|
version = packageJson.version;
|
||||||
in
|
in
|
||||||
pkgs.buildNpmPackage (npm // {
|
pkgs.buildNpmPackage (npm // {
|
||||||
pname = "hermes-tui";
|
pname = "hermes-tui";
|
||||||
inherit src npmDeps version;
|
inherit version;
|
||||||
|
|
||||||
doCheck = false;
|
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 = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
|
|
||||||
mkdir -p $out/lib/hermes-tui
|
mkdir -p $out/lib/hermes-tui
|
||||||
|
# esbuild writes to ui-tui/dist/ from the source root (no cd).
|
||||||
# Single self-contained bundle built by scripts/build.mjs (esbuild).
|
cp -r ui-tui/dist $out/lib/hermes-tui/dist
|
||||||
cp -r dist $out/lib/hermes-tui/dist
|
|
||||||
|
|
||||||
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
|
# 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
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|||||||
25
nix/web.nix
25
nix/web.nix
@ -1,31 +1,34 @@
|
|||||||
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
|
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
|
||||||
{ pkgs, hermesNpmLib, ... }:
|
{ pkgs, hermesNpmLib, ... }:
|
||||||
let
|
let
|
||||||
src = ../web;
|
|
||||||
npmDeps = pkgs.fetchNpmDeps {
|
|
||||||
inherit src;
|
|
||||||
hash = "sha256-HV0aISBVjwbGqDj8qQynSxGFrrZDzuYAW3D3lB/x3zo=";
|
|
||||||
};
|
|
||||||
|
|
||||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
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;
|
version = packageJson.version;
|
||||||
in
|
in
|
||||||
pkgs.buildNpmPackage (npm // {
|
pkgs.buildNpmPackage (npm // {
|
||||||
pname = "hermes-web";
|
pname = "hermes-web";
|
||||||
inherit src npmDeps version;
|
inherit version;
|
||||||
|
|
||||||
doCheck = false;
|
doCheck = false;
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
npx tsc -b
|
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
|
||||||
npx vite build --outDir dist
|
# 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 = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
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
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
})
|
})
|
||||||
|
|||||||
3015
package-lock.json
generated
3015
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,10 @@
|
|||||||
"description": "An AI agent with advanced tool-calling capabilities, featuring a flexible toolsets system for organizing and managing tools.",
|
"description": "An AI agent with advanced tool-calling capabilities, featuring a flexible toolsets system for organizing and managing tools.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*"
|
"apps/*",
|
||||||
|
"ui-tui",
|
||||||
|
"ui-tui/packages/*",
|
||||||
|
"web"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'"
|
"postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'"
|
||||||
|
|||||||
@ -198,36 +198,50 @@ class TestCmdUpdateBranchFallback:
|
|||||||
if call.args and call.args[0][0] == "/usr/bin/npm"
|
if call.args and call.args[0][0] == "/usr/bin/npm"
|
||||||
]
|
]
|
||||||
|
|
||||||
# cmd_update runs npm commands in four locations:
|
# cmd_update runs npm commands in these locations:
|
||||||
# 1. repo root — slash-command / TUI bridge deps (subprocess.run)
|
# 1. repo root — root-only install (--workspaces=false)
|
||||||
# 2. ui-tui/ — Ink TUI deps (subprocess.run)
|
# 2. repo root — workspace install (--workspace ui-tui --workspace web)
|
||||||
# 3. web/ — npm install (subprocess.run)
|
# 3. web/ — npm ci --silent (if lockfile not at root)
|
||||||
# 4. web/ — npm run build (_run_with_idle_timeout)
|
# 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
|
# With a single workspace lockfile at the repo root, the root
|
||||||
# without `capture_output` so optional postinstall scripts (e.g.
|
# 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 —
|
# `@askjo/camofox-browser`'s browser-binary fetch) print progress —
|
||||||
# otherwise long downloads look like a hang (#18840). The web/ install
|
# otherwise long downloads look like a hang (#18840).
|
||||||
# keeps `--silent` because its build step is short and noisy.
|
root_flags = [
|
||||||
update_flags = [
|
|
||||||
"/usr/bin/npm",
|
"/usr/bin/npm",
|
||||||
"ci",
|
"ci",
|
||||||
"--no-fund",
|
"--no-fund",
|
||||||
"--no-audit",
|
"--no-audit",
|
||||||
"--progress=false",
|
"--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] == [
|
assert npm_calls[:2] == [
|
||||||
(repo_flags, PROJECT_ROOT),
|
(root_flags, PROJECT_ROOT),
|
||||||
(update_flags, PROJECT_ROOT / "ui-tui"),
|
(ws_flags, PROJECT_ROOT),
|
||||||
]
|
]
|
||||||
if len(npm_calls) > 2:
|
if len(npm_calls) > 2:
|
||||||
# Only the web/ install is left in subprocess.run; the build moved
|
# The web/ install runs from the workspace root when the root
|
||||||
# to _run_with_idle_timeout to make Vite progress visible (#33788).
|
# lockfile exists (npm workspaces hoist node_modules upward).
|
||||||
assert npm_calls[2:] == [
|
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.
|
# 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_args[0] == ["/usr/bin/npm", "run", "build"]
|
||||||
assert idle_kwargs["cwd"] == PROJECT_ROOT / "web"
|
assert idle_kwargs["cwd"] == PROJECT_ROOT / "web"
|
||||||
|
|
||||||
# Regression for #18840: repo root + ui-tui installs must stream
|
# Regression for #18840: root npm installs must stream output
|
||||||
# output (capture_output=False) so postinstall progress is visible
|
# (capture_output=False) so postinstall progress is visible
|
||||||
# to the user.
|
# to the user. The _build_web_ui install uses --silent and
|
||||||
repo_and_tui_calls = [
|
# capture_output=True, so exclude it.
|
||||||
|
root_install_calls = [
|
||||||
call
|
call
|
||||||
for call in mock_run.call_args_list
|
for call in mock_run.call_args_list
|
||||||
if call.args
|
if call.args
|
||||||
and call.args[0][0] == "/usr/bin/npm"
|
and call.args[0][0] == "/usr/bin/npm"
|
||||||
and call.args[0][1] == "ci"
|
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
|
assert len(root_install_calls) == 2 # root-only + workspace install
|
||||||
for call in repo_and_tui_calls:
|
for call in root_install_calls:
|
||||||
assert call.kwargs.get("capture_output") is False, (
|
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"
|
"(no capture_output) so postinstall progress is visible"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -193,3 +193,109 @@ def test_make_tui_argv_keeps_desktop_always_build_behaviour(
|
|||||||
|
|
||||||
assert calls
|
assert calls
|
||||||
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
|
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)
|
||||||
|
)
|
||||||
|
|||||||
@ -69,7 +69,9 @@ class TestWebUIBuildNeeded:
|
|||||||
def test_returns_true_when_package_lock_newer_than_dist(self, tmp_path):
|
def test_returns_true_when_package_lock_newer_than_dist(self, tmp_path):
|
||||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||||
_touch(dist_dir / ".vite" / "manifest.json", offset=-10)
|
_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
|
assert _web_ui_build_needed(web_dir) is True
|
||||||
|
|
||||||
def test_returns_true_when_vite_config_newer_than_dist(self, tmp_path):
|
def test_returns_true_when_vite_config_newer_than_dist(self, tmp_path):
|
||||||
|
|||||||
@ -147,11 +147,13 @@ def test_dockerfile_installs_tui_dependencies(dockerfile_text):
|
|||||||
# because it's referenced as a ``file:`` workspace dependency from
|
# because it's referenced as a ``file:`` workspace dependency from
|
||||||
# ``ui-tui/package.json`` — copying the tree avoids npm stopping at a
|
# ``ui-tui/package.json`` — copying the tree avoids npm stopping at a
|
||||||
# bare ``package.json`` shell.
|
# 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.json" in dockerfile_text
|
||||||
assert "ui-tui/package-lock.json" in dockerfile_text
|
|
||||||
assert "ui-tui/packages/hermes-ink/" in dockerfile_text
|
assert "ui-tui/packages/hermes-ink/" in dockerfile_text
|
||||||
|
assert "package-lock.json" in dockerfile_text
|
||||||
assert any(
|
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)
|
for step in _run_steps(dockerfile_text)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
7449
ui-tui/package-lock.json
generated
7449
ui-tui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
1289
ui-tui/packages/hermes-ink/package-lock.json
generated
1289
ui-tui/packages/hermes-ink/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
8887
web/package-lock.json
generated
8887
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user