diff --git a/nix/checks.nix b/nix/checks.nix index 7ae8c6ef266d..7f625ca4fe4b 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -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" diff --git a/nix/desktop.nix b/nix/desktop.nix index a266accaaa83..cb18a4f0491b 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -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")); diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index 4013e80c1fe7..043d1e942021 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -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" diff --git a/nix/lib.nix b/nix/lib.nix index a7a6eab7c5bc..a705331c12b3 100644 --- a/nix/lib.nix +++ b/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"; }; diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index 4458926b0f00..df74427c48f9 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -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} diff --git a/nix/overlays.nix b/nix/overlays.nix index 474e57d852b6..53248a8a56e7 100644 --- a/nix/overlays.nix +++ b/nix/overlays.nix @@ -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; }; } diff --git a/nix/packages.nix b/nix/packages.nix index d11a21d2cb71..0dad8e3673aa 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -58,6 +58,8 @@ tui = full.hermesTui; web = full.hermesWeb; desktop = full.hermesDesktop; + + update-npm-lockfile = full.hermesNpmLib.updateNpmLockfile; }; }; } diff --git a/nix/python.nix b/nix/python.nix index 0ba5c1717ad7..602c08a3fd04 100644 --- a/nix/python.nix +++ b/nix/python.nix @@ -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 diff --git a/nix/tui.nix b/nix/tui.nix index 6659302d60be..80696a0626a3 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -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; diff --git a/nix/web.nix b/nix/web.nix index ba32d82cab45..b93466a2a14f 100644 --- a/nix/web.nix +++ b/nix/web.nix @@ -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; diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 7db931f521d3..24ad3e8409ec 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -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=""