mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(nix): dirty-tree wrapper bug + filtered rebuild scope + overlay alias (#65237)
* fix(nix): fold makeWrapper line continuations into optionalStrings
When rev == null (any dirty-tree build), the empty optionalString
expansion left the previous line's trailing backslash dangling onto a
blank line, ending the makeWrapper command early and running
`--suffix PYTHONPATH ...` as its own shell command (`--suffix: command
not found`, exit 127). Clean trees passed CI; dirty trees with
extraPythonPackages failed — exactly the path the NixOS module
exercises.
The continuation now lives inside each optionalString (" \\\n --set
..."), so the makeWrapper chain stays intact whether or not the
optional flags expand.
Verified by building with rev = null + extraPythonPackages =
[ pyfiglet ]: wrapper builds, PYTHONPATH suffix lands inside the
makeWrapper call, wrapped `hermes --version` runs, and the collision
check still executes (certifi correctly rejected).
* perf(nix): filter derivation sources to shrink rebuild scope
Every derivation previously saw the whole repo, so any file change
rebuilt everything. Each derivation now gets a filtered src with only
the files it consumes:
- lib.nix: derive npm workspace topology from the root package.json
`workspaces` globs (single source of truth — a new workspace member
is picked up with zero nix edits). pythonSrc (cleanSourceWith)
excludes the JS workspace trees, docs/website, docker/.github,
tests, nix/, flake.nix/flake.lock, root docs, and skills/ +
optional-skills/. importNpmLock reads from a fileset-filtered
npmRoot (root manifests + member package.jsons only).
- mkNpmPassthru takes `dirs` — the workspace dirs the package
contains — and builds a per-package fileset src from them. web and
desktop include apps/shared (file: dep). One shared
`nix run .#update-npm-lockfile` replaces the per-package
update_*_lockfile bins that only existed inside build sandboxes.
- python.nix: release venv loads the uv2nix workspace from pythonSrc.
The editable venv keeps an unfiltered ./.. root —
mkEditablePyprojectOverlay calls lib.path.splitRoot, which rejects
a cleanSourceWith set, and the editable install reads the live
checkout anyway.
- hermes-agent.nix: skills ship exclusively via HERMES_BUNDLED_SKILLS
/ HERMES_OPTIONAL_SKILLS (same mechanism as Homebrew packaging;
setup.py's _data_file_tree returns [] for missing dirs), so
SKILL.md edits no longer rebuild the venv. optional-mcps stays in
the wheel — pyproject.toml lists its manifests as explicit
data-files. Bundled assets are symlinked instead of copied, making
the wrapper drv near-instant when only an input changed.
__pycache__ filtered from bundled skills.
- checks.nix: find -L through the new symlinks; assert
optional-skills presence + HERMES_OPTIONAL_SKILLS in the wrapper.
- run_tests.sh: fall back to $HERMES_PYTHON when no local venv
exists, guarded by an `import pytest` probe (HERMES_PYTHON from a
wrapped hermes binary points at the release venv, which has no
pytest — without the guard every test file dies with "No module
named pytest" while the runner exits 0).
Verified: nix flake check exit 0; built .#default .#tui .#web
.#desktop; SKILL.md and flake.nix edits leave the venv drvPath
unchanged; .py edits leave the tui drvPath unchanged; .tsx edits
leave the venv drvPath unchanged (and do change the tui drv);
scripts/run_tests.sh runs 299 tests green through both the venv and
HERMES_PYTHON paths, and rejects a pytest-less HERMES_PYTHON.
* refactor(nix): overlay aliases the flake's own package instead of re-instantiating
The overlay previously re-called callPackage against the consumer's
nixpkgs (final), so pkgs.hermes-agent could be a different derivation
than nix build .#default and the NixOS module's default — an untested
build matrix against arbitrary consumer nixpkgs versions, for a
package whose Python side is uv2nix-locked anyway.
Now the overlay is a pure alias for the flake's own locked package:
one callPackage site (packages.nix), everything else references it.
.override { ... } still works — callPackage's makeOverridable travels
with the derivation.
Verified: direct drvPath == overlaid drvPath; .override produces a
distinct drv.
* fix(nix): dedupe extraPlugins assertions, replace MESSAGING_CWD with terminal.cwd
- Delete the duplicated extraPlugins duplicate-name assertions block
(same assertion declared twice back to back).
- Stop setting the deprecated MESSAGING_CWD env var, which made the
module trip hermes' own startup deprecation warning. The working
directory is now injected as terminal.cwd into the generated
config.yaml; cfg.settings wins via recursiveUpdate, and container
mode maps to the in-container mount path.
This commit is contained in:
parent
a16ac37dd9
commit
f8b6d381e2
11 changed files with 322 additions and 77 deletions
|
|
@ -127,7 +127,8 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
|||
test -d ${hermes-agent}/share/hermes-agent/skills || (echo "FAIL: skills directory missing"; exit 1)
|
||||
echo "PASS: skills directory exists"
|
||||
|
||||
SKILL_COUNT=$(find ${hermes-agent}/share/hermes-agent/skills -name "SKILL.md" | wc -l)
|
||||
# -L: skills/ is a symlink to the filtered source store path
|
||||
SKILL_COUNT=$(find -L ${hermes-agent}/share/hermes-agent/skills -name "SKILL.md" | wc -l)
|
||||
test "$SKILL_COUNT" -gt 0 || (echo "FAIL: no SKILL.md files found in skills directory"; exit 1)
|
||||
echo "PASS: $SKILL_COUNT bundled skills found"
|
||||
|
||||
|
|
@ -135,6 +136,16 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
|||
(echo "FAIL: HERMES_BUNDLED_SKILLS not in wrapper"; exit 1)
|
||||
echo "PASS: HERMES_BUNDLED_SKILLS set in wrapper"
|
||||
|
||||
# Optional skills ship via the wrapper too (pythonSrc excludes
|
||||
# them from the wheel, so the env var is the only path in nix).
|
||||
test -d ${hermes-agent}/share/hermes-agent/optional-skills || \
|
||||
(echo "FAIL: optional-skills directory missing"; exit 1)
|
||||
OPT_COUNT=$(find -L ${hermes-agent}/share/hermes-agent/optional-skills -name "SKILL.md" | wc -l)
|
||||
test "$OPT_COUNT" -gt 0 || (echo "FAIL: no SKILL.md files in optional-skills"; exit 1)
|
||||
grep -q "HERMES_OPTIONAL_SKILLS" ${hermes-agent}/bin/hermes || \
|
||||
(echo "FAIL: HERMES_OPTIONAL_SKILLS not in wrapper"; exit 1)
|
||||
echo "PASS: $OPT_COUNT optional skills found, HERMES_OPTIONAL_SKILLS set in wrapper"
|
||||
|
||||
echo "=== All bundled skills checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
|
|
@ -169,7 +180,8 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
|||
test -d ${hermes-agent}/share/hermes-agent/locales || (echo "FAIL: locales directory missing"; exit 1)
|
||||
echo "PASS: locales directory exists"
|
||||
|
||||
LOC_COUNT=$(find ${hermes-agent}/share/hermes-agent/locales -name "*.yaml" | wc -l)
|
||||
# -L: locales/ is a symlink to the source store path
|
||||
LOC_COUNT=$(find -L ${hermes-agent}/share/hermes-agent/locales -name "*.yaml" | wc -l)
|
||||
test "$LOC_COUNT" -ge 16 || (echo "FAIL: expected >=16 catalogs, found $LOC_COUNT"; exit 1)
|
||||
echo "PASS: $LOC_COUNT locale catalogs found"
|
||||
|
||||
|
|
|
|||
|
|
@ -17,10 +17,13 @@
|
|||
...
|
||||
}:
|
||||
let
|
||||
# apps/shared ships as a file: workspace dep of apps/desktop, so its
|
||||
# source must be in the filtered src tree too.
|
||||
npm = hermesNpmLib.mkNpmPassthru {
|
||||
folder = "apps/desktop";
|
||||
attr = "desktop";
|
||||
pname = "hermes-desktop";
|
||||
dirs = [
|
||||
"apps/desktop"
|
||||
"apps/shared"
|
||||
];
|
||||
};
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ let
|
|||
extraDependencyGroups:
|
||||
callPackage ./python.nix {
|
||||
inherit uv2nix pyproject-nix pyproject-build-systems;
|
||||
pythonSrc = hermesNpmLib.pythonSrc;
|
||||
dependency-groups = [ "all" ] ++ extraDependencyGroups;
|
||||
};
|
||||
|
||||
|
|
@ -60,7 +61,17 @@ let
|
|||
|
||||
bundledSkills = lib.cleanSourceWith {
|
||||
src = ../skills;
|
||||
filter = path: _type: !(lib.hasInfix "/index-cache/" path);
|
||||
filter =
|
||||
path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
|
||||
};
|
||||
|
||||
# Optional skills are NOT in the wheel (pythonSrc excludes them, see
|
||||
# lib.nix) — the wrapper exposes them via HERMES_OPTIONAL_SKILLS, the
|
||||
# same mechanism Homebrew packaging uses.
|
||||
bundledOptionalSkills = lib.cleanSourceWith {
|
||||
src = ../optional-skills;
|
||||
filter =
|
||||
path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
|
||||
};
|
||||
|
||||
# Import bundled plugins (memory, context_engine, platforms/*). Keeping
|
||||
|
|
@ -161,28 +172,40 @@ stdenv.mkDerivation (finalAttrs: {
|
|||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Symlinks, not copies: these are all store paths already, and the
|
||||
# wrapper env vars just hold paths. Symlinking keeps this derivation
|
||||
# near-instant when only the venv changed, with an identical closure.
|
||||
mkdir -p $out/share/hermes-agent $out/bin
|
||||
cp -r ${bundledSkills} $out/share/hermes-agent/skills
|
||||
cp -r ${bundledPlugins} $out/share/hermes-agent/plugins
|
||||
cp -r ${bundledLocales} $out/share/hermes-agent/locales
|
||||
cp -r ${hermesWeb} $out/share/hermes-agent/web_dist
|
||||
|
||||
mkdir -p $out/ui-tui
|
||||
cp -r ${hermesTui}/lib/hermes-tui/* $out/ui-tui/
|
||||
ln -s ${bundledSkills} $out/share/hermes-agent/skills
|
||||
ln -s ${bundledOptionalSkills} $out/share/hermes-agent/optional-skills
|
||||
ln -s ${bundledPlugins} $out/share/hermes-agent/plugins
|
||||
ln -s ${bundledLocales} $out/share/hermes-agent/locales
|
||||
ln -s ${hermesWeb} $out/share/hermes-agent/web_dist
|
||||
ln -s ${hermesTui}/lib/hermes-tui $out/ui-tui
|
||||
|
||||
${lib.concatMapStringsSep "\n"
|
||||
(name: ''
|
||||
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
|
||||
--suffix PATH : "${runtimePath}" \
|
||||
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \
|
||||
--set HERMES_OPTIONAL_SKILLS $out/share/hermes-agent/optional-skills \
|
||||
--set HERMES_BUNDLED_PLUGINS $out/share/hermes-agent/plugins \
|
||||
--set HERMES_BUNDLED_LOCALES $out/share/hermes-agent/locales \
|
||||
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
|
||||
--set HERMES_TUI_DIR $out/ui-tui \
|
||||
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
|
||||
--set HERMES_NODE ${lib.getExe nodejs} \
|
||||
${lib.optionalString (rev != null) ''--set HERMES_REVISION ${rev} \''}
|
||||
${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''}
|
||||
--set HERMES_NODE ${lib.getExe nodejs}${
|
||||
# Fold the line continuation INTO the optionalString: a bare
|
||||
# `\` on the line above an empty expansion would dangle onto a
|
||||
# blank line, ending the makeWrapper command early and running
|
||||
# the next flag as its own shell command (`--suffix: command
|
||||
# not found`). Only reproduces when rev == null (dirty trees).
|
||||
lib.optionalString (rev != null) " \\\n --set HERMES_REVISION ${rev}"
|
||||
}${
|
||||
lib.optionalString (
|
||||
extraPythonPackages != [ ]
|
||||
) " \\\n --suffix PYTHONPATH : \"${pythonPath}\""
|
||||
}
|
||||
'')
|
||||
[
|
||||
"hermes"
|
||||
|
|
|
|||
240
nix/lib.nix
240
nix/lib.nix
|
|
@ -1,57 +1,251 @@
|
|||
# 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,
|
||||
# root package-lock.json. mkNpmPassthru provides the shared npmDeps,
|
||||
# npmRoot, and npmConfigHook so individual .nix files don't duplicate them.
|
||||
#
|
||||
# Source filters (pythonSrc, per-package npm srcs) reduce rebuild scope so
|
||||
# that e.g. a .tsx change doesn't trigger a Python venv rebuild, and a .py
|
||||
# change doesn't trigger a TUI/Web/Desktop rebuild. Each derivation gets a
|
||||
# filtered src that only includes files it actually needs, while keeping
|
||||
# the repo-root directory layout intact for buildNpmPackage /
|
||||
# npmConfigHook workspace resolution.
|
||||
#
|
||||
# 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.
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
npm-lockfile-fix,
|
||||
nodejs,
|
||||
}:
|
||||
let
|
||||
# The workspace root — where the single package-lock.json lives.
|
||||
src = ../.;
|
||||
repoRoot = ./..;
|
||||
|
||||
# ── npm workspace discovery ────────────────────────────────────────
|
||||
# Single source of truth: the `workspaces` field of the root
|
||||
# package.json. Everything below (workspace package.json discovery,
|
||||
# the Python source's JS-dir exclusions) is derived from this so the
|
||||
# topology is never duplicated. Add a workspace to package.json and
|
||||
# the nix build picks it up automatically.
|
||||
rootPackageJson = builtins.fromJSON (builtins.readFile (repoRoot + "/package.json"));
|
||||
|
||||
# Expand a workspace glob (e.g. "apps/*") into concrete member dirs
|
||||
# relative to the repo root. Only trailing "*" globs are supported —
|
||||
# that's all npm uses here. Literal patterns (e.g. "ui-tui") pass
|
||||
# through unchanged.
|
||||
expandWorkspace =
|
||||
pattern:
|
||||
let
|
||||
parts = lib.splitString "/" pattern;
|
||||
in
|
||||
if lib.last parts == "*" then
|
||||
let
|
||||
parent = lib.concatStringsSep "/" (lib.init parts);
|
||||
entries = builtins.readDir (repoRoot + "/${parent}");
|
||||
dirs = lib.filterAttrs (_: t: t == "directory") entries;
|
||||
in
|
||||
map (d: "${parent}/${d}") (builtins.attrNames dirs)
|
||||
else
|
||||
[ pattern ];
|
||||
|
||||
# All workspace member directories (relative paths), filtered to those
|
||||
# that actually carry a package.json — a glob like apps/* may match a
|
||||
# dir that isn't really a package.
|
||||
workspaceMemberDirs = builtins.filter (d: builtins.pathExists (repoRoot + "/${d}/package.json")) (
|
||||
lib.concatMap expandWorkspace rootPackageJson.workspaces
|
||||
);
|
||||
|
||||
# Top-level directory of each workspace member, deduplicated. Used to
|
||||
# exclude JS/TS workspace trees from the Python source filter. E.g.
|
||||
# apps/desktop + apps/shared + ui-tui + web → [ "apps" "ui-tui" "web" ].
|
||||
jsWorkspaceTopDirs = lib.unique (map (d: builtins.head (lib.splitString "/" d)) workspaceMemberDirs);
|
||||
|
||||
# ── Source filters for reducing rebuild scope ──────────────────────
|
||||
# Changing a .tsx/.mjs file should NOT trigger a Python venv rebuild,
|
||||
# and changing a .py file should NOT trigger a TUI/Web/Desktop rebuild.
|
||||
|
||||
# Python source: everything except JS/TS/docs/infra directories.
|
||||
pythonSrc = lib.cleanSourceWith {
|
||||
src = repoRoot;
|
||||
name = "hermes-python-source";
|
||||
filter =
|
||||
path: type:
|
||||
let
|
||||
relPath = lib.removePrefix (toString repoRoot + "/") (toString path);
|
||||
components = lib.splitString "/" relPath;
|
||||
topComponent = if components == [ ] then "" else builtins.head components;
|
||||
excludedDirs =
|
||||
# JS/TS workspace directories — derived from the npm workspaces
|
||||
# so a new workspace member is excluded from the Python source
|
||||
# without touching this list.
|
||||
jsWorkspaceTopDirs
|
||||
++ [
|
||||
# Documentation
|
||||
"docs"
|
||||
"website"
|
||||
# CI/infra
|
||||
"docker"
|
||||
".github"
|
||||
# Content/examples
|
||||
"infographic"
|
||||
"datagen-config-examples"
|
||||
# unused packaging infra
|
||||
"packaging"
|
||||
# Test infrastructure
|
||||
"tests"
|
||||
# Plan/temp files
|
||||
"plans"
|
||||
# Nix build definitions (Python build doesn't need these)
|
||||
"nix"
|
||||
# Skills are shipped via HERMES_BUNDLED_SKILLS /
|
||||
# HERMES_OPTIONAL_SKILLS (see hermes-agent.nix), not via the
|
||||
# wheel's data_files — setup.py's _data_file_tree returns []
|
||||
# for a missing dir, so the wheel builds fine without them.
|
||||
# This keeps SKILL.md edits from rebuilding the Python venv.
|
||||
# NOTE: optional-mcps must stay — pyproject.toml lists its
|
||||
# manifests as explicit data-files, which error when missing.
|
||||
"skills"
|
||||
"optional-skills"
|
||||
];
|
||||
excludedFiles = [
|
||||
# JS root manifests
|
||||
"package.json"
|
||||
"package-lock.json"
|
||||
# Docker files
|
||||
"Dockerfile"
|
||||
"docker-compose.yml"
|
||||
"docker-compose.windows.yml"
|
||||
# Nix build definitions — editing the flake shouldn't rebuild
|
||||
# the venv. (Input changes rebuild regardless, via the lock.)
|
||||
"flake.nix"
|
||||
"flake.lock"
|
||||
# Root docs the wheel doesn't consume. README.md and LICENSE
|
||||
# must stay — pyproject.toml references them (readme /
|
||||
# license-files).
|
||||
"AGENTS.md"
|
||||
"CONTRIBUTING.md"
|
||||
"SECURITY.md"
|
||||
"README.zh-CN.md"
|
||||
".gitignore"
|
||||
"setup-hermes.sh"
|
||||
];
|
||||
in
|
||||
if relPath == "" then
|
||||
true
|
||||
else if builtins.elem relPath excludedFiles then
|
||||
false
|
||||
else if builtins.elem topComponent excludedDirs then
|
||||
false
|
||||
else
|
||||
true;
|
||||
};
|
||||
|
||||
# Common npm workspace resolution files needed by all npm builds.
|
||||
# npm ci requires all workspace package.json files to resolve
|
||||
# workspace: protocol dependencies correctly. Discovered from the
|
||||
# root package.json workspaces — root manifests + every member's
|
||||
# package.json.
|
||||
npmWorkspaceFiles = lib.fileset.unions (
|
||||
[
|
||||
(repoRoot + "/package.json")
|
||||
(repoRoot + "/package-lock.json")
|
||||
]
|
||||
++ map (d: repoRoot + "/${d}/package.json") workspaceMemberDirs
|
||||
);
|
||||
|
||||
# npm deps source: just what importNpmLock needs (root manifests +
|
||||
# workspace member package.jsons). Much smaller than the full repo,
|
||||
# so changing source files won't invalidate the npmDeps derivation.
|
||||
npmDepsSrc = lib.fileset.toSource {
|
||||
root = repoRoot;
|
||||
fileset = npmWorkspaceFiles;
|
||||
};
|
||||
|
||||
# npm dependencies for the workspace, shared by all members. importNpmLock
|
||||
# resolves each package from the lockfile's own `integrity` hashes, so the
|
||||
# lockfile is the single source of truth — no separate dependency hash to
|
||||
# keep in sync with it.
|
||||
npmDeps = pkgs.importNpmLock.importNpmLock { npmRoot = src; };
|
||||
npmDeps = pkgs.importNpmLock.importNpmLock { npmRoot = npmDepsSrc; };
|
||||
|
||||
# Build a per-package npm source: workspace resolution files + the
|
||||
# package's own directory tree(s). Source ROOT is always the repo
|
||||
# root, preserving the workspace layout that buildNpmPackage and
|
||||
# npmConfigHook expect. Callers pass the dirs they need (relative to
|
||||
# the repo root), so each package owns its own source scope.
|
||||
mkNpmSrc =
|
||||
dirs:
|
||||
lib.fileset.toSource {
|
||||
root = repoRoot;
|
||||
fileset = lib.fileset.union npmWorkspaceFiles (
|
||||
lib.fileset.unions (map (d: repoRoot + "/${d}") dirs)
|
||||
);
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit pythonSrc;
|
||||
|
||||
# Regenerate the shared root lockfile from scratch and verify all npm
|
||||
# packages still build. Exposed as a runnable package — `nix run
|
||||
# .#update-npm-lockfile` — so it's actually usable, unlike a bin buried
|
||||
# in a build sandbox's PATH. All workspace packages share one lockfile,
|
||||
# so there's a single script (not one per package).
|
||||
updateNpmLockfile = pkgs.writeShellScriptBin "update-npm-lockfile" ''
|
||||
set -euo pipefail
|
||||
# DEBUG=1 nix run .#update-npm-lockfile — trace every command
|
||||
[ -n "''${DEBUG:-}" ] && set -x
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
rm -rf node_modules/
|
||||
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
|
||||
CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces
|
||||
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
|
||||
|
||||
# importNpmLock reads hashes from the lockfile itself — rebuild every
|
||||
# npm package to verify the new lockfile resolves offline.
|
||||
nix build .#tui .#web .#desktop
|
||||
echo "Lockfile updated and all npm packages built."
|
||||
'';
|
||||
|
||||
# Returns a buildNpmPackage-compatible attrs set that provides:
|
||||
# src, npmDeps, npmRoot — workspace source + importNpmLock dep set
|
||||
# src, npmDeps, npmRoot — filtered workspace source + importNpmLock dep set
|
||||
# npmConfigHook — importNpmLock's offline `npm install` hook
|
||||
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
|
||||
# passthru.packageJsonPath — relative path to this workspace's package.json
|
||||
# nodejs — fixed nodejs version for all packages we use in the repo
|
||||
#
|
||||
# `dirs` is the single source of truth for what the package contains:
|
||||
# its first entry is the package's own folder (→ packageJsonPath), and
|
||||
# all entries scope the filtered src. Packages that import source from
|
||||
# another workspace member (file: deps) must list that member's dir too,
|
||||
# e.g. apps/desktop depends on apps/shared.
|
||||
#
|
||||
# Usage:
|
||||
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
# npm = hermesNpmLib.mkNpmPassthru { dirs = [ "ui-tui" ]; };
|
||||
# npm = hermesNpmLib.mkNpmPassthru { dirs = [ "apps/desktop" "apps/shared" ]; };
|
||||
# pkgs.buildNpmPackage (npm // {
|
||||
# sourceRoot = "ui-tui";
|
||||
# pname = "hermes-tui";
|
||||
# inherit version;
|
||||
# buildPhase = '' ... '';
|
||||
# installPhase = '' ... '';
|
||||
# })
|
||||
mkNpmPassthru =
|
||||
{
|
||||
folder, # repo-relative folder with package.json, e.g. "ui-tui"
|
||||
attr, # flake package attr, e.g. "tui"
|
||||
...
|
||||
}:
|
||||
{ dirs }:
|
||||
let
|
||||
# The package's own folder is the first dir; it carries the
|
||||
# package.json that buildNpmPackage reads.
|
||||
folder = builtins.head dirs;
|
||||
# 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 src npmDeps nodejs;
|
||||
inherit nodejs npmDeps;
|
||||
src = mkNpmSrc dirs;
|
||||
# importNpmLock's hook installs the rewritten lockfile (every `resolved`
|
||||
# rewritten to a /nix/store file: path) into the unpacked workspace and
|
||||
# runs `npm install` offline, so every workspace member's dependencies
|
||||
|
|
@ -61,24 +255,6 @@ in
|
|||
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
|
||||
|
||||
nativeBuildInputs = [
|
||||
(pkgs.writeShellScriptBin "update_${attr}_lockfile" ''
|
||||
set -euox pipefail
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
# 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 --workspaces
|
||||
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
|
||||
|
||||
nix build .#${attr}
|
||||
echo "Lockfile updated and build verified for .#${attr}"
|
||||
'')
|
||||
];
|
||||
|
||||
passthru = {
|
||||
packageJsonPath = "${folder}/package.json";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,8 +42,16 @@
|
|||
merge = _loc: defs: lib.foldl' lib.recursiveUpdate { } (map (d: d.value) defs);
|
||||
};
|
||||
|
||||
# Generate config.yaml from Nix attrset (YAML is a superset of JSON)
|
||||
configJson = builtins.toJSON cfg.settings;
|
||||
# Generate config.yaml from Nix attrset (YAML is a superset of JSON).
|
||||
# terminal.cwd replaces the deprecated MESSAGING_CWD env var — hermes
|
||||
# reads it from config.yaml and bridges it to TERMINAL_CWD internally.
|
||||
# recursiveUpdate: cfg.settings wins, so an explicit
|
||||
# settings.terminal.cwd overrides the workingDirectory default.
|
||||
# Container mode uses the in-container mount path.
|
||||
effectiveWorkDir = if cfg.container.enable then containerWorkDir else cfg.workingDirectory;
|
||||
configJson = builtins.toJSON (
|
||||
lib.recursiveUpdate { terminal.cwd = effectiveWorkDir; } cfg.settings
|
||||
);
|
||||
generatedConfigFile = pkgs.writeText "hermes-config.yaml" configJson;
|
||||
configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile;
|
||||
|
||||
|
|
@ -674,16 +682,6 @@
|
|||
}];
|
||||
}
|
||||
|
||||
# ── Assertions ─────────────────────────────────────────────────────
|
||||
{
|
||||
assertions = let
|
||||
names = map lib.getName cfg.extraPlugins;
|
||||
in [{
|
||||
assertion = (lib.length names) == (lib.length (lib.unique names));
|
||||
message = "services.hermes-agent.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate.";
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Warnings ──────────────────────────────────────────────────────
|
||||
# ── Per-user profile for extraPackages ───────────────────────────
|
||||
# Wire extraPackages into the hermes user's per-user profile so the
|
||||
|
|
@ -883,7 +881,8 @@
|
|||
HOME = cfg.stateDir;
|
||||
HERMES_HOME = "${cfg.stateDir}/.hermes";
|
||||
HERMES_MANAGED = "true";
|
||||
MESSAGING_CWD = cfg.workingDirectory;
|
||||
# Working directory is declared via terminal.cwd in the merged
|
||||
# config.yaml (see configJson above) — MESSAGING_CWD is deprecated.
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
|
|
@ -980,7 +979,6 @@
|
|||
--env HERMES_HOME=${containerDataDir}/.hermes \
|
||||
--env HERMES_MANAGED=true \
|
||||
--env HOME=${containerHomeDir} \
|
||||
--env MESSAGING_CWD=${containerWorkDir} \
|
||||
${lib.concatStringsSep " " cfg.container.extraOptions} \
|
||||
${cfg.container.image} \
|
||||
${containerDataDir}/current-package/bin/hermes gateway run --replace ${lib.concatStringsSep " " cfg.extraArgs}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
# nix/overlays.nix — Expose pkgs.hermes-agent for external NixOS configs
|
||||
#
|
||||
# The overlay is a pure alias for this flake's own package — NOT a
|
||||
# re-instantiation against the consumer's nixpkgs. This guarantees
|
||||
# `pkgs.hermes-agent`, `nix build .#default`, and the NixOS module's
|
||||
# default package are all the exact same locked, tested derivation.
|
||||
# (.override { extraPythonPackages = ...; } still works — callPackage's
|
||||
# makeOverridable travels with the package.)
|
||||
{ inputs, ... }:
|
||||
{
|
||||
flake.overlays.default = final: _: {
|
||||
hermes-agent = final.callPackage ./hermes-agent.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
npm-lockfile-fix = inputs.npm-lockfile-fix.packages.${final.stdenv.hostPlatform.system}.default;
|
||||
rev = inputs.self.rev or null;
|
||||
};
|
||||
hermes-agent = inputs.self.packages.${final.stdenv.hostPlatform.system}.default;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@
|
|||
tui = full.hermesTui;
|
||||
web = full.hermesWeb;
|
||||
desktop = full.hermesDesktop;
|
||||
|
||||
update-npm-lockfile = full.hermesNpmLib.updateNpmLockfile;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@
|
|||
pyproject-nix,
|
||||
pyproject-build-systems,
|
||||
stdenv,
|
||||
# Filtered Python source (see lib.nix pythonSrc) — keeps JS/docs/skills
|
||||
# edits from invalidating the venv derivation.
|
||||
pythonSrc,
|
||||
dependency-groups ? [ "all" ],
|
||||
}:
|
||||
let
|
||||
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./..; };
|
||||
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = pythonSrc; };
|
||||
hacks = callPackage pyproject-nix.build.hacks { };
|
||||
|
||||
overlay = workspace.mkPyprojectOverlay {
|
||||
|
|
@ -110,11 +113,18 @@ let
|
|||
]
|
||||
);
|
||||
|
||||
editableOverlay = workspace.mkEditablePyprojectOverlay {
|
||||
# The editable venv points at the live checkout, so it uses an
|
||||
# UNFILTERED workspace rooted at a real path — mkEditablePyprojectOverlay
|
||||
# computes relative paths via lib.path.splitRoot, which rejects the
|
||||
# filtered pythonSrc (a cleanSourceWith set, not a path). Filtering
|
||||
# buys nothing here anyway: the editable install reads from
|
||||
# $HERMES_PYTHON_SRC_ROOT at runtime.
|
||||
workspaceRoot = ./..;
|
||||
editableWorkspace = uv2nix.lib.workspace.loadWorkspace { inherit workspaceRoot; };
|
||||
editableOverlay = editableWorkspace.mkEditablePyprojectOverlay {
|
||||
root = "$HERMES_PYTHON_SRC_ROOT"; # resolved at shellHook time
|
||||
};
|
||||
|
||||
workspaceRoot = ./..;
|
||||
editableSet = pythonSet.overrideScope (
|
||||
lib.composeManyExtensions [
|
||||
editableOverlay
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
|
||||
{ pkgs, hermesNpmLib, ... }:
|
||||
let
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
npm = hermesNpmLib.mkNpmPassthru { dirs = [ "ui-tui" ]; };
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/ui-tui/package.json"));
|
||||
version = packageJson.version;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
|
||||
{ pkgs, hermesNpmLib, ... }:
|
||||
let
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
||||
# @hermes/shared ships as a file: workspace dep of web, so its source
|
||||
# must be in the filtered src tree too.
|
||||
npm = hermesNpmLib.mkNpmPassthru {
|
||||
dirs = [
|
||||
"web"
|
||||
"apps/shared"
|
||||
];
|
||||
};
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/web/package.json"));
|
||||
version = packageJson.version;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ set -euo pipefail
|
|||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# ── Activate venv ───────────────────────────────────────────────────────────
|
||||
# ── Locate python ───────────────────────────────────────────────────────────
|
||||
# Probe local venvs first; fall back to the Nix devShell's editable venv
|
||||
# (HERMES_PYTHON is exported by the devShell hook and ships [dev] extras:
|
||||
# pytest, pytest-asyncio, pytest-timeout, ruff, ty).
|
||||
VENV=""
|
||||
for candidate in "$REPO_ROOT/.venv" "$REPO_ROOT/venv" "$HOME/.hermes/hermes-agent/venv"; do
|
||||
if [ -f "$candidate/bin/activate" ]; then
|
||||
|
|
@ -46,13 +49,21 @@ for candidate in "$REPO_ROOT/.venv" "$REPO_ROOT/venv" "$HOME/.hermes/hermes-agen
|
|||
fi
|
||||
done
|
||||
|
||||
if [ -z "$VENV" ]; then
|
||||
echo "error: no virtualenv found in $REPO_ROOT/.venv or $REPO_ROOT/venv" >&2
|
||||
if [ -n "$VENV" ]; then
|
||||
PYTHON="$VENV/bin/python"
|
||||
elif [ -n "${HERMES_PYTHON:-}" ] && [ -x "$HERMES_PYTHON" ] \
|
||||
&& "$HERMES_PYTHON" -c 'import pytest' 2>/dev/null; then
|
||||
# Guard with an import check: HERMES_PYTHON may point at the RELEASE
|
||||
# venv (no pytest) when inherited from a wrapped `hermes` binary rather
|
||||
# than the devShell hook.
|
||||
PYTHON="$HERMES_PYTHON"
|
||||
echo "▶ no local venv — using Nix dev venv via HERMES_PYTHON: $PYTHON"
|
||||
else
|
||||
echo "error: no virtualenv found in $REPO_ROOT/.venv or $REPO_ROOT/venv," >&2
|
||||
echo " and HERMES_PYTHON is not a python with pytest (enter the Nix devShell or create a venv)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PYTHON="$VENV/bin/python"
|
||||
|
||||
|
||||
# ── Live-gateway plugin (computed before we drop env) ───────────────────────
|
||||
EXTRA_PYTHONPATH=""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue