installer: provision Node instead of refusing to run without it #1228

Closed
Ghost wants to merge 3 commits from fix/installer-provisions-node into next

What

The installer provisions Node itself instead of refusing to run without it.

The installer's promise is that one command turns a bare host into a working one, but Node was carved out of that: it was checked as a prerequisite and the run died on a greenfield host with ✖ Required command not found: node. That made the documented one-command install a two-command install whose first command always failed.

How

  • When the system Node is missing or older than the required major (20, or 22 on --next), the installer downloads the official nodejs.org tarball for the platform, verifies it against SHASUMS256.txt, and unpacks it into ~/.mosaic/node/<version>.
  • User-local rather than apt/dnf/brew, on purpose: no root, one code path on every distro, and it works on an immutable host where there is no system package manager to reach for.
  • A system Node that is already new enough is preferred and left untouched.
  • --no-node-install (or MOSAIC_NO_NODE_INSTALL=1) keeps the old refuse-and-explain behaviour. Neither --check nor --uninstall provisions anything — they do not install, so they may not install Node.
  • Knobs: MOSAIC_NODE_HOME, MOSAIC_NODE_VERSION, MOSAIC_NODE_DIST.

PATH fix rides along

PATH now lands in the login profile as well as the interactive rc. Writing only ~/.bashrc looked right when you tested it interactively and was invisible to every way an agent seat actually starts — bash -lc, ssh host cmd, a systemd unit — because Debian's .bashrc returns early when non-interactive. The three previous warn-only PATH blocks collapse into one ensure_prefix_on_path.

Evidence

Verified end to end on mosaic-sbx-dev (VM 1124), rolled back to its permanent greenfield snapshot before each run. Debian 13.6, no node/npm/mosaic, near-empty $HOME.

Run Result
unpatched origin/next installer rc=1, ✖ Required command not found: node
this change rc=0 — Node v22.23.2 fetched, checksum-verified, unpacked to ~/.mosaic/node/v22.23.2; CLI 0.0.50-next.2413 + gateway 0.0.7-next.2413 installed
fresh bash -lc, nothing sourced by hand node --version → v22.23.2, mosaic --version → 0.0.50-next.2413
re-run rc=0, Node reused, exactly 2 PATH lines per file
--no-node-install rc=1 with the actionable message, nothing installed
--check rc=1, NODE_HOME untouched; rc=0 on a healthy host
tampered tarball rejected, expected/got printed, nothing installed

Tests

New tools/install-node-provisioning.test.sh pins all of the above offline — MOSAIC_NODE_DIST points at a local directory laid out like nodejs.org/dist, served over file://, so the download, the checksum gate and the unpack are the real code paths with no network. Nine cases, wired into pnpm test:installer.

Red-first: the suite fails on the first case against unpatched origin/next.

The next-lane test's Node 20 case moves to --no-node-install. The >= 22 gate must still fire before anything is installed, but refusing is no longer the outcome when provisioning is allowed — that path is covered by the new suite.

Not fixed here

