Merge updated model metrics into tool metrics
Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user