Files
hermes-agent/tests/hermes_cli/test_source_build.py
teknium1 b63c138d78 fix: Hermes never falls back to the user's node/npm/npx/uv
Maintainer ruling: only Hermes and only its packaged package managers
(uv/pip/node/npm) are ever used; no PATH fallback when the managed tool is
missing, no "prefer the user's if new enough".

- hermes_constants.find_node_executable: node/npm/npx resolve to PM's
  installed copy or None. Every caller already pm.ensure()s on None, so a
  missing runtime is now provisioned instead of silently borrowing the
  user's Node (native-addon ABI / npm cache mismatches).
- agent/lsp/install._install_npm: pm.ensure('npm') when PM npm is absent,
  instead of failing over to whatever npm is on PATH.
- gateway._append_node_dir_for_service: stop baking the invoker's PATH node
  dir into generated systemd/launchd units.
- main_install_repair._resolve_node_runtime_npm: drop the PATH re-scan for
  another npm.
- source_build.source_product_current: run the freshness reader only with
  PM's node.
- doctor: Node/npm rows and npm audit use PM's copies (Termux APT distro
  keeps its system Node).
- install.sh ensure_uv / install.ps1 Get-Uv: always stage the pinned uv
  artifact; delete the "uv on PATH if new enough" developer shortcut.
2026-09-27 22:04:26 -07:00

311 lines
14 KiB
Python