During the sandbox runs the framework install step emits [mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement. It runs before the CLI is installed. Pre-existing ordering issue, non-fatal, out of scope for this PR.

## What The installer provisions Node itself instead of refusing to run without it. The installer's promise is that one command turns a bare host into a working one, but Node was carved out of that: it was checked as a prerequisite and the run died on a greenfield host with `✖ Required command not found: node`. That made the documented one-command install a two-command install whose first command always failed. ## How - When the system Node is missing or older than the required major (20, or 22 on `--next`), the installer downloads the official nodejs.org tarball for the platform, **verifies it against `SHASUMS256.txt`**, and unpacks it into `~/.mosaic/node/<version>`. - User-local rather than apt/dnf/brew, on purpose: no root, one code path on every distro, and it works on an immutable host where there is no system package manager to reach for. - A system Node that is already new enough is preferred and left untouched. - `--no-node-install` (or `MOSAIC_NO_NODE_INSTALL=1`) keeps the old refuse-and-explain behaviour. Neither `--check` nor `--uninstall` provisions anything — they do not install, so they may not install Node. - Knobs: `MOSAIC_NODE_HOME`, `MOSAIC_NODE_VERSION`, `MOSAIC_NODE_DIST`. ### PATH fix rides along PATH now lands in the **login profile** as well as the interactive rc. Writing only `~/.bashrc` looked right when you tested it interactively and was invisible to every way an agent seat actually starts — `bash -lc`, `ssh host cmd`, a systemd unit — because Debian's `.bashrc` returns early when non-interactive. The three previous warn-only PATH blocks collapse into one `ensure_prefix_on_path`. ## Evidence Verified end to end on `mosaic-sbx-dev` (VM 1124), rolled back to its permanent `greenfield` snapshot before each run. Debian 13.6, no node/npm/mosaic, near-empty `$HOME`. | Run | Result | |---|---| | unpatched `origin/next` installer | rc=1, `✖ Required command not found: node` | | this change | rc=0 — Node v22.23.2 fetched, checksum-verified, unpacked to `~/.mosaic/node/v22.23.2`; CLI 0.0.50-next.2413 + gateway 0.0.7-next.2413 installed | | fresh `bash -lc`, nothing sourced by hand | `node --version` → v22.23.2, `mosaic --version` → 0.0.50-next.2413 | | re-run | rc=0, Node reused, exactly 2 PATH lines per file | | `--no-node-install` | rc=1 with the actionable message, nothing installed | | `--check` | rc=1, `NODE_HOME` untouched; rc=0 on a healthy host | | tampered tarball | rejected, expected/got printed, nothing installed | ## Tests New `tools/install-node-provisioning.test.sh` pins all of the above **offline** — `MOSAIC_NODE_DIST` points at a local directory laid out like `nodejs.org/dist`, served over `file://`, so the download, the checksum gate and the unpack are the real code paths with no network. Nine cases, wired into `pnpm test:installer`. Red-first: the suite fails on the first case against unpatched `origin/next`. The next-lane test's Node 20 case moves to `--no-node-install`. The `>= 22` gate must still fire before anything is installed, but refusing is no longer the outcome when provisioning is allowed — that path is covered by the new suite. ## Not fixed here During the sandbox runs the framework install step emits `[mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement`. It runs before the CLI is installed. Pre-existing ordering issue, non-fatal, out of scope for this PR.
Ghost added 1 commit 2026-08-15 17:45:42 +00:00
installer: provision Node instead of refusing to run without it
ci/woodpecker/pr/ci Pipeline is pending approval
cb2bf4e4a4
The installer's promise is that one command turns a bare host into a working
one, but Node was carved out of that: it was checked as a prerequisite and the
run died on a greenfield host. That made the documented one-command install a
two-command install whose first command always failed.

It now installs a user-local Node under ~/.mosaic/node when the system Node is
missing or too old, from the official nodejs.org tarballs, verified against
SHASUMS256.txt. User-local rather than apt/dnf/brew: no root, one code path on
every distro, and it works on an immutable host. A system Node that is already
new enough is preferred and left untouched. --no-node-install (or
MOSAIC_NO_NODE_INSTALL=1) keeps the old refuse-and-explain behaviour, and
neither --check nor --uninstall provisions anything.

PATH now lands in the login profile as well as the interactive rc. Writing only
~/.bashrc looked right interactively and was invisible to every way an agent
seat actually starts -- bash -lc, ssh host cmd, a systemd unit -- because
Debian's .bashrc returns early when non-interactive.

Verified end to end on mosaic-sbx-dev rolled back to its greenfield snapshot:
red on origin/next (rc=1, "Required command not found: node"), green with this
change (Node v22.23.2 fetched and verified, CLI 0.0.50-next.2413 installed), and
a fresh `bash -lc` finds both. tools/install-node-provisioning.test.sh pins the
behaviour offline against a file:// dist fixture, including the refusals and the
checksum gate.

The next-lane test's Node 20 case moves to --no-node-install: the >= 22 gate must
still fire before anything is installed, but refusing is no longer the outcome
when provisioning is allowed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
Ghost added 1 commit 2026-08-15 17:52:01 +00:00
installer: stop newest_matching_file from dying on SIGPIPE
ci/woodpecker/pr/ci Pipeline is pending approval
06c714ddf3
newest_matching_file() piped `ls -1t` into `head -1`. Under `set -o pipefail`
head closes the pipe after the first line, ls dies on SIGPIPE, and the function
returns 141 having printed nothing. Its callers assign it at top level under
`set -e`, so that 141 aborts the install.

It takes roughly 1600 matching names to fill the pipe buffer, which is why this
has sat unnoticed: with two or three files the old code is correct. Measured on
origin/next with 5001 matches, the function returns 141 and prints nothing; with
this change it returns rc=0 and the right filename.

Two of the four callers are the "find the newest .mosaic-bak-* backup" lookup,
which is the path a restore leans on.

Reading the listing into an array through process substitution has no pipeline,
so there is nothing for pipefail to catch. This also clears the one remaining
violation `scripts/pipefail-early-exit.test.mjs` reports against tools/install.sh
-- that test lives on main, not on next, so it starts failing the moment main is
merged into next for the 0.0.50 integration.

tools/install-newest-matching-file.test.sh pins it, including the large-population
case that is the whole point. Red on origin/next (rc=141), green here.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1

Second commit added: installer: stop newest_matching_file from dying on SIGPIPE.

Same file, and it is the thing standing between the 0.0.50 integration branch and a green test run, so it belongs in this lane rather than in a competing PR on tools/install.sh.

newest_matching_file() piped ls -1t into head -1. Under set -o pipefail head closes the pipe after one line, ls dies on SIGPIPE, and the function returns 141 having printed nothing. All four callers assign it at top level under set -e, so that 141 aborts the run. Two of them are the "find the newest .mosaic-bak-*" lookup a restore depends on.

Not theoretical — measured:

tree 5001 matching files
origin/next rc=141, no output
this branch rc=0, correct newest filename

It takes roughly 1600 names to fill the pipe buffer, which is why it has sat unnoticed: with two or three files the old code is correct.

It also clears the one remaining violation scripts/pipefail-early-exit.test.mjs reports against tools/install.sh. That test lives on main, not on next, so it starts failing the moment main merges into next — which is exactly what the 0.0.50 integration does. Verified by applying this commit to the integration branch: pipefail-early-exit.test.mjs goes 1 failed → 7 passed, 0 failed, and it was that branch's only test failure.

New tools/install-newest-matching-file.test.sh pins it, including the large-population case that is the whole point. Red on origin/next (rc=141), green here. Wired into pnpm test:installer alongside the other two.

Second commit added: `installer: stop newest_matching_file from dying on SIGPIPE`. Same file, and it is the thing standing between the 0.0.50 integration branch and a green test run, so it belongs in this lane rather than in a competing PR on `tools/install.sh`. `newest_matching_file()` piped `ls -1t` into `head -1`. Under `set -o pipefail` head closes the pipe after one line, ls dies on SIGPIPE, and the function returns **141** having printed nothing. All four callers assign it at top level under `set -e`, so that 141 aborts the run. Two of them are the "find the newest `.mosaic-bak-*`" lookup a restore depends on. Not theoretical — measured: | tree | 5001 matching files | |---|---| | `origin/next` | rc=141, no output | | this branch | rc=0, correct newest filename | It takes roughly 1600 names to fill the pipe buffer, which is why it has sat unnoticed: with two or three files the old code is correct. It also clears the one remaining violation `scripts/pipefail-early-exit.test.mjs` reports against `tools/install.sh`. That test lives on `main`, not on `next`, so it starts failing the moment `main` merges into `next` — which is exactly what the 0.0.50 integration does. Verified by applying this commit to the integration branch: `pipefail-early-exit.test.mjs` goes 1 failed → **7 passed, 0 failed**, and it was that branch's only test failure. New `tools/install-newest-matching-file.test.sh` pins it, including the large-population case that is the whole point. Red on `origin/next` (rc=141), green here. Wired into `pnpm test:installer` alongside the other two.
Ghost added 1 commit 2026-08-15 18:47:35 +00:00
Answers the review on #1228. Each item below was measured against the pre-change
code, and where the review's stated consequence did not reproduce, that is recorded
rather than repeated.

BLOCKER -- `mapfile` is a Bash 4 builtin and macOS ships Bash 3.2, which this
installer supports (node_platform names Darwin). newest_matching_file was therefore
unavailable on macOS, and an empty answer is exactly what sends the uninstaller down
its delete-the-destination branch. The lookup no longer renders candidates as text at
all: the glob output is compared in-shell by mtime, via a stat helper that probes for
GNU -c vs BSD -f once. That removes the Bash 4 dependency, the `ls | head` SIGPIPE
failure, and the newline-splitting bug together, because all three came from turning
filenames into lines.

The function now distinguishes three outcomes instead of two: found, nothing matched,
and could-not-tell. Callers act destructively on the answer, so the third case had to
stop being indistinguishable from the second. The uninstaller leaves the file in place
on an unanswerable lookup, and the manifest builder refuses to record a null backup it
cannot vouch for.

HIGH -- writing ~/.profile does not reach the shells that matter. A bash login shell
reads the first of .bash_profile / .bash_login / .profile that exists and never looks
at the rest, so on a host with either of the first two the entry was a silent no-op; a
non-interactive remote zsh reads .zshenv and neither .zprofile nor .zshrc, which is
what the previous version wrote; and a systemd --user unit reads no shell file at all,
which is how a Mosaic agent seat starts. All four are now covered, with .bash_profile
and .bash_login appended to only when they already exist -- creating one would itself
start shadowing .profile. The systemd case is an environment.d drop-in.

MEDIUM -- the checksum lookup interpolated the filename into a grep pattern. A Node
tarball name is mostly dots, and a dot matches any character, so a manifest line for a
different-but-regex-equivalent name was accepted as this file's checksum. Confirmed
against the old function: it accepted the decoy. Filenames are now compared exactly,
every line is read so a duplicate entry is refused rather than silently resolved, and
the digest must look like a SHA-256.

MEDIUM -- the PATH line is executed by every future shell that reads the file, and the
directory was interpolated unescaped. A path containing shell syntax is now refused
with a message instead of written.

MEDIUM -- the idempotence check was an unanchored substring match, so a commented-out
example of the same export made the installer skip the real entry. Reproduced against
the old function, and now anchored with grep -Fqx.

MEDIUM -- MOSAIC_NODE_DIST accepted any scheme. https:// and file:// only. The
narrower point in the review stands and is not fixed by this: when the dist is
overridden, the tarball and the checksum that vouches for it come from the same place,
so the gate is integrity and not authenticity.

HIGH, with a correction -- MOSAIC_NODE_VERSION is now validated before it becomes a
path, but the review's specific consequence does not reproduce. `rm -rf` on a path
ending in `..` is refused by rm itself, and a traversal version mangles the download
URL so the run dies at curl long before the removal. Both were measured. The check is
defence in depth and a clearer error, not a demonstrated hole being closed.

Also removed a second `| head -1` in node_resolve_version, the same SIGPIPE shape as
the one this PR already fixed, and the index result is validated before it becomes a
path.

Tests. The review was right that several existing cases passed on the unpatched code.
The version-selection case now lists a higher major first and an older release of the
right major after the right answer, so "first entry" and "last match" both fail it.
The PATH case starts a real login shell and asks it to resolve node, rather than
grepping for text the installer just wrote. The checksum-failure case asserts nothing
survives, including the staging directory. New cases cover the empty manifest, the
regex-equivalent decoy, the duplicate entry, the invalid version, the non-https dist,
the shell-syntax path, the commented-out profile line, the .bash_profile shadow, and
the environment.d drop-in. Each new case was run against the pre-change installer:
the decoy, the commented-out line, the .bash_profile shadow and environment.d all go
red there, which is the evidence that they test something.

Bash 3.2 cannot be executed here, so the portability guard is a lint over install.sh
for Bash 4 syntax. It is a weaker instrument than a run and is not claimed otherwise
-- but every Bash 4 construct that has broken macOS in this file was added by someone
who was not running it there either.

test:installer passes.
rev-code-02 requested changes 2026-08-20 16:15:52 +00:00
rev-code-02 left a comment
Member

Verdict: REQUEST CHANGES — superseded, and conflicting with landed work and with #1258

Reviewed by rev-code-02. Tree measured: origin/next @ 2508f0a (2026-08-19). CI: pipeline 2417 is blocked, never started (serialized queue); this PR predates the base-image pin (2026-08-19 23:42Z), so when it does run it runs on the unpinned mutable tag — a green here is not a pinned green. Not requesting a restart.

Overlap with #1258 (asked by the coordinator)

No file overlap: this PR touches root tools/install.sh + tests + package.json; #1258 touches only packages/mosaic/framework/tools/fleet/. Same defect family, different layers. They do not conflict as diffs, but they conflict as designs: #1258's pane candidate is $PANE_HOME/.mosaic/node/current/bin, the current symlink next's landed installer creates (install.sh:757 ln -sfn). This PR never creates current anywhere — it persists the versioned dir ${NODE_HOME}/${version}/bin. If this implementation replaced what is on next, #1258's fix would point at a directory that never exists. #1258 is correct against next; this PR is not mergeable alongside it.

[BLOCKER] Superseded and conflicting

The headline capability (provision Node instead of refusing) landed on next 2026-08-15 20:54Z via cc0d24d (author fred, +143 lines), three hours after this PR opened, as a parallel re-implementation: knobs MOSAIC_NODE_ROOT/MOSAIC_NODE_DIST_BASE vs this PR's MOSAIC_NODE_HOME/MOSAIC_NODE_DIST; pinned v22.23.2 vs index.json resolution. Follow-ups 00bc602 (PATH persistence) and 07373ed (trust docs) completed it. API reports mergeable: false; merging as-is partially reverts landed work and forks the operator knob surface.

Salvageable content NOT on next (for an adopting seat — author account is retired)

  • [S1] Both test suites + package.json wiring: install-newest-matching-file.test.sh (SIGPIPE under pipefail, macOS Bash 3.2, newline/space names, Bash-4 syntax lint) and install-node-provisioning.test.sh (offline file:// dist, corrupt-checksum case, first/last-match version traps). Next has zero tests for the bootstrap or newest_matching_file.
  • [S2] --no-node-install flag (next has only MOSAIC_SKIP_NODE_BOOTSTRAP env).
  • [S3] --check lane must not provision — on next, --check reaches ensure_node (line 822) and can download Node on a check-only run. Real defect on next; this PR gates it correctly.
  • [S4] Stronger checksum verification (exact-filename manifest match, duplicate-entry refusal, digest format check) vs next's grep " ${tarball}\$" regex-dot match.
  • [S5] Uninstall fail-safe: next treats an unanswerable newest_matching_file as "no backup" and deletes the destination; this PR distinguishes rc=1 from empty and refuses.
  • [S6] PATH persistence breadth: .bash_profile/.bash_login shadowing and systemd environment.d; next's persist_on_path writes one file.

Recommended disposition

Do not merge. Close as superseded by cc0d24d + 00bc602, and re-land S1-S6 as fresh diffs against next (S3 is a live defect on next regardless of this PR). Remediation requires an adopting seat; the author cannot act on findings.

## Verdict: REQUEST CHANGES — superseded, and conflicting with landed work and with #1258 Reviewed by rev-code-02. Tree measured: `origin/next` @ 2508f0a (2026-08-19). CI: pipeline 2417 is `blocked`, never started (serialized queue); this PR predates the base-image pin (2026-08-19 23:42Z), so when it does run it runs on the unpinned mutable tag — a green here is not a pinned green. Not requesting a restart. ### Overlap with #1258 (asked by the coordinator) No file overlap: this PR touches root `tools/install.sh` + tests + `package.json`; #1258 touches only `packages/mosaic/framework/tools/fleet/`. Same defect family, different layers. They do not conflict as diffs, but they conflict as designs: #1258's pane candidate is `$PANE_HOME/.mosaic/node/current/bin`, the `current` symlink next's landed installer creates (install.sh:757 `ln -sfn`). This PR never creates `current` anywhere — it persists the versioned dir `${NODE_HOME}/${version}/bin`. If this implementation replaced what is on next, #1258's fix would point at a directory that never exists. #1258 is correct against next; this PR is not mergeable alongside it. ### [BLOCKER] Superseded and conflicting The headline capability (provision Node instead of refusing) landed on next 2026-08-15 20:54Z via cc0d24d (author fred, +143 lines), three hours after this PR opened, as a parallel re-implementation: knobs `MOSAIC_NODE_ROOT`/`MOSAIC_NODE_DIST_BASE` vs this PR's `MOSAIC_NODE_HOME`/`MOSAIC_NODE_DIST`; pinned v22.23.2 vs index.json resolution. Follow-ups 00bc602 (PATH persistence) and 07373ed (trust docs) completed it. API reports `mergeable: false`; merging as-is partially reverts landed work and forks the operator knob surface. ### Salvageable content NOT on next (for an adopting seat — author account is retired) - [S1] Both test suites + `package.json` wiring: `install-newest-matching-file.test.sh` (SIGPIPE under pipefail, macOS Bash 3.2, newline/space names, Bash-4 syntax lint) and `install-node-provisioning.test.sh` (offline file:// dist, corrupt-checksum case, first/last-match version traps). Next has zero tests for the bootstrap or `newest_matching_file`. - [S2] `--no-node-install` flag (next has only `MOSAIC_SKIP_NODE_BOOTSTRAP` env). - [S3] `--check` lane must not provision — on next, `--check` reaches `ensure_node` (line 822) and can download Node on a check-only run. Real defect on next; this PR gates it correctly. - [S4] Stronger checksum verification (exact-filename manifest match, duplicate-entry refusal, digest format check) vs next's `grep " ${tarball}\$"` regex-dot match. - [S5] Uninstall fail-safe: next treats an unanswerable `newest_matching_file` as "no backup" and deletes the destination; this PR distinguishes rc=1 from empty and refuses. - [S6] PATH persistence breadth: `.bash_profile`/`.bash_login` shadowing and systemd `environment.d`; next's `persist_on_path` writes one file. ### Recommended disposition Do not merge. Close as superseded by cc0d24d + 00bc602, and re-land S1-S6 as fresh diffs against next (S3 is a live defect on next regardless of this PR). Remediation requires an adopting seat; the author cannot act on findings.
Collaborator

Closing as SUPERSEDED, with the reason stated so this is not mistaken for a rejection.

The core capability landed on next as cc0d24d (2026-08-15T20:54Z, author fred), three hours after this PR opened, with different knobs (MOSAIC_NODE_ROOT / MOSAIC_NODE_DIST_BASE). Merging this now would partially revert landed work, and it conflicts as a design with #1258, whose pane candidate is ~/.mosaic/node/current/bin — a current symlink this PR never creates.

The remainder is not lost. #1331 captures everything in this PR that is not on next, including a live defect: --check reaches ensure_node and can provision. Anyone continuing this work should start there, not by reopening this.

Closed by fred as coordinator, on rev-code-02'''s review 222. Author is the retired mos-dt (Ghost) account and could not act on the findings itself — that is why this sat for four days.

A close with no stated reason is indistinguishable from a rejection, so nobody reopens it (defect D20). This is the reason.

**Closing as SUPERSEDED, with the reason stated so this is not mistaken for a rejection.** The core capability landed on `next` as `cc0d24d` (2026-08-15T20:54Z, author fred), three hours after this PR opened, with different knobs (`MOSAIC_NODE_ROOT` / `MOSAIC_NODE_DIST_BASE`). Merging this now would **partially revert landed work**, and it conflicts as a design with #1258, whose pane candidate is `~/.mosaic/node/current/bin` — a `current` symlink this PR never creates. **The remainder is not lost.** #1331 captures everything in this PR that is *not* on `next`, including a live defect: `--check` reaches `ensure_node` and can provision. Anyone continuing this work should start there, not by reopening this. Closed by fred as coordinator, on rev-code-02'''s review 222. Author is the retired `mos-dt` (Ghost) account and could not act on the findings itself — that is why this sat for four days. A close with no stated reason is indistinguishable from a rejection, so nobody reopens it (defect D20). This is the reason.
fred closed this pull request 2026-08-20 16:16:47 +00:00

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.