With --no-checkout the file download moved out of the retried clone into the single unretried pin checkout, which ran after the empty clone was already published: a throttled pin left INSTALL_DIR with no index, and the next rerun stashed a 'delete every file' autostash. The pin now runs in the staged tree inside the deferred-checkout retry loop (which also stops materializing the tip first), and nothing is published until it succeeds.
The ladder's INT/TERM traps wrote the receipt and exited as soon as the
foreground stage returned, even when the stage's child caught Ctrl-C and
succeeded (setup's provider picker prints "Provider setup skipped." and
carries on). gateway and complete were skipped, the bootstrap marker was
never written and rc went 0 -> 130: a telemetry write changed installer
control flow.
install.sh:
- The ladder traps (INT, TERM and now HUP, a closed terminal) only note the
signal while a stage runs. rc==0 clears a Ctrl-C and the ladder carries on,
as bash's cooperative exit did on main; rc!=0 with a signal noted writes
`interrupted` and re-raises the signal. Outside a stage (platform check,
banner, between stages) the signal stops the run at once with a receipt.
The traps are installed with the EXIT trap, before check_platform.
- Inside a ladder stage, a signal marks STAGE_INTERRUPTED; stage_result
writes `interrupted` over the stage's own class, so pm.cli /
source_completion / `hermes setup` catching KeyboardInterrupt and exiting 1
no longer reads deps_install_failed / setup_failed. A child the signal
killed still ends the stage at once (status 128+N).
- A TERM/HUP sent to the installer alone takes effect when the running stage
returns (documented; the stage stays in the foreground because a
background stage's children ignore Ctrl-C under bash 3.2), and the receipt
names the stage the run stopped before, not a finished one.
- A stage killed outright (SIGKILL/OOM) now leaves a `failed/<stage>/other`
receipt; a failed write (full disk) removes its .tmp; the fallback id uses
$(( RANDOM*32768+RANDOM )) (no octal parse); no clock -> no receipt.
install.ps1:
- A native child that ended on Ctrl-C (130 or STATUS_CONTROL_C_EXIT) reads
`interrupted` over the stage's class; the receipt write removes its .tmp
on failure; the dead `other` class on the pre-ladder Initialize-ResolvedPaths
Fail is dropped (it runs before the ladder's try, so it never wrote one).
Tests (real install.sh main block with stub stage bodies, real SIGINT to the
process group): a handled Ctrl-C finishes the install with a success
receipt; a child that turns Ctrl-C into exit 1 reads interrupted. Both red
at 6246dd0f69d.
install.sh: a run id per ladder, first writer wins (a parent-only SIGTERM no longer
leaves two receipts); Ctrl-C / SIGTERM write failure_class=interrupted.
install.ps1: same once-only guard, finally{} records an interrupted run.
Reporter: latch a receipt id only after its row is saved, so a short-lived start
whose daemon reporter is cut off at exit re-reports on the next start.
install.sh / install.ps1 leave one closed-token receipt per full ladder run
(installer, outcome, failed_stage, failure_class, start/finish epoch) under
$HERMES_HOME/telemetry/shared_metrics/pending_installs. Every fail()/Fail call
site passes a closed failure class; the installer never sends anything.
The next Hermes start records each receipt once (latched by id, deleted after
the row is saved) only while collection is on; a start with collection off
purges the receipts unreported. Out-of-contract receipts are dropped.
A Desktop built before the blobless fix keeps walking history during
`hermes update`. On a treeless (tree:0) checkout every walk downloads trees
again, and #129514 filled a 434 GB disk during the minutes of dependency work.
The one-time conversion (#133016) ran at the very end of the update, after that
window. Both update hand-offs (update_completion._prepare and an older
updater's _update_takeover.prepare) now run it first, in the bootstrap
interpreter, and the late call is gone.
Re-running install.sh / install.ps1 over a treeless checkout now converts it
too. The filter is recorded only after the refetch succeeds, so a failure is
retried by the next update instead of leaving a blob:none filter over a
history that has no trees.
The pack cleanup also sweeps tmp_* transfer files that killed fetches left
behind. They are more than an hour old, a storm leaves gigabytes of them,
and git itself only prunes them after two weeks. The docs gain the recovery
steps for a disk that the storm already filled.
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: Yuan Li <dskwelmcy@163.com>
Installers have cloned with --filter=tree:0 since c3e9ac87a7. A treeless
clone keeps commits only, and git requests a missing tree without telling
the server which trees it already has, so every checkout and every
path-filtered history walk re-downloads complete directory snapshots
(~0.58 MB per commit for this repo). GitHub's own guidance is that
treeless clones are for throwaway CI builds, not long-lived checkouts;
the blobless layout keeps every commit and tree local and fetches only
file contents on demand.
- install.sh / install.ps1 clone --filter=blob:none (same release tags,
--commit pins and ancestor checks the treeless switch was made for).
- hermes update (post-update step) converts an existing tree:0 checkout once with
`git fetch --refetch --filter=blob:none origin` (~120 MB, ~8 s
from GitHub) and records the new filter only after it succeeds, so a
failed refetch retries next update.
- The shallow-heal conversion unshallows as blob:none instead of tree:0.
- checkout_contains asks git with GIT_NO_LAZY_FETCH: a recorded SHA the
checkout lacks is "not contained", never a download of that commit's
whole history.
The libatomic sudo warm-up checked only for a terminal, so
--non-interactive, -NonInteractive and --skip-setup still reached
`sudo -v </dev/tty` inside an SSH session or any PTY-allocating runner.
Gate it on NON_INTERACTIVE like setup and gateway. Reported by
@JoaoMarcos44 on #132664.
The clone and downloads can outlast sudo's timestamp_timeout, after which
PM's sudo -n repair silently falls back to the manual remedy. Refresh the
ticket non-interactively right before PM installs. Compute the bootstrap
target once in stage_prerequisites.
PM's libatomic repair runs `sudo -n` under its install lock, so on a host
whose sudo needs a password the automatic attempt can never succeed and
`curl | bash` still ends in "pm install failed". When the glibc host lacks
libatomic.so.1 and a terminal is attached, cache sudo credentials during
the prerequisites stage so the later non-interactive install goes through.
Root, passwordless sudo, musl, and headless runs are unchanged.
A login bash reads only the first of ~/.bash_profile, ~/.bash_login and
~/.profile. wire_shell_path always wrote ~/.profile and ~/.bash_profile
when present, so a user whose only login file is ~/.bash_login never saw
~/.local/bin on PATH. Treat ~/.bash_login like ~/.bash_profile: append the
guarded PATH line when the file exists, never create it (creating
~/.bash_profile would shadow it).
The test helper resolves bash by absolute path so the login-PATH tests run
on hosts without /bin/bash (NixOS).
#131679 set maintenance.auto=false in the treeless install checkout to stop the
commit-graph lazy-fetch loop (#127711). That also switched off the post-fetch
`gc --auto` that git <= 2.53 runs after every lazy fetch, so packs piled up
between updates and only folded on a manual `hermes update` / `--check`
(nothing runs `--check` automatically).
The loop needs a commit-graph write, and gc.writeCommitGraph=false +
fetch.writeCommitGraph=false already stop gc and fetch from writing one.
maintenance.commit-graph.enabled=false closes the last writer (a commit-graph
maintenance task, e.g. from a user's global maintenance.strategy) while
leaving automatic maintenance, and its gc fold, on.
Checkouts that already got maintenance.auto=false from the first cut are
converged: the next update/--check unsets exactly that value.
Measured on a real tree:0 clone with a Bloom commit-graph, 100 lazy fetches
after one updater run:
git 2.53.0: main 101 packs, this change 1 pack, 0 commit-graph writes
git 2.55.0: 101 / 101 (stock 2.55 maintenance does not fold), 0 writes
The two salvaged commits above disagree: #127721 persists gc.auto=0, which
turns the fold's `gc --auto` into a no-op (106 packs stay 106 on git 2.50.1,
2.53.0 and 2.55.0), and the fold itself is exposed to the loop #127721
guards against. Probed on all three gits against a blobless clone whose
commit-graph carries changed-path (Bloom) data:
- gc, `gc --auto`, `maintenance run --task=commit-graph` and
fetch.writeCommitGraph each lazy-fetch the trees of every commit the graph
has not seen (30 unseen commits: 30 fetches, 30 packs), and every lazy
fetch spawns `git maintenance` again. gc.writeCommitGraph=false stops it
(gc: 31 packs -> 1).
- Without a Bloom graph none of that happens, so the keys are a guard, not a
fix for every partial clone.
Changes:
- keys: maintenance.auto, gc.writeCommitGraph, fetch.writeCommitGraph; gc.auto
stays default. Idempotent (no rewrite when already set), `--local`, hidden
console on Windows. install.sh and install.ps1 set the same three keys.
- consolidate_lazy_fetch_packs applies the keys before the fold, so installs
that predate the installer change converge on their next update check.
- the fold runs under bounded_probe_run: subprocess.run(timeout=) kills only
`git gc` and leaves `pack-objects` running (reproduced), and with
GIT_NO_LAZY_FETCH.
- dropped the post-gc mark_unmarked_packs_promisor: gc keeps the .promisor
marker on 2.50.1, 2.53.0 and 2.55.0.
- docs: updating.md troubleshooting entry.
- _deepen_shallow_repo marks packs in its finally without a git config probe
that could itself raise and turn a finished unshallow into False; markers
are inert in a clone without a promisor remote.
- Installers warn instead of aborting the stage when a marker cannot be
written, matching the Python helper.
- Docs: the manual commands follow HERMES_HOME, and the missing-object
backfill feeds IDs through `fetch --stdin` so a large list cannot exceed
the Windows command-line limit.
- Test and docstring comments describe the contract, not the incident.
An install whose updater dies on the git 2.53+ pack-objects BUG (#124272)
never fetches the release that heals it: the fetch runs in the installed
code. The installer rerun (install.sh / install.ps1, served fresh) is the
path that runs new code, so when origin is a promisor remote it now marks
the checkout's unmarked packs before its existing-checkout fetch. Marking is
idempotent and never rewrites objects, so it runs up front rather than as a
retry that would first print a failed-fetch report.
The default browser_exec tool ran the `browser-use` CLI from a PM side
environment (browser-use==0.13.10 in <home>/environments/browser-use),
provisioned by the installers and `hermes update`. Sealed Desktop payloads
skip that step, so the Desktop app never had it and silently fell back to
the built-in tools; the side env was also per-profile and 225 MB.
The CLI's execution path is only `browser_harness.run.main()`; the
browser-use agent framework (anthropic/openai/google-api pins, 93 MB of
googleapiclient) is never imported. browser-harness itself is 2.6 MB of
pure Python whose pins (Pillow 12.3.0, websockets 15.0.1) already match
Hermes's own, so it becomes a core dependency and runs on sys.executable:
- pyproject/uv.lock: browser-harness==0.1.13 (+ cdp-use, fetch-use).
- _find_cli() returns [sys.executable, -m, browser_harness.run]; the child
env points PYTHONPATH at the harness site dir (the Desktop store
interpreter boots without a venv and the harness daemon re-runs
sys.executable), replacing whatever the agent inherited.
- The side-env provisioning (install_cli, the update/installer step) goes.
Maintainer ruling: only Hermes and only its packaged package managers
(uv/pip/node/npm) are ever used; no PATH fallback when the managed tool is
missing, no "prefer the user's if new enough".
- hermes_constants.find_node_executable: node/npm/npx resolve to PM's
installed copy or None. Every caller already pm.ensure()s on None, so a
missing runtime is now provisioned instead of silently borrowing the
user's Node (native-addon ABI / npm cache mismatches).
- agent/lsp/install._install_npm: pm.ensure('npm') when PM npm is absent,
instead of failing over to whatever npm is on PATH.
- gateway._append_node_dir_for_service: stop baking the invoker's PATH node
dir into generated systemd/launchd units.
- main_install_repair._resolve_node_runtime_npm: drop the PATH re-scan for
another npm.
- source_build.source_product_current: run the freshness reader only with
PM's node.
- doctor: Node/npm rows and npm audit use PM's copies (Termux APT distro
keeps its system Node).
- install.sh ensure_uv / install.ps1 Get-Uv: always stage the pinned uv
artifact; delete the "uv on PATH if new enough" developer shortcut.
Computer use and browser use are meant to work out of the box. The PM rewrite
(3d12e86ef1) and the MSIX installer rework (47f4ab3a17) dropped the
install-time cua-driver fetch (7060ac7bed) and the Browser Use CLI install
(baa6b2e34d). On a fresh install the computer_use check_fn therefore stayed
False, so the tool never reached the model and its lazy ensure could not fire,
and browser_exec quietly fell back to the built-in tools.
- cua-driver is a default PM package, so the installers, a bare
`hermes pm install` and `hermes update` carry it on every target it builds
for. Adds the missing Android gap (the lock has no bionic artifact).
- The shared default-tool step, which the installers (via source completion)
and `hermes update` both run, provisions the Browser Use CLI for the default
and explicit Browser Use backends. `--skip-browser` declines it along with
agent-browser, and `off`/Camofox never use it.
- Installers regain --skip-computer-use / -SkipComputerUse (recorded as
`--without cua-driver`).
- The update message stops calling every default "browser tools".
The installer's re-run path fetched `origin <branch>` by name, then checked
the branch out and fast-forwarded to origin/<branch>. On a checkout an older
installer made with `--depth 1 --single-branch --branch <tag>`, the remote's
only refspec maps the tag: the fetch wrote FETCH_HEAD but no origin/<branch>,
and `git checkout main` failed with "pathspec 'main' did not match".
Fetch by explicit refspec (as `hermes update` now does), and when there is no
local branch yet create it at the fetched tip — checkout's own branch guess
ignores remote refs the configured refspec does not map. The remote's fetch
config is left as the user has it. Same change in install.sh and install.ps1.
PM's musl Node is the unofficial-builds musl archive, which links the
system libstdc++. On a stock Alpine (bash, git, curl) node and npm then
fail staged verification with raw relocation errors after the clone and
downloads. Check in the prerequisites stage and name the package.
install.sh consulted ldd before the ELF interpreter while pm/store.py's
_is_musl_libc reads the native userland's ELF interpreter first, so a
glibc ldd (secondary toolchain, gcompat) could make the bootstrap stage
a different libc than PM later resolves. Read /bin/sh (then /bin/ls)
PT_INTERP first in both; ldd and the loader glob are fallbacks only.
Fall back to the musl loader if ldd cannot identify libc, while honoring
an explicit GNU libc report on hosts with a secondary musl toolchain.
Cover both paths against the pinned uv URL and digest.
Refs: #123682
Skip glibc-linked FFmpeg on musl in both default install and update roots.
Select musl uv during the standalone shell bootstrap, and let the native
userland resolve libc before bootstrap Python build metadata.
Add regression checks for the closure, target precedence, and installer pins.
wire_shell_path's existing-setup regex required a character before PATH=, so it missed bare assignments such as Fedora's ~/.bashrc ( PATH="$HOME/.local/bin:$HOME/bin:$PATH") and Debian's ~/.profile. The installer then appended its own line to .bashrc, .profile and .bash_profile, and because Fedora's .bash_profile sources .bashrc, login shells got ~/.local/bin on PATH several times.
Match bare assignments too, and make the appended line a no-op when PATH already contains ~/.local/bin.
electron-builder names the unpacked output <os>-unpacked on x64 and
<os>-<arch>-unpacked elsewhere (linux-arm64-unpacked, win-arm64-unpacked).
install.sh desktop_product_present and install.ps1
Test-DesktopProductPresent only listed the x64 names, so a rerun on an
ARM64 desktop install skipped the desktop rebuild and left a bundle built
from the previous code. List every unpacked dir main_desktop.py already
resolves, in both installers.
The setup stage installs the gateway service through
ensure_gateway_service. On Windows that asks the start-now, Scheduled
Task and UAC questions. The gateway stage then ran `hermes gateway
install`, which asked them all again.
`gateway install --if-missing` does nothing when a service is already
installed. Both installers' gateway stages use it, so they ask only
when setup did not install the service.
The flag used to exit 1 as retired. Now that PM installs the browser tools
by default, it maps to `pm.cli install --without agent-browser`, which later
installs and `hermes update` honour.
The PM-clean installers dropped the old "restart your terminal" /
"source ~/.bashrc" closing line, so a fresh install ended on "Run: hermes"
in a shell that could not find it.
install.ps1 now also prepends the bin dir to $env:Path. That variable is
process-wide, so under the documented `irm | iex` path (and `& .\install.ps1`)
the caller's own window resolves `hermes` immediately. When run as a script
file whose inherited PATH lacked the bin dir (e.g. `powershell -File`, a
child that cannot touch its parent), the ladder ends with one arrow line:
restart the terminal, or reload $env:Path from the User and Machine values.
Setup/gateway already launch hermes through the resolved runtime command.
install.sh prints the equivalent "open a new terminal, or run: source <rc>"
line after the ladder when the inherited PATH lacks ~/.local/bin; the
installer is always a child and cannot change its parent's PATH.
The pm-era installer printed "[hermes]" lines between raw git, uv and pm
output. Restore the pre-pm installer's look (→ ✓ ⚠ ✗, banner on the full
ladder) and route every child command through run_logged: on a terminal it
shows one status line rewritten with the command's newest output line
(git clone phases via --progress), appends everything to
$HERMES_HOME/logs/install.log, and on failure prints the last 20 lines plus
the log path. CI, --verbose, HERMES_INSTALL_VERBOSE and a non-terminal
stdout (the Hermes-Setup --json driver, E2E transcripts) keep the full
stream, so their parsers see what they saw before. Errors stay on stderr.
Re-running install.sh / install.ps1 over an existing checkout (desktop
bootstrap and its update retry do this) falls back to
`reset --hard origin/<branch>` when a fast-forward fails, with no anchor for
the commits it drops. Park HEAD under refs/hermes-update-backups/, the same
namespace `hermes update` writes and prunes, and print the ref.
A --depth 1 clone hides the release tag runtime identity is derived from
and cannot resolve a non-tip --commit pin or the ancestor guard; a full
clone downloads every tree and blob ever committed. --filter=tree:0 keeps
the whole commit graph and its tags and fetches trees on demand: 125M vs
77M for --depth 1 against GitHub, identity exact. The deferred-checkout
fallback uses the same filter.
install.ps1 already kept uv's bootstrap Python out of the Windows
registry, but setup-hermes.ps1 did not, so every Windows dev checkout
registered that interpreter under HKCU. The flag is Windows-only; the
POSIX installers take it too so every bootstrap issues the same command.
Review findings against the pm-clean installers, each reproduced first:
- install.sh: `curl | bash` aborted before main under `set -u` (empty
BASH_SOURCE). The entry guard falls back to $0.
- install.sh: setup/gateway read stdin, which under `curl | bash` is the
script itself. They open /dev/tty when a terminal can be opened, and
otherwise skip with guidance.
- Both: any uv on PATH was trusted. uv 0.6.17 has no `python install
--no-bin`. A PATH uv now has to run and be at least the pinned version,
otherwise the pin is staged.
- install.sh: the staged uv went under ~/.hermes/tools even with a custom
--hermes-home. It now goes to pm's store_root() default,
$HERMES_HOME/tools.
- Both: when a stash failed, the script logged "overwritten below" and ran
`reset --hard` anyway. Local work is now parked before checkout, and a
stash failure stops the install.
- Both: reruns ignored an explicit HERMES_REPO_URL. It now repoints origin.
- Both: --commit had no ancestor guard. The pin must be on the installed
branch.
- install.sh: the blobless fallback was `--depth 1 --single-branch`, so a
non-tip --commit could not check out. It now keeps full history with
blobs fetched on demand.
- Both: ported main's recovery for a commit-less .git (moved aside, #40998)
and for an unmerged index (reset -q before the stash, #4735). Stashing
before checkout makes both reachable.
- install.ps1: on Windows PowerShell 5.1, `native 2>$null` / `2>&1` under
Stop turns stderr into a terminating NativeCommandError, verified on a
Win11 host. Every native call now goes through Invoke-Native, which
relaxes the preference only for that call.
- install.ps1: clone publishes from a staging dir, with retries and a
blobless fallback, and refuses a non-empty destination (mirrors
install.sh). UV_NO_CONFIG and `--no-registry` are restored. pwsh 7
HttpRequestException falls back to the mirror, except TLS trust failures.
tar resolves from System32. A literal CR/LF in the desktop failure
message is removed.
Deletes six tests that regex-extracted main's legacy install.sh functions
(node, browsers, PATH block, lockfile churn; pm runs `npm ci` whenever a
lockfile exists). The two behaviours still relevant are covered by new
behavioural tests.
check_platform accepted Termux as plain Linux, so `curl | bash` on a
phone walked the source-install ladder: a glibc uv, a lock whose CPython
is the bundled bionic build with no Android wheels, and on-device sdist
builds. The signed APT package is the only supported Termux shape, so
detect Termux the way the runtime does (TERMUX_VERSION or the com.termux
PREFIX) and stop before any stage with `pkg install hermes-agent` and
the setup docs URL. --json surfaces the reason in the stage frame.
The clone publication step did `mv <staged>/tree "$INSTALL_DIR"`. When
INSTALL_DIR already existed as a non-git directory, mv moved the
checkout INSIDE it as INSTALL_DIR/tree and the stage reported success,
leaving later stages to read pm/lock.json from a directory that holds
the user's files instead of a checkout. An existing file made mv fail
with a generic "cannot publish" error.
Before staging the clone, refuse a destination that exists and is not a
Hermes checkout (non-empty dir, file, or symlink) and say what to do; an
empty directory is taken over so the checkout lands AT the path.
- check_no_tmp_literals: resolve scratch via tempfile/os.tmpdir; the termux
container mount point is one marked variable per script
- ruff TID251: desktop E2E fixtures may reach PM internals like tests do;
the pm.runtime_stage ban message no longer names a module that never existed
- auth_codex: build the capped httpx stream subclass on first use so importing
hermes_cli.auth_codex no longer forces httpx (the lazy proxy in auth_constants
was defeated by a module-scope base class; broke lanes without httpx)
- desktop-smoke: launchApp is a parameter; the bundle-env test substitutes a
refusing launcher instead of letting Playwright spawn a dying binary
(3 unhandled rejections failed the tests-js lane)
Hermes now routes scratch space through HERMES_HOME/cache/scratch (exported as
TMPDIR), so every production path that still spelled out /tmp bypassed that and
kept teaching the agent the habit. Fallbacks in tool_result_storage,
code_execution_tool, process_registry, the ACP child HOME, mini_swe_runner's
local cwd, and the CI/profiling scripts now use tempfile.gettempdir(); shell
installers fall back to $TMPDIR (then HERMES_HOME) when mktemp is missing, and
repro/eval shells use `mktemp -d -t`. User-facing help text and sample payloads
(hermes send, approvals test, hooks test, voice-mode WSL hints, meet_bot debug
line) no longer suggest /tmp.
Container-side paths (mini_swe_runner docker cwd, sandbox base env, remote
sync tarballs) keep the literal because they name the sandbox filesystem,
not the host.
`stage_repository` treated a failed `pull --ff-only` as "keep local state"
and carried on. Every later stage reads files only the new tree has (`pm/`),
so an install whose checkout could not fast-forward failed further down with
`ModuleNotFoundError: No module named 'pm'` instead of updating.
A release cut off the main line diverges for every user who installed from it:
v2026.5.29.2 is not an ancestor of main, so its checkout can never fast-forward
to a later main. Match the remote the way `hermes update` already does, after
parking the old tip behind refs/hermes-install-backup/<stamp>-<sha> and
stashing any local work so neither is destroyed silently.
Verified against a real diverged repository: before, the checkout stayed on
the old line (HEAD=6a2e091, remote=43eea63) with no backup; after, HEAD equals
the new main, the backup ref and an autostash exist, and both are named in the
installer's output.
installer-script+desktop -> installer-script failed as "desktop output is
missing, stale, or damaged": the leg installs with the desktop, then re-runs the
plain one-liner, which built only tui/web -- while the driver's verifier still
expects the desktop product it installed (EXPECT_DESKTOP comes from the install
method). The artifacts live inside the tree, so an update makes them stale rather
than absent, and a desktop build left over from the previous code is exactly what
the freshness receipt rejects.
The products stage now selects the desktop when --include-desktop/-IncludeDesktop
is given OR the checkout already carries a built app. Verified: install.sh syntax
clean and the new predicate returns absent/present against real temp trees;
install.ps1 parses clean.
The installer ladder stopped at node-deps/path/desktop with its own
semantics while an update ran launchers, product builds and post-build
maintenance, so a fresh install and a finished update ended in different
states: after re-running the installer at HEAD the products had no receipts
and the read-only source acceptance failed.
hermes_cli/source_completion.py now owns that tail -- publish launchers,
build the products, run the maintenance -- and update_completion's
_complete_selected calls it, so there is one implementation. install.sh and
install.ps1 keep the bootstrap stages (prerequisites, repository, venv,
python-deps, config) and hand off to it in a single `products` stage;
--include-desktop selects the desktop product inside that stage instead of
adding a second build stage, and `desktop` stays dispatchable via --stage for
external callers.
Windows keeps its installer-owned PATH publication (expose_cli answers
"windows-installer-owned" on Windows) plus the packaged-artifact probe, ACL
grant and shortcuts. The desktop stage no longer pre-syncs wake/voice: pm
lazy-installs them at first use, as the update path does.
The first cut derived the package from `binding-${platform}-${arch}` with an
exact-suffix match, which never matches Windows (`-msvc`) or Linux
(`-gnu`/`-musl`) names, so the repair only ever worked on macOS and
install.sh had to gate it there. Rolldown's own loader already resolves
platform, arch and libc and prints the exact `@rolldown/binding-*` it wanted
in its error chain; parse that instead and drop the gate. Also spawn npm
through a shell on Windows (Node refuses to spawn npm.cmd directly) and trim
the tests to the two invariants (no-op when it loads; installs exactly what
the loader asked for, then re-probes).
Path.glob raises NotImplementedError for a non-relative pattern, which a string `workspaces` (iterated char by char, so "/") or an absolute entry produces. The (OSError, ValueError, TypeError) catch missed it, so the error escaped to the caller's suppress(Exception) and no lock was reverted at all -- back to autostash every run. Non-list values are now ignored and each pattern is tried on its own so a bad one just owns nothing.
install.sh: read the workspace globs with `while read` instead of an unquoted $(...) so they are never pathname-expanded against the caller's CWD before `case` sees the pattern.