Merge updated model metrics into tool metrics

Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
Alex Fournier
2026-08-02 20:11:20 -07:00
881 changed files with 88099 additions and 10987 deletions
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Audit (and auto-fix) contributor email mappings for a PR branch.
Mirrors the CI gate in .github/workflows/contributor-check.yml so salvage
branches never bounce off the check-attribution job. Run it from the branch
you are about to push:
python3 scripts/audit_pr_attribution.py # report only
python3 scripts/audit_pr_attribution.py --fix # create mapping files
Logic (kept in sync with contributor-check.yml):
- scans ``git log $(git merge-base origin/main HEAD)..HEAD --format=%ae``
- skips teknium/bot emails and ``<id>+<login>@users.noreply.github.com``
(CI auto-resolves those)
- everything else must have ``contributors/emails/<email>`` or a legacy
AUTHOR_MAP entry in scripts/release.py
``--fix`` resolution order for an unmapped email:
1. bare ``<login>@users.noreply.github.com`` → ``<login>``, verified via
``gh api users/<login>``. A warning is printed: the local part is
*usually* the GitHub login but is user-controlled (the historical
``bryan@…`` → ``hydraxman`` case) — eyeball it against the PR author.
2. ``gh api 'search/users?q=<email>+in:email'``
3. otherwise: prints the manual ``add_contributor.py`` command and exits 1.
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKIP_SUBSTRINGS = (
"teknium",
"noreply@github.com",
"dependabot",
"github-actions",
"anthropic.com",
"cursor.com",
)
ID_NOREPLY_RE = re.compile(r"\d+\+.+@users\.noreply\.github\.com$")
BARE_NOREPLY_RE = re.compile(r"^([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})@users\.noreply\.github\.com$")
def run(*args: str, check: bool = True) -> str:
result = subprocess.run(
list(args), capture_output=True, text=True, encoding="utf-8",
errors="replace", cwd=str(REPO_ROOT),
)
if check and result.returncode != 0:
raise RuntimeError(f"{' '.join(args)}: {result.stderr.strip()}")
return result.stdout.strip()
def new_emails() -> list[str]:
base = run("git", "merge-base", "origin/main", "HEAD")
log = run("git", "log", f"{base}..HEAD", "--format=%ae", "--no-merges", check=False)
return sorted({e for e in log.splitlines() if e.strip()})
def is_mapped(email: str) -> bool:
if any(s in email for s in SKIP_SUBSTRINGS):
return True
if ID_NOREPLY_RE.search(email):
return True
if (REPO_ROOT / "contributors" / "emails" / email).is_file():
return True
release_py = REPO_ROOT / "scripts" / "release.py"
try:
if f'"{email}"' in release_py.read_text(encoding="utf-8", errors="replace"):
return True
except OSError:
pass
return False
def gh_json(*args: str):
try:
out = run("gh", "api", *args, check=False)
return json.loads(out) if out else None
except (RuntimeError, json.JSONDecodeError, FileNotFoundError):
return None
def resolve_login(email: str) -> tuple[str, str] | None:
"""Return (login, how) or None."""
m = BARE_NOREPLY_RE.match(email)
if m:
login = m.group(1)
user = gh_json(f"users/{login}")
if user and user.get("login"):
return user["login"], "bare-noreply local part (verified user exists)"
found = gh_json(f"search/users?q={email}+in:email")
if found and found.get("items"):
return found["items"][0]["login"], "GitHub email search"
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--fix", action="store_true",
help="auto-create contributors/emails/ mapping files")
args = parser.parse_args()
unmapped = [e for e in new_emails() if not is_mapped(e)]
if not unmapped:
print("✅ All contributor emails on this branch are mapped.")
return 0
failed = []
for email in unmapped:
author = run("git", "log", f"--author={email}", "--format=%an", "-1", check=False)
if not args.fix:
print(f"⚠️ unmapped: {email} ({author})")
continue
resolved = resolve_login(email)
if resolved:
login, how = resolved
run("python3", "scripts/add_contributor.py", email, login)
print(f"✔ mapped {email} -> {login} [{how}]")
if BARE_NOREPLY_RE.match(email):
print(f" ⚠ local part is user-controlled — confirm @{login} really is "
f"the contributor (git name: {author!r}) before pushing.")
else:
failed.append((email, author))
if not args.fix:
print("\nRun with --fix to auto-create mapping files, or manually:")
for email in unmapped:
print(f" python3 scripts/add_contributor.py {email} <github-username>")
return 1
if failed:
print("\nCould not auto-resolve; map manually:")
for email, author in failed:
print(f" python3 scripts/add_contributor.py {email} <github-username> # {author}")
return 1
print("\nDone — remember to `git add contributors && git commit`.")
return 0
if __name__ == "__main__":
sys.exit(main())
+37
View File
@@ -295,6 +295,43 @@ def main():
# Batch resolve GitHub paths for skills.sh entries
all_skills = batch_resolve_paths(all_skills, auth)
# Enrich ClawHub skills with owner handles. The listing API does not
# include owner info, so we fetch each skill's detail page concurrently.
# This is needed to build valid "View source" URLs on the Skills Hub page:
# https://clawhub.ai/{owner}/skills/{slug}. Without the owner segment the
# URL leads to a 404.
clawhub_skills = [s for s in all_skills if s["source"] == "clawhub"]
if clawhub_skills:
# Convert dicts back to SkillMeta for enrichment, then update in place.
clawhub_metas = []
for s in clawhub_skills:
meta = SkillMeta(
name=s["name"],
description=s["description"],
source=s["source"],
identifier=s["identifier"],
trust_level=s["trust_level"],
repo=s.get("repo") or None,
path=s.get("path") or None,
tags=s.get("tags") or [],
extra=dict(s.get("extra") or {}),
)
clawhub_metas.append(meta)
print(f" Enriching {len(clawhub_metas)} ClawHub skills with owner handles...",
flush=True)
enrich_start = time.time()
enriched = sources["clawhub"].enrich_owners(clawhub_metas, max_workers=30)
# Write enriched owner back into the index dicts.
meta_by_id = {m.identifier: m for m in clawhub_metas}
for s in clawhub_skills:
meta = meta_by_id.get(s["identifier"])
if meta and meta.extra.get("owner"):
s.setdefault("extra", {})["owner"] = meta.extra["owner"]
enrich_elapsed = time.time() - enrich_start
print(f" Enriched {enriched}/{len(clawhub_metas)} ClawHub owners "
f"({enrich_elapsed:.1f}s)", flush=True)
# Collect which sources hit a GitHub API rate limit during the crawl.
# github / well-known both read api.github.com, so a rate-limited token
# zeroes both at once — surfaced below so the failure message names the
+18
View File
@@ -9,6 +9,10 @@ booleans (one per lane) to ``$GITHUB_OUTPUT`` and stdout. The
Lanes:
* ``python`` — pytest / ruff / ty / footguns.
* ``python_prod`` — Python changes OUTSIDE tests/ — gates jobs that ship or
run the product (Desktop E2E backend, Docker image) but never import the
test suite. A tests-only PR keeps ``python`` (pytest must run) while
skipping those product jobs.
* ``docker_meta`` — Dockerfiles etc.
* ``frontend`` — TS typecheck matrix + desktop build.
* ``site`` — Docusaurus + generated skill docs.
@@ -74,6 +78,18 @@ def _py_irrelevant(p: str) -> bool:
return _is_docs(p) or p in _ROOT_NPM or p.startswith(_PY_SKIP) or p.startswith(_DOCKER_META)
def _py_test_only(p: str) -> bool:
"""Is ``p`` inside the test suite (never shipped / imported by the product)?
Product jobs (Desktop E2E's ``hermes serve`` backend, the Docker image)
run installed code — nothing under ``tests/`` is packaged or importable
there. scripts/run_tests.sh and run_tests_parallel.py are deliberately
NOT test-only: they are runner infrastructure, and a bad edit there can
mask real failures, so they stay conservative (python_prod=true).
"""
return p.startswith("tests/")
def _is_scan(p: str) -> bool:
return p.endswith(_SCAN_EXTS) or p in _SCAN_FILES
@@ -100,6 +116,7 @@ def classify(files: list[str]) -> dict[str, bool]:
files = [f.strip() for f in files if f.strip()]
ret = {
"python": any(not _py_irrelevant(f) for f in files),
"python_prod": any(not _py_irrelevant(f) and not _py_test_only(f) for f in files),
"docker_meta": any(f.startswith(_DOCKER_META) for f in files),
"frontend": any(f.startswith(_FRONTEND) or f in _ROOT_NPM for f in files),
"site": any(f.startswith(_SITE) for f in files),
@@ -111,6 +128,7 @@ def classify(files: list[str]) -> dict[str, bool]:
}
if not files or any(f.startswith(".github/") for f in files):
ret["python"] = True
ret["python_prod"] = True
ret["docker_meta"] = True
ret["frontend"] = True
ret["site"] = True
@@ -0,0 +1,125 @@
# Behavioral test for install.ps1's persisted-User-PATH migration.
#
# Run: pwsh -NoProfile -File scripts/ci/test_install_ps1_path_migration.ps1
#
# Not wired into the default CI lane — the Linux runners have no PowerShell
# host. It runs on any machine with pwsh (including via nixpkgs#powershell),
# and on a Windows runner if one is ever added.
#
# This is NOT a source-regex test. It parses install.ps1, lifts the real
# Set-ManagedNodeFirstOnUserPath body out of the AST, and rewrites *only* the
# two registry calls into an in-memory store so the actual shipped logic —
# split, dedupe, prepend, change-detection — executes for real. Rewriting from
# the AST rather than hand-copying the body means the test cannot silently
# drift away from the function it claims to cover.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$installPs1 = Join-Path $PSScriptRoot '..' 'install.ps1' | Resolve-Path
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$installPs1, [ref]$null, [ref]$null)
$fn = $ast.Find({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$n.Name -eq 'Set-ManagedNodeFirstOnUserPath'
}, $true)
if (-not $fn) {
throw "Set-ManagedNodeFirstOnUserPath not found in $installPs1"
}
# Swap the two registry calls for the in-memory store. Both must match, or the
# function has changed shape and this harness is no longer exercising it.
# Rewrite the whole definition extent (which already carries `function <name>
# { param(...) ... }`) so the shipped param block and body run verbatim.
$definition = $fn.Extent.Text
$reads = ([regex]'\[Environment\]::GetEnvironmentVariable\("Path", "User"\)').Matches($definition).Count
$writes = ([regex]'\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)').Matches($definition).Count
if ($reads -ne 1 -or $writes -ne 1) {
throw "expected exactly one User PATH read and one write in the function body; found $reads read(s), $writes write(s). Update this harness."
}
$definition = $definition -replace `
'\[Environment\]::GetEnvironmentVariable\("Path", "User"\)', '$script:FakeUserPath'
$definition = $definition -replace `
'\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)', '$script:FakeUserPath = $1; $script:FakeWrites++'
Invoke-Expression $definition
$NODE = 'C:\Users\me\AppData\Local\hermes\node'
$script:Failures = 0
function Invoke-Migration {
param([string]$Start, [string]$NodeDir = $NODE)
$script:FakeUserPath = $Start
$script:FakeWrites = 0
Set-ManagedNodeFirstOnUserPath $NodeDir
}
function Assert-Equal {
param($Expected, $Actual, [string]$Name)
if ($Expected -ceq $Actual) {
Write-Host " PASS $Name"
} else {
Write-Host " FAIL $Name"
Write-Host " expected: [$Expected]"
Write-Host " actual: [$Actual]"
$script:Failures++
}
}
Write-Host "install.ps1 Set-ManagedNodeFirstOnUserPath"
# The regression this function exists for: an install made by an older
# install.ps1, which *appended*. A system Node leads and the managed dir is
# stranded at the tail, so every new shell resolves the wrong node.exe. An
# add-if-missing check would see the entry present and leave it there forever.
Invoke-Migration "C:\Program Files\nodejs;C:\Users\me\bin;$NODE"
Assert-Equal "$NODE;C:\Program Files\nodejs;C:\Users\me\bin" $script:FakeUserPath `
'upgrade from appending installer: managed dir becomes first entry'
Assert-Equal 1 (@($script:FakeUserPath -split ';' | Where-Object { $_ -eq $NODE }).Count) `
'upgrade: managed dir is not duplicated'
Assert-Equal "C:\Program Files\nodejs;C:\Users\me\bin" `
(($script:FakeUserPath -split ';' | Where-Object { $_ -ne $NODE }) -join ';') `
'upgrade: unrelated entries keep their relative order'
Assert-Equal 1 $script:FakeWrites 'upgrade: persists exactly once'
Invoke-Migration "$NODE;C:\Program Files\nodejs"
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'already correct: unchanged'
Assert-Equal 0 $script:FakeWrites 'already correct: no registry write'
Invoke-Migration "C:\Program Files\nodejs"
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'fresh install: prepended'
# Empty segments are legal in a real User PATH (a trailing ';' is common) and
# the installer's other PATH code preserves them. Migration must not quietly
# rewrite parts of PATH it was not asked to touch.
Invoke-Migration "C:\Program Files\nodejs;;C:\Users\me\bin;"
Assert-Equal "$NODE;C:\Program Files\nodejs;;C:\Users\me\bin;" $script:FakeUserPath `
'empty segments are preserved'
# Windows paths are case-insensitive, and -ne on strings is too.
Invoke-Migration "C:\Program Files\nodejs;c:\users\me\appdata\local\HERMES\Node"
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath `
'existing entry in different case is replaced, not duplicated'
Invoke-Migration "$NODE;C:\Program Files\nodejs;$NODE"
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'duplicates collapse'
Invoke-Migration ""
Assert-Equal $NODE $script:FakeUserPath 'empty User PATH'
Invoke-Migration "C:\Program Files\nodejs" ""
Assert-Equal "C:\Program Files\nodejs" $script:FakeUserPath 'empty NodeDir is a no-op'
Assert-Equal 0 $script:FakeWrites 'empty NodeDir does not write'
if ($script:Failures -gt 0) {
Write-Host ""
Write-Host "$script:Failures assertion(s) failed"
exit 1
}
Write-Host ""
Write-Host "all assertions passed"
+157 -19
View File
@@ -151,6 +151,13 @@ $PythonVersion = "3.11"
# source of truth shared by Test-Python's fallback and Resolve-AvailablePythonVersion.
$PythonFallbackVersions = @("3.12", "3.13", "3.10")
$NodeVersion = "22"
# The npm range the root package.json pins in `engines.npm`. A constant rather
# than a manifest read like the POSIX side does: Test-Node runs BEFORE the repo
# is cloned, so there is usually no package.json on disk yet (and none at all
# when install.ps1 is piped straight from the web). Get-NpmRange prefers the
# manifest whenever it does exist, so a drifted constant self-corrects on any
# run against an existing checkout.
$NpmRange = ">=12.0.0"
# Stage-protocol version. Bumped only for genuinely breaking changes to the
# manifest schema, stage-name set semantics, or stdout JSON shape. Adding a
@@ -528,6 +535,135 @@ function Ensure-NodeExeOnPath {
return $true
}
# Put the Hermes-managed Node dir at the FRONT of the persisted User PATH.
#
# Appending is not enough: it leaves a pre-existing system Node ahead of the
# bundled one in every new shell, so anything launched without a curated
# environment (a standalone hermes-setup.exe run, a user typing `npm`) silently
# resolves the wrong Node. Bundled must win.
#
# Move-to-front rather than add-if-missing, because installs made by an older
# install.ps1 already have this dir in User PATH -- at the tail. An
# add-if-missing check sees it present and leaves the broken ordering in place
# forever, so the very users the ordering bug hurt would never be repaired.
#
# Unrelated entries keep their relative order, including empty segments (a
# trailing ';' is legal and common in a real User PATH; Install-Git's splitting
# preserves them too, so this must not quietly rewrite them). Duplicate
# occurrences of the managed dir collapse into the single leading entry.
# PowerShell's -ne is case-insensitive for strings, which is the right
# comparison on Windows. Persists only when the resulting string differs, so
# an already-correct PATH costs one registry read and no write.
function Set-ManagedNodeFirstOnUserPath {
param([string]$NodeDir)
if (-not $NodeDir) { return }
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$items = if ($userPath) { @($userPath -split ";") } else { @() }
$rest = @($items | Where-Object { $_ -ne $NodeDir })
$updated = (@($NodeDir) + $rest) -join ";"
if ($updated -ne $userPath) {
[Environment]::SetEnvironmentVariable("Path", $updated, "User")
}
}
# The npm range to install into the managed Node tree. Prefers the checkout's
# root package.json so the installer and the manifest cannot drift; falls back
# to the $NpmRange constant, which is the common case here because Test-Node
# runs before the repo is cloned.
function Get-NpmRange {
$manifest = Join-Path $InstallDir "package.json"
if (Test-Path $manifest) {
try {
$engines = (Get-Content $manifest -Raw | ConvertFrom-Json).engines
if ($engines -and $engines.npm) { return [string]$engines.npm }
} catch { }
}
return $NpmRange
}
# Upgrade the Hermes-managed Node tree's bundled npm into $NpmRange.
#
# The nodejs.org zip ships whatever npm that Node major bundles -- Node 26.5.1
# bundles npm 11.17.0, one minor below the root package.json's own
# `engines.npm` floor of >=12. The repo .npmrc sets `engine-strict=true`, so
# that is fatal rather than a warning and a brand-new install dies at the first
# `npm ci` with EBADENGINE. Provision the right npm here instead of reacting
# to the failure later.
#
# Three details are load-bearing, mirroring _nb_ensure_bundled_npm_range in
# scripts/lib/node-bootstrap.sh and upgrade_managed_npm in
# hermes_cli/npm_engine.py:
# - a temp cwd, so the checkout's own .npmrc (engine-strict,
# min-release-age) does not gate the very upgrade meant to satisfy it;
# - npm_config_min_release_age=0, which also neutralises a user ~/.npmrc;
# - an explicit --prefix at the managed tree, so the upgrade rewrites the
# tree's own npm rather than installing a second copy elsewhere.
#
# Best-effort: a failure leaves a working Node with an old npm, which beats no
# Node at all, and npm_engine.py still covers the EBADENGINE that follows.
function Update-ManagedNpm {
param([string]$NodeDir)
$npmCmd = Join-Path $NodeDir "npm.cmd"
if (-not (Test-Path $npmCmd)) { return $false }
$range = Get-NpmRange
# Skip the network round-trip when the bundled npm already satisfies the
# range. Only the ">=N" shape we actually author is parsed; anything more
# exotic falls through to letting npm itself decide.
if ($range -match '^>=(\d+)') {
$want = [int]$Matches[1]
try {
$have = (& $npmCmd --version 2>$null)
if ($have -match '^(\d+)') {
if ([int]$Matches[1] -ge $want) { return $true }
}
} catch { }
}
Write-Info "Upgrading bundled npm to satisfy $range ..."
$tmpCwd = Join-Path $env:TEMP ("hermes-npm-upgrade-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $tmpCwd | Out-Null
$prevAge = $env:npm_config_min_release_age
$prevCI = $env:CI
$prevEAP = $ErrorActionPreference
Push-Location $tmpCwd
try {
$env:npm_config_min_release_age = "0"
$env:CI = "1"
# Relax EAP=Stop so npm's stderr lines don't get wrapped as
# ErrorRecords and short-circuit before $LASTEXITCODE is checked.
# Same pattern as Install-Uv.
$ErrorActionPreference = "Continue"
& $npmCmd install --global --prefix $NodeDir "npm@$range" `
--no-fund --no-audit --progress=false 2>&1 | Out-Null
$exit = $LASTEXITCODE
} catch {
$exit = 1
} finally {
$ErrorActionPreference = $prevEAP
Pop-Location
$env:npm_config_min_release_age = $prevAge
$env:CI = $prevCI
Remove-Item -Recurse -Force $tmpCwd -ErrorAction SilentlyContinue
}
if ($exit -ne 0) {
Write-Warn "Could not upgrade bundled npm to $range -- ``npm ci`` may fail with EBADENGINE."
Write-Info "Fix manually: npm install -g --prefix `"$NodeDir`" npm@`"$range`""
return $false
}
Write-Success "npm $(& $npmCmd --version 2>$null) installed"
return $true
}
# Re-discover uv without re-installing it. Cross-process stage drivers
# (the desktop GUI's onboarding wizard, CI step-runners) invoke each stage
# in a fresh powershell process, so $script:UvCmd set by Install-Uv in a
@@ -1054,11 +1190,11 @@ function Set-GitBashEnvVar {
Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path."
}
# The desktop build runs Vite ^8, which refuses to start on Node outside
# `^20.19 || >=22.12` -- older Node lacks node:util.styleText, so `vite build`
# crashes with a SyntaxError that surfaces only as the opaque "Build desktop
# app ... exit code 1" install failure. Returns $true when a `node --version`
# string clears that floor.
# The dependency tree's real Node floor is >=22.22.0, set by react-router 8.3.0
# (`engines.node`). Keep this in sync with the root package.json: looser lets an
# install reach a `npm ci` that dies with EBADENGINE, stricter replaces a working
# user toolchain for nothing. Returns $true when a `node --version` string
# clears that floor.
function Test-NodeVersionOk {
param([string]$Version)
try {
@@ -1066,9 +1202,8 @@ function Test-NodeVersionOk {
} catch {
return $false
}
if ($v.Major -eq 20 -and $v.Minor -ge 19) { return $true }
if ($v.Major -ge 22 -and ($v.Major -gt 22 -or $v.Minor -ge 12)) { return $true }
return $false
if ($v.Major -eq 22) { return ($v.Minor -ge 22) }
return ($v.Major -gt 22)
}
function Test-Node {
@@ -1082,7 +1217,7 @@ function Test-Node {
$script:HasNode = $true
return $true
}
Write-Warn "Node.js $version is too old for the desktop build (need ^20.19 or >=22.12)"
Write-Warn "Node.js $version is too old (Hermes requires Node >=26)"
}
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
@@ -1090,7 +1225,12 @@ function Test-Node {
if ((Test-Path $managedNode) -and (Test-NodeVersionOk (& $managedNode --version))) {
$version = & $managedNode --version
$env:Path = "$HermesHome\node;$env:Path"
Set-ManagedNodeFirstOnUserPath "$HermesHome\node"
Write-Success "Node.js $version found (Hermes-managed)"
# A tree from an older install still has that Node major's bundled
# npm, which is below the current engines.npm floor. No-ops when the
# npm is already in range, so reruns cost one --version probe.
Update-ManagedNpm "$HermesHome\node" | Out-Null
$script:HasNode = $true
return $true
}
@@ -1132,17 +1272,15 @@ function Test-Node {
# Persist to User PATH so fresh shells (and future stages
# in cross-process driver mode) see it. Matches the
# pattern Install-Git uses for PortableGit.
$nodeDir = "$HermesHome\node"
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$userPathItems = if ($userPath) { $userPath -split ";" } else { @() }
if ($userPathItems -notcontains $nodeDir) {
$userPathItems += $nodeDir
[Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User")
}
# pattern Install-Git uses for PortableGit. See
# Set-ManagedNodeFirstOnUserPath for why this is a
# move-to-front and not an add-if-missing.
Set-ManagedNodeFirstOnUserPath "$HermesHome\node"
$version = & "$HermesHome\node\node.exe" --version
Write-Success "Node.js $version installed to $HermesHome\node\ (portable, user-scoped)"
# The zip's bundled npm is below the repo's engines.npm floor.
Update-ManagedNpm "$HermesHome\node" | Out-Null
$script:HasNode = $true
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
@@ -1176,7 +1314,7 @@ function Test-Node {
# even after a "successful" install. The OpenJS manifest does
# publish an arm64 installer, so this is safe.
$wingetArgs = @(
'install','OpenJS.NodeJS.LTS','--silent',
'install','OpenJS.NodeJS','--silent',
'--accept-package-agreements','--accept-source-agreements'
)
if ((Get-WindowsArch) -eq 'arm64') {
@@ -2891,7 +3029,7 @@ function Install-Desktop {
# Always re-resolve Node here. Stages run in separate PowerShell processes,
# so $script:HasNode from Stage-Node isn't visible; more importantly Test-Node
# enforces the build floor (^20.19 || >=22.12) and prepends the Hermes-managed
# enforces the build floor (Node >=26) and prepends the Hermes-managed
# Node to PATH, so the build never runs on a too-old system Node -- the cause
# of the opaque "Build desktop app ... exit code 1" failure (Vite crashes on
# old Node).
+67 -14
View File
@@ -780,23 +780,43 @@ check_git() {
exit 1
}
# The desktop build runs Vite ^8, which refuses to start on Node outside
# `^20.19 || >=22.12` — older Node lacks `node:util.styleText`, so `vite build`
# crashes with a SyntaxError that surfaces only as the opaque "Build desktop
# app … exit code 1" install failure. Returns 0 when the given `node --version`
# string clears that floor; anything below it is replaced with the Hermes-
# managed Node $NODE_VERSION LTS.
# The dependency tree's real Node floor is >=22.22.0, set by react-router 8.3.0
# (`engines.node`), with Vite ^8 next at `^20.19 || >=22.12`. Keep this in sync
# with the root package.json — a gate looser than the manifest lets an install
# proceed to a `npm ci` that then dies with EBADENGINE, and a gate stricter than
# the manifest replaces a working user toolchain for nothing. Returns 0 when the
# given `node --version` string clears the floor; anything below it is replaced
# with the Hermes-managed Node $NODE_VERSION.
node_satisfies_build() {
local ver="${1#v}"
local major="${ver%%.*}"
local minor="${ver#*.}"; minor="${minor%%.*}"
case "$major" in ''|*[!0-9]*) return 1 ;; esac
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
if [ "$major" -eq 20 ] && [ "$minor" -ge 19 ]; then return 0; fi
if [ "$major" -ge 22 ] && { [ "$major" -gt 22 ] || [ "$minor" -ge 12 ]; }; then return 0; fi
if [ "$major" -ge 22 ] && { [ "$major" -gt 22 ] || [ "$minor" -ge 22 ]; }; then return 0; fi
return 1
}
# npm 11.10.0–11.16.x honor `min-release-age` but ignore
# `min-release-age-exclude`, both of which `.npmrc` sets. That combination
# applies the 14-day age gate to packages we deliberately exempted, so every
# install fails ETARGET on a freshly published dependency. The root
# package.json excludes that band via `engines.npm`, and `engine-strict=true`
# makes it fatal — so a system npm in the band cannot install this repo, no
# matter how new its Node is. Returns 0 when the npm is usable.
npm_supports_npmrc() {
local ver="${1#v}"
local major="${ver%%.*}"
local minor="${ver#*.}"; minor="${minor%%.*}"
case "$major" in ''|*[!0-9]*) return 1 ;; esac
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
# The bad band is 11.10.0 through 11.16.x.
if [ "$major" -eq 11 ] && [ "$minor" -ge 10 ] && [ "$minor" -le 16 ]; then
return 1
fi
return 0
}
check_node() {
log_info "Checking Node.js (for browser tools)..."
@@ -805,10 +825,20 @@ check_node() {
# every install — including re-runs that skip the Node (re)install below.
configure_managed_node_npm_prefix
# The system toolchain is only usable when BOTH halves work: a Node new
# enough for the desktop build AND an npm that can read our .npmrc. A
# bad-band npm (see npm_supports_npmrc) fails `npm ci` outright, and the
# managed Node we install instead bundles one that works.
if command -v node &> /dev/null && node_satisfies_build "$(node --version)"; then
log_success "Node.js $(node --version) found"
HAS_NODE=true
return 0
if ! command -v npm &> /dev/null || npm_supports_npmrc "$(npm --version 2>/dev/null)"; then
log_success "Node.js $(node --version) found"
HAS_NODE=true
return 0
fi
log_warn "npm $(npm --version) cannot honor this repo's .npmrc (npm 11.10-11.16 ignore"
log_warn "min-release-age-exclude) — installing Hermes-managed Node $NODE_VERSION instead..."
install_node
return
fi
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
@@ -820,7 +850,7 @@ check_node() {
fi
if command -v node &> /dev/null; then
log_warn "Node.js $(node --version) is too old for the desktop build (need ^20.19 or >=22.12) — installing Hermes-managed Node $NODE_VERSION LTS..."
log_warn "Node.js $(node --version) is too old (Hermes requires Node >=26) — installing Hermes-managed Node $NODE_VERSION..."
elif [ "$DISTRO" = "termux" ]; then
log_info "Node.js not found — installing Node.js via pkg..."
else
@@ -869,7 +899,7 @@ install_node() {
;;
esac
# Resolve the latest v22.x.x tarball name from the index page
# Resolve the latest v${NODE_VERSION}.x.x tarball name from the index page
local index_url="https://nodejs.org/dist/latest-v${NODE_VERSION}.x/"
local tarball_name
tarball_name=$(curl -fsSL "$index_url" \
@@ -1725,6 +1755,29 @@ EOF
chmod +x "$command_link_dir/hermes"
log_success "Installed hermes launcher → $command_link_display_dir/hermes"
# Also expose `hermes-agent`. The `hermes-agent` console script declared in
# pyproject.toml's [project.scripts] lives inside the venv, which is not on
# the login-shell PATH. Without this launcher users can't invoke the agent
# entrypoint directly from outside the venv. (#74819)
rm -f "$command_link_dir/hermes-agent"
if [ "$USE_VENV" = true ]; then
cat > "$command_link_dir/hermes-agent" <<EOF
#!/usr/bin/env bash
unset PYTHONPATH
unset PYTHONHOME
exec "$HERMES_BIN" "$INSTALL_DIR/run_agent.py" "\$@"
EOF
else
cat > "$command_link_dir/hermes-agent" <<EOF
#!/usr/bin/env bash
unset PYTHONPATH
unset PYTHONHOME
exec "$HERMES_BIN" run_agent.py "\$@"
EOF
fi
chmod +x "$command_link_dir/hermes-agent"
log_success "Installed hermes-agent launcher → $command_link_display_dir/hermes-agent"
# Also expose `hermes-acp`. ACP hosts (Zed, JetBrains, Buzz) resolve the
# agent by command name on the login-shell PATH, and the `hermes-acp`
# console script lives inside the venv, which is not on that PATH. Without
@@ -2905,7 +2958,7 @@ install_desktop() {
# with no app and a confusing "couldn't find a built desktop" at launch.
# Always re-resolve Node here. Stages run in separate processes, so we can't
# trust an earlier check; more importantly check_node now enforces the build
# floor (^20.19 || >=22.12) and prepends the Hermes-managed Node to PATH, so
# floor (Node >=26) and prepends the Hermes-managed Node to PATH, so
# the build never runs on a too-old system Node — the cause of the opaque
# "Build desktop app … exit code 1" failure (Vite crashes on old Node).
check_node
+126 -5
View File
@@ -76,6 +76,93 @@ _nb_node_major() {
[[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0
}
# The npm range the checkout's root package.json demands. Read from the
# manifest rather than duplicated here so the two can never drift; falls back
# to the current floor when the manifest is unreadable (vendored copy of this
# script, stripped install tree).
_nb_npm_range() {
if [ -n "${HERMES_NPM_TARGET_RANGE:-}" ]; then
printf '%s\n' "$HERMES_NPM_TARGET_RANGE"
return 0
fi
local repo_root manifest range
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)"
manifest="$repo_root/package.json"
if [ -r "$manifest" ]; then
# sed, not node: this runs before a usable node is guaranteed.
range=$(sed -n '/"engines"/,/}/p' "$manifest" \
| sed -n 's/.*"npm"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -1)
if [ -n "$range" ]; then
printf '%s\n' "$range"
return 0
fi
fi
printf '>=12.0.0\n'
}
# Upgrade the managed tree's bundled npm into the checkout's engines.npm range.
#
# The nodejs.org tarball ships whatever npm that Node major bundles — Node
# 26.5.1 bundles npm 11.17.0, one minor below our own `engines.npm` floor of
# >=12. With `engine-strict=true` in the repo .npmrc that is fatal, not a
# warning, so a brand-new install died at the first `npm ci` with EBADENGINE.
# The Python side recovers through hermes_cli/npm_engine.py; the installer path
# had no such rung, so provision the right npm here instead of reacting later.
#
# Three details are load-bearing, all mirroring upgrade_managed_npm():
# - a temp cwd, so the checkout's own .npmrc (engine-strict, min-release-age)
# does not gate the very upgrade meant to satisfy it;
# - npm_config_min_release_age=0, which also neutralises a user ~/.npmrc;
# - an explicit --prefix at the managed tree, because
# _nb_configure_npm_prefix wrote prefix=~/.local into its etc/npmrc, and
# without the override this installs a second npm elsewhere while the
# managed tree stays stale.
#
# Best-effort: a failure here leaves a working Node with an old npm, which is
# strictly better than no Node at all, and npm_engine.py still covers the
# EBADENGINE that follows.
_nb_ensure_bundled_npm_range() {
local npm_bin="$HERMES_HOME/node/bin/npm"
[ -x "$npm_bin" ] || return 0
local range have want
range="$(_nb_npm_range)"
[ -n "$range" ] || return 0
# Skip the network round-trip when the bundled npm already satisfies the
# range. Only the ">=N" shape we actually author is checked; anything more
# exotic falls through to letting npm itself decide.
if [[ "$range" =~ ^\>=([0-9]+) ]]; then
want="${BASH_REMATCH[1]}"
have=$("$npm_bin" --version 2>/dev/null | cut -d. -f1)
if [[ "$have" =~ ^[0-9]+$ ]] && [ "$have" -ge "$want" ]; then
return 0
fi
fi
_nb_log "Upgrading bundled npm to satisfy $range..."
local tmp_cwd
tmp_cwd=$(mktemp -d)
if (
cd "$tmp_cwd" || exit 1
CI=1 npm_config_min_release_age=0 \
"$npm_bin" install --global \
--prefix "$HERMES_HOME/node" \
"npm@$range" \
--no-fund --no-audit --progress=false >/dev/null 2>&1
); then
rm -rf "$tmp_cwd"
_nb_ok "npm $("$npm_bin" --version 2>/dev/null) installed"
return 0
fi
rm -rf "$tmp_cwd"
_nb_warn "Could not upgrade bundled npm to $range — \`npm ci\` may fail with EBADENGINE."
_nb_warn "Fix manually: npm install -g --prefix \"$HERMES_HOME/node\" npm@\"$range\""
return 1
}
_nb_have_modern_node() {
command -v node >/dev/null 2>&1 || return 1
[ "$(_nb_node_major)" -ge "$HERMES_NODE_MIN_VERSION" ]
@@ -215,10 +302,16 @@ _nb_install_bundled_node() {
local _link_dir
_link_dir="$(_nb_get_link_dir)"
mkdir -p "$_link_dir"
ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node"
ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx"
# HERMES_NODE_SKIP_LINKS=1: the caller only wants the private managed tree
# (e.g. the EBADENGINE recovery provisioning a runtime alongside a working
# system Node). Skipping the links keeps the user's own node/npm first on
# PATH instead of shadowing them with ours.
if [ "${HERMES_NODE_SKIP_LINKS:-0}" != "1" ]; then
mkdir -p "$_link_dir"
ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node"
ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx"
fi
_nb_configure_npm_prefix
@@ -226,6 +319,9 @@ _nb_install_bundled_node() {
_nb_have_modern_node || return 1
_nb_ok "Node $(node --version) installed to $HERMES_HOME/node/"
# The tarball's bundled npm is usually below the repo's engines.npm floor.
# Best-effort: an old npm still beats no Node.
_nb_ensure_bundled_npm_range || true
return 0
}
@@ -249,6 +345,24 @@ _nb_managed_tool_broken() {
return 1
}
# The managed node runs but is below HERMES_NODE_TARGET_MAJOR — an old tree
# from a previous install (e.g. 22). Outdated heals the same way broken does,
# so existing users get upgraded on the next heal probe, not just on a full
# installer re-run. Mirrors _managed_node_tree_outdated() in
# hermes_constants.py.
_nb_managed_node_outdated() {
local probe ver major
for probe in "$HERMES_HOME/node/bin/node" "$HERMES_HOME/node/node"; do
[ -x "$probe" ] || continue
ver="$("$probe" --version 2>/dev/null)" || return 1
major="${ver#v}"; major="${major%%.*}"
case "$major" in ''|*[!0-9]*) return 1 ;; esac
[ "$major" -lt "$HERMES_NODE_TARGET_MAJOR" ] && return 0
return 1
done
return 1
}
_nb_managed_node_needs_heal() {
local tool
for tool in node npm npx; do
@@ -256,7 +370,7 @@ _nb_managed_node_needs_heal() {
return 0
fi
done
return 1
_nb_managed_node_outdated
}
# Redownload the pinned nodejs.org tarball when a managed tree exists but
@@ -294,6 +408,13 @@ ensure_node() {
if _nb_have_modern_node; then
_nb_ok "Node $(node --version) found (Hermes-managed)"
HERMES_NODE_AVAILABLE=true
# A tree from an older install still carries that Node major's
# bundled npm, and the upgrade in _nb_install_bundled_node is
# best-effort — one offline install leaves an at-target tree
# stranded below engines.npm forever, since heal only fires for a
# *broken* tree. Mirrors Update-ManagedNpm's reuse-path call in
# install.ps1. No-ops on a probe when the npm is already in range.
_nb_ensure_bundled_npm_range || true
return 0
fi
fi
+2
View File
@@ -75,8 +75,10 @@ LEGACY_AUTHOR_MAP = {
"embwl0x@users.noreply.github.com": "embwl0x", # PR #65105 salvage (gateway: preserve external supervisor ownership)
"41409874+2751738943@users.noreply.github.com": "2751738943", # PR #54785 salvage (tui: post-turn completion ownership routing)
"Burgunthy@users.noreply.github.com": "Burgunthy", # PR #20096 salvage (gateway: profile-based routing for inbound messages)
"BB-light@users.noreply.github.com": "BB-light", # PR #76015 salvage (caching: honor cache_ttl disable; #33555)
"75556242+webtecnica@users.noreply.github.com": "webtecnica", # PR #63360 salvage (nous: restore inference-api base_url)
"contato@webtecnica.com.br": "webtecnica", # PR #70888 salvage
"webtecnica@gmail.com": "webtecnica", # PR #75838 salvage (aux: free-only fallback guard; #75803)
"skosarevivan@yandex.ru": "Epoxidex", # PR #29820 salvage (ollama: top-level reasoning_effort=none; #25758)
"jdjiayou@163.com": "JiaDe-Wu", # PR #34742 salvage (bedrock: bearer routing + streaming fallback + image decode; #28156)
"changhyun.min@gmail.com": "minchang", # PR #42231 salvage (providers: add Upstage Solar)