"""Source orchestration uses real node-deps/npm in an isolated checkout.
Only PM's tool acquisition (and its installed-node record) is substituted with the host's node/npm. Small
workspace scripts stand in for the expensive UI compilers; subprocess failures,
locked dependency selection, environment propagation and publication are real.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
from types import SimpleNamespace
import pytest
import pm
from pm.package import Runner
def use_host_node_as_pm_node(monkeypatch):
"""Freshness reads run only under PM's recorded Node; stand the host's node in for it."""
node, real = Path(shutil.which("node")), pm.installed_package
monkeypatch.setattr(pm, "installed_package", lambda name, **kwargs: (
SimpleNamespace(binary=node) if name == "node" else real(name, **kwargs)))
def copy_freshness_scripts(root):
repository = Path(__file__).resolve().parents[2]
scripts = root / "scripts/build"
scripts.mkdir(parents=True, exist_ok=True)
for name in ("freshness.mjs", "frontend-common.mjs"):
shutil.copy2(repository / "scripts/build" / name, scripts / name)
def stamp_product(root, product, out):
script = (root / "scripts/build/freshness.mjs").as_uri()
subprocess.run([shutil.which("node"), "--input-type=module", "-e",
f"import {{recordProduct, buildInputs}} from {json.dumps(script)};"
"const [source, product, out] = process.argv.slice(1);"
"recordProduct({source, product, out, inputs: buildInputs(source, product)});",
str(root), product, str(out)], check=True)
def test_source_build_uses_selected_python_for_isolated_icon_child(tmp_path, monkeypatch):
from hermes_cli.source_build import source_build_env
from pm import paths
from pm.environments import site_packages, venv_python
root = tmp_path / "source"
root.mkdir()
venv = root / "venv"
subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(venv)], check=True)
selected = site_packages(venv)
(selected / "icon_dependency.py").write_text("ready = True\n", encoding="utf-8")
monkeypatch.setattr(paths, "repo_root", lambda: root)
monkeypatch.setattr(pm, "ensure", lambda name, **kwargs: Runner(name, kwargs["base_env"]))
monkeypatch.syspath_prepend(str(selected))
env = source_build_env()
assert env["HERMES_PYTHON"] == str(venv_python(venv))
subprocess.run([env["HERMES_PYTHON"], "-I", "-c",
"import icon_dependency; assert icon_dependency.ready"], check=True)
def test_automatic_build_preserves_pm_admission_intent(monkeypatch):
from hermes_cli.source_build import source_build_env
intent = []
def acquire(name, *, base_env, explicit):
intent.append(explicit)
return Runner(name, base_env)
monkeypatch.setattr(pm, "ensure", acquire)
source_build_env()
assert intent == [False]
def test_installed_npm_does_not_authorize_missing_workspace_dependencies(source_checkout, monkeypatch):
from hermes_cli.source_build import prepare_source_dependencies, source_build_env
root, _ = source_checkout
env = source_build_env()
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
with pytest.raises(subprocess.CalledProcessError):
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
assert _events(root) == []
prepare_source_dependencies(root, ("ui-tui", "web"), env=env, explicit=True)
events = _events(root)
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
assert _events(root) == events
@pytest.fixture
def source_checkout(tmp_path, monkeypatch):
node, npm = shutil.which("node"), shutil.which("npm")
assert node and npm, "source-build integration requires node and npm"
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools"))
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "0")
monkeypatch.setenv("npm_config_cache", str(tmp_path / "npm-cache"))
monkeypatch.setenv("ESBUILD_BINARY_PATH", "/wrong/esbuild")
monkeypatch.setenv("HERMES_PYTHON", "/wrong/python")
(home / "npmrc").write_text("fund=false\n", encoding="utf-8")
monkeypatch.delenv("NPM_CONFIG_USERCONFIG", raising=False)
acquired = []
def acquire(name, *, base_env=None, explicit=False):
acquired.append(name)
assert name == "npm"
return Runner(name, {**(base_env or os.environ), "PATH": os.pathsep.join(
[str(Path(npm).parent), str(Path(node).parent), os.environ["PATH"]])})
monkeypatch.setattr(pm, "ensure", acquire)
use_host_node_as_pm_node(monkeypatch)
root = tmp_path / "source with spaces"
root.mkdir()
workspaces = ["ui-tui", "web", "apps/desktop", "unrelated"]
manifest = {"name": "build-fixture", "private": True, "version": "1.0.0",
"workspaces": workspaces, "scripts": {"postinstall": "node log.mjs deps"}}
(root / "package.json").write_text(json.dumps(manifest), encoding="utf-8")
for workspace in workspaces:
directory = root / workspace
directory.mkdir(parents=True)
(directory / "package.json").write_text(json.dumps({
"name": workspace.replace("/", "-"), "version": "1.0.0",
"scripts": {"build": "node ../../scripts/build/build-desktop.mjs",
"builder": "node ../../scripts/build/package-desktop.mjs"}
if workspace == "apps/desktop" else {},
}), encoding="utf-8")
(root / "log.mjs").write_text(
"import { appendFileSync } from 'node:fs';\n"
"appendFileSync('events.jsonl', JSON.stringify({step: process.argv[2], "
"python: process.env.HERMES_PYTHON, ci: process.env.CI, "
"esbuild: process.env.ESBUILD_BINARY_PATH, "
"npmrc: process.env.NPM_CONFIG_USERCONFIG}) + '\\n');\n",
encoding="utf-8",
)
subprocess.run([npm, "install", "--package-lock-only", "--ignore-scripts", "--offline",
"--no-audit", "--no-fund"], cwd=root, check=True)
scripts = root / "scripts" / "build"
scripts.mkdir(parents=True)
repository = Path(__file__).resolve().parents[2]
for name in ("node-deps.mjs", "freshness.mjs", "frontend-common.mjs"):
shutil.copy2(repository / "scripts/build" / name, scripts / name)
(root / ".gitignore").write_text("node_modules/\n**/dist/\n", encoding="utf-8")
return root, acquired
@pytest.fixture
def source_products(source_checkout):
root, acquired = source_checkout
(root / "product.mjs").write_text(
"import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';\n"
"import { dirname, join } from 'node:path';\n"
"import { fileURLToPath } from 'node:url';\n"
"import { recordProduct, buildInputs } from './scripts/build/freshness.mjs';\n"
"const root = dirname(fileURLToPath(import.meta.url));\n"
"export function build(step, output) {\n"
" appendFileSync(join(root, 'events.jsonl'), JSON.stringify({step}) + '\\n');\n"
" if (existsSync(join(root, 'fail-' + step))) throw new Error('fixture ' + step + ' failure');\n"
" if (step === 'web' && !existsSync(join(root, 'web/public/favicon.ico'))) throw new Error('icons missing');\n"
" const path = join(root, output); mkdirSync(dirname(path), { recursive: true });\n"
" writeFileSync(path, step);\n"
" if (step !== 'icons') {\n"
" let out = dirname(path);\n"
" if (step === 'desktop') { out = join(out, 'resources/app.asar.unpacked/dist');\n"
" mkdirSync(out, {recursive: true}); writeFileSync(join(out, 'index.html'), 'renderer'); }\n"
" recordProduct({source: root, product: step, out, inputs: buildInputs(root, step)});\n"
" }\n"
"}\n",
encoding="utf-8",
)
for script, step, output in [
("generate-icons.mjs", "icons", "never-rendered-at-install"),
("build/tui.mjs", "tui", "ui-tui/dist/entry.js"),
("build/web.mjs", "web", "hermes_cli/web_dist/index.html"),
]:
relative = "../../" if script.startswith("build/") else "../"
(root / "scripts" / script).write_text(
f"import {{ build }} from '{relative}product.mjs'; build({step!r}, {output!r});\n",
encoding="utf-8",
)
(root / "scripts/build/package-desktop.mjs").write_text(
"import { relative } from 'node:path';\n"
"import { build } from '../../product.mjs';\n"
"const flag = '-c.directories.output=';\n"
"const staging = process.argv.find(arg => arg.startsWith(flag)).slice(flag.length);\n"
"build('desktop', relative('../..', staging) + '/linux-unpacked/hermes');\n",
encoding="utf-8",
)
(root / "scripts/build/build-desktop.mjs").write_text(
"if (!process.argv.includes('--icons')) await import('../generate-icons.mjs');\n",
encoding="utf-8",
)
# Default-brand icons are committed; updates consume them without rendering.
(root / "web/public").mkdir(parents=True, exist_ok=True)
(root / "web/public/favicon.ico").write_bytes(b"committed icon")
return root, acquired
def _events(root):
path = root / "events.jsonl"
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
@pytest.mark.platforms("posix")
def test_preparation_reuses_only_the_exact_completed_workspace_union(source_checkout):
from hermes_cli.source_build import prepare_source_dependencies, source_build_env
root, acquired = source_checkout
before = (root / "package-lock.json").read_bytes()
env = source_build_env()
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
first = _events(root)
assert [event["step"] for event in first] == ["deps"]
assert first[0]["python"] == env["HERMES_PYTHON"]
assert first[0]["ci"] == "1"
assert "esbuild" not in first[0]
assert first[0]["npmrc"] == str(Path(os.environ["HERMES_HOME"]) / "npmrc")
assert (root / "node_modules/ui-tui").exists()
assert (root / "node_modules/web").exists()
assert not (root / "node_modules/apps-desktop").exists()
assert not (root / "node_modules/unrelated").exists()
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
assert _events(root) == first
prepare_source_dependencies(root, ("ui-tui", "web", "apps/desktop"), env=env)
assert len(_events(root)) == 2
assert (root / "node_modules/apps-desktop").exists()
assert not (root / "node_modules/unrelated").exists()
assert (root / "package-lock.json").read_bytes() == before
assert acquired == ["npm"]
# A lock failure must not fall back to npm install and rewrite the lock.
(root / "package-lock.json").write_text("not json", encoding="utf-8")
with pytest.raises(subprocess.CalledProcessError):
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
assert (root / "package-lock.json").read_text() == "not json"
assert len(_events(root)) == 2
assert acquired == ["npm"]
# Exercise PM rather than hiding a provisioning error behind system npm.
from unittest.mock import patch
with patch.object(pm, "ensure", side_effect=pm.InstallError("npm", "unavailable")):
with pytest.raises(pm.InstallError, match="unavailable"):
source_build_env()
assert len(_events(root)) == 2
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("desktop", [False, True])
def test_update_builds_selected_products_after_one_union_preparation(source_products, desktop):
from hermes_cli.source_build import build_update_products
from hermes_cli.main_web_build import _web_ui_build_needed
root, acquired = source_products
app = root / "apps/desktop/release/linux-unpacked/hermes"
app.parent.mkdir(parents=True)
app.write_text("previous app")
build_update_products(root, desktop=desktop)
steps = [event["step"] for event in _events(root)]
assert steps == ["deps", "tui", "web"] + (["desktop"] if desktop else [])
assert acquired == ["npm"]
assert (root / "ui-tui/dist/entry.js").read_text() == "tui"
assert (root / "hermes_cli/web_dist/index.html").read_text() == "web"
assert not _web_ui_build_needed(root / "web")
assert (root / "node_modules/apps-desktop").exists() == desktop
assert not (root / "node_modules/unrelated").exists()
assert app.read_text() == ("desktop" if desktop else "previous app")
assert not list((root / "apps/desktop").glob(".staging-*"))
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("step", ["tui", "web", "desktop"])
def test_update_failure_raises_without_retries_or_replacing_live_app(source_products, step):
from hermes_cli.source_build import build_update_products
root, acquired = source_products
app = root / "apps/desktop/release/linux-unpacked/hermes"
app.parent.mkdir(parents=True)
app.write_text("previous app")
(root / f"fail-{step}").touch()
with pytest.raises(subprocess.CalledProcessError):
build_update_products(root, desktop=True)
assert app.read_text() == "previous app"
assert not list((root / "apps/desktop").glob(".staging-*"))
order = ["deps", "tui", "web", "desktop"]
assert [event["step"] for event in _events(root)] == order[:order.index(step) + 1]
assert acquired == ["npm"]
assert not (Path(os.environ["HERMES_HOME"]) / "desktop-build-stamp.json").exists()
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("desktop", [False, True])
def test_module_cli_builds_the_requested_products(source_products, desktop, monkeypatch):
import runpy
root, acquired = source_products
monkeypatch.setattr(sys, "argv", ["source_build", "--source", str(root)] + (["--desktop"] if desktop else []))
# run_module exercises __main__ while substituting only tool acquisition.
monkeypatch.delitem(sys.modules, "hermes_cli.source_build", raising=False)
runpy.run_module("hermes_cli.source_build", run_name="__main__")
assert acquired == ["npm"]
assert (root / "hermes_cli/web_dist/index.html").is_file()
assert (root / "apps/desktop/release/linux-unpacked/hermes").exists() == desktop