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.fix/verification-admin-route-recovery
parent
01eaba7061
commit
a51a7b9b92
2
.envrc
2
.envrc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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]}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
296
nix/lib.nix
296
nix/lib.nix
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"; };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
24
nix/tui.nix
24
nix/tui.nix
|
|
@ -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
|
||||
'';
|
||||
|
|
|
|||
25
nix/web.nix
25
nix/web.nix
|
|
@ -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
|
||||
'';
|
||||
})
|
||||
|
|
|
|||
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.",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
"apps/*",
|
||||
"ui-tui",
|
||||
"ui-tui/packages/*",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"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"
|
||||
]
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue