Compare commits

..
Author SHA1 Message Date
fred b7a6179a58 installer: harden the Node provisioning path against its own inputs
ci/woodpecker/pr/ci Pipeline is pending approval
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.
2026-08-15 13:46:48 -05:00
Jason WoltjeandClaude Opus 5 06c714ddf3 installer: stop newest_matching_file from dying on SIGPIPE
ci/woodpecker/pr/ci Pipeline is pending approval
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
2026-08-15 12:51:59 -05:00
Jason WoltjeandClaude Opus 5 cb2bf4e4a4 installer: provision Node instead of refusing to run without it
ci/woodpecker/pr/ci Pipeline is pending approval
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
2026-08-15 12:44:39 -05:00
mos-dt-0 7a6fb024b4 docs: establish canonical documentation architecture (#1210)
ci/woodpecker/push/publish Pipeline failed
2026-08-13 17:56:13 +00:00
mos-dt-0 f82307c4dc fix(lease): raise capability-probe timeout to 10s on both halves (#869) (#1207)
ci/woodpecker/push/publish Pipeline failed
2026-08-13 17:28:00 +00:00
10 changed files with 1397 additions and 54 deletions
+71
View File
@@ -0,0 +1,71 @@
# REPORT A1207
Date: 2026-08-13
Branch: `fix/869-lease-probe-timeout`
Starting head: `2373a5ad345fb316ad2460f6390baab1f45ba08f`
Base: `216cd72226cd9ee17eea461cfe7cd0e010a22f02`
## What changed
- Added Python behavior tests using isolated temporary directories and marker-writing fake `mosaic` executables. They prove that the supplied `PATH` wins over ambient `os.environ["PATH"]`, and that absent or empty supplied `PATH` values do not search ambient paths, platform defaults, or the current directory.
- Bound Python override behavior with executable fakes: a valid `MOSAIC_LEASE_VERSION_PROBE_COMMAND` wins over supplied and ambient `PATH`; an invalid override returns `None` without PATH fallback.
- Added a Python runner binding test that captures kwargs and requires `timeout=10.0`. Existing timeout, transport-error, and nonzero-exit checks remain fail-closed with `None`.
- Added the optional TypeScript dependency-injection seam `CapabilityProbeExecFile`, defaulting to the existing real `execFileSync` implementation. Production callers have no behavior change.
- Added TypeScript tests that capture child-process options and require exactly `timeout: 10_000`. Injected timeout, spawn-error, nonzero-exit, unparseable JSON, and malformed-object cases all return `null`.
- Removed the ambient no-dependency TypeScript smoke case that could execute a built checkout's real CLI. Default resolver and supervisor behavior retain their isolated tests, while capability transport tests now use an isolated artifact or the injected transport.
No Python production code changed relative to `2373a5ad`. The only production delta is the optional TypeScript child-process injection seam.
## Hermeticity incident and correction
An initial ambient-lookup mutation run exposed that the pre-existing Python "not resolvable" test left ambient process PATH uncontrolled. On this host, that mutation resolved and executed the host `mosaic` capability probe. A post-build intermediate TypeScript run also let the pre-existing no-dependency smoke case execute the checkout's built `dist/cli.js` capability probe. No `claude` process was run. I then isolated the Python test's ambient PATH, removed the TypeScript ambient smoke case, repeated the PATH mutation using only marker-writing temporary fakes, and repeated the final suites without either real probe path.
## Mutation evidence
Each mutation was applied independently, its focused suite was run, and the production source was restored before the final run.
| Mutation | Result | Reddened test name(s) |
| ------------------------------------------------------------------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shutil.which("mosaic", path=environ.get("PATH", ""))` to ambient `shutil.which("mosaic")` | RED, three failures | `ProbeActivationCapabilityTest.test_supplied_path_wins_over_ambient_process_path`; `ProbeActivationCapabilityTest.test_absent_or_empty_supplied_path_never_falls_back_or_executes` for both absent and empty PATH subtests |
| Python `PROBE_TIMEOUT_SECONDS: 10.0` to `2.0` | RED, one failure | `ProbeActivationCapabilityTest.test_probe_passes_ten_second_timeout_to_runner` |
| TypeScript `LEASE_CAPABILITY_PROBE_TIMEOUT_MS: 10_000` to `2_000` | RED, one failure | `defaultCapabilityProbe > passes the exact ten-second timeout to the injected child-process transport` |
## Final test run
Dependencies were installed first with `pnpm install --frozen-lockfile`. Workspace dependencies were then built with `pnpm --filter '@mosaicstack/mosaic...' run build` so package type declarations were available.
```text
$ cd packages/mosaic && python3 src/mutator-gate/version_coupling_unittest.py
...................
----------------------------------------------------------------------
Ran 19 tests in 0.007s
OK
$ pnpm exec vitest run src/commands/lease-activation-probe.spec.ts
✓ src/commands/lease-activation-probe.spec.ts (20 tests) 80ms
Test Files 1 passed (1)
Tests 20 passed (20)
```
```text
$ pnpm exec prettier --check packages/mosaic/src/commands/lease-activation-probe.ts packages/mosaic/src/commands/lease-activation-probe.spec.ts
Checking formatting...
All matched files use Prettier code style!
$ pnpm --filter @mosaicstack/mosaic lint
> eslint src
$ pnpm --filter @mosaicstack/mosaic typecheck
> tsc --noEmit
$ python3 -m py_compile packages/mosaic/src/mutator-gate/version_coupling_unittest.py packages/mosaic/framework/tools/lease-broker/activation_version_gate.py
$ git diff --check
```
All commands above exited zero.
## Ambiguities skipped
None.
+1 -1
View File
@@ -11,7 +11,7 @@
"typecheck": "pnpm preflight && turbo run typecheck",
"test:checkout": "node --test scripts/*.test.mjs",
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
"test:installer": "bash tools/install-next-lane.test.sh",
"test:installer": "bash tools/install-next-lane.test.sh && bash tools/install-node-provisioning.test.sh && bash tools/install-newest-matching-file.test.sh",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "node scripts/install-hooks.mjs"
@@ -62,7 +62,14 @@ EXPECTED_ACTIVATION_CAPABILITY: Final[ActivationCapability] = {
# capability as compact JSON.
LEASE_CAPABILITY_PROBE_COMMAND: Final = "__lease-capability"
PROBE_TIMEOUT_SECONDS: Final = 2.0
# Budget for the out-of-process `mosaic __lease-capability` probe. The CLI
# is a Node program whose cold start alone measures 2.2-2.3s on a mid-range
# workstation (sb-it-1-dt, 2026-08-13), so a 2s budget made every launch on
# such hosts fail closed with the #869 skew message even though the
# capability matched. The timeout only bounds the pathological hang case —
# the happy path returns as soon as the probe exits — so a generous budget
# costs nothing on healthy hosts.
PROBE_TIMEOUT_SECONDS: Final = 10.0
# Override hook: a full shell-style command line (parsed with `shlex.split`)
# to run INSTEAD of resolving `mosaic` on PATH and appending the probe
@@ -88,7 +95,13 @@ def _resolve_probe_command(environ: Mapping[str, str]) -> list[str] | None:
if override:
parsed = shlex.split(override)
return parsed or None
resolved = shutil.which("mosaic")
# Resolve against the PROVIDED environment's PATH, not the ambient
# os.environ. Before this, a test passing a hermetic environ still
# resolved (and spawned) the host's real `mosaic` — masked only on hosts
# where the real probe happened to exceed the old 2s timeout. No PATH in
# the provided environment means nothing is resolvable (fail-closed),
# matching the probe's overall contract.
resolved = shutil.which("mosaic", path=environ.get("PATH", ""))
if resolved is None:
return None
return [resolved, LEASE_CAPABILITY_PROBE_COMMAND]
@@ -7,11 +7,13 @@ import { fileURLToPath } from 'node:url';
import {
LEASE_ACTIVATION_CAPABILITY,
LEASE_CAPABILITY_PROBE_COMMAND,
LEASE_CAPABILITY_PROBE_TIMEOUT_MS,
defaultCapabilityProbe,
defaultResolveCliEntry,
defaultSupervisorProbe,
leaseEnforcementActivatable,
registerLeaseCapabilityProbe,
type CapabilityProbeExecFile,
type LeaseActivationCapability,
type SupervisorProbeResult,
} from './lease-activation-probe.js';
@@ -35,6 +37,17 @@ const presentSupervisor: SupervisorProbeResult = {
socketPath: '/run/user/1000/mosaic-lease/broker.sock',
};
function withScratchCli<T>(run: (cliPath: string) => T): T {
const scratchDir = mkdtempSync(join(tmpdir(), 'mosaic-lease-capability-probe-'));
try {
const cliPath = join(scratchDir, 'cli.js');
writeFileSync(cliPath, '// isolated fake; injected execFile means this is never executed\n');
return run(cliPath);
} finally {
rmSync(scratchDir, { recursive: true, force: true });
}
}
describe('leaseEnforcementActivatable', () => {
it('is false when the activation capability is absent (null)', () => {
const result = leaseEnforcementActivatable({
@@ -100,15 +113,6 @@ describe('leaseEnforcementActivatable', () => {
});
expect(result).toBe(true);
});
it('uses the real default probes when no deps are injected (does not throw)', () => {
// No live broker / built CLI is guaranteed in a test environment, so this
// only asserts the predicate degrades to a safe boolean rather than
// throwing — the fail-closed behavior itself is covered by the injected
// cases above.
expect(() => leaseEnforcementActivatable()).not.toThrow();
expect(typeof leaseEnforcementActivatable()).toBe('boolean');
});
});
describe('defaultCapabilityProbe', () => {
@@ -127,6 +131,61 @@ describe('defaultCapabilityProbe', () => {
expect(result).toBeNull();
});
it('passes the exact ten-second timeout to the injected child-process transport', () => {
withScratchCli((cliPath) => {
let captured:
| {
file: string;
args: string[];
options: Parameters<CapabilityProbeExecFile>[2];
}
| undefined;
const execFile: CapabilityProbeExecFile = (file, args, options) => {
captured = { file, args, options };
return JSON.stringify(LEASE_ACTIVATION_CAPABILITY);
};
const result = defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile });
expect(result).toEqual(LEASE_ACTIVATION_CAPABILITY);
expect(captured).toEqual({
file: process.execPath,
args: [cliPath, LEASE_CAPABILITY_PROBE_COMMAND],
options: {
encoding: 'utf-8',
timeout: 10_000,
stdio: ['ignore', 'pipe', 'ignore'],
},
});
expect(captured?.options.timeout).toBe(LEASE_CAPABILITY_PROBE_TIMEOUT_MS);
});
});
it.each([
['timeout', Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' })],
['spawn error', Object.assign(new Error('spawn failed'), { code: 'ENOENT' })],
['nonzero exit', Object.assign(new Error('child exited 1'), { status: 1 })],
])('returns null (fail-closed) on child-process %s', (_failure, error) => {
withScratchCli((cliPath) => {
const execFile: CapabilityProbeExecFile = () => {
throw error;
};
expect(defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile })).toBeNull();
});
});
it.each([
['unparseable JSON', 'not-json'],
['malformed object', JSON.stringify({ name: LEASE_ACTIVATION_CAPABILITY.name })],
])('returns null (fail-closed) on %s output', (_failure, output) => {
withScratchCli((cliPath) => {
const execFile: CapabilityProbeExecFile = () => output;
expect(defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile })).toBeNull();
});
});
describe('positive path — injected resolver, isolated scratch dir (never the real dist/)', () => {
// A prior version of this test staged the stub cli.js at the package's
// REAL resolved dist/ path and relied on afterEach to clean up "only
@@ -55,6 +55,19 @@ export const LEASE_ACTIVATION_CAPABILITY: LeaseActivationCapability = {
/** Hidden CLI probe subcommand name — wired via {@link registerLeaseCapabilityProbe}. */
export const LEASE_CAPABILITY_PROBE_COMMAND = '__lease-capability';
/**
* Budget for the out-of-process capability probe. The probe launches a fresh
* Node process on the built CLI entrypoint, whose cold start alone measures
* 2.2-2.3s on a mid-range workstation (sb-it-1-dt, 2026-08-13) — so the
* previous 2s budget made the probe time out and report NO capability on
* such hosts, failing every launch with the #869 skew message even though
* the capability matched. The timeout only bounds the pathological hang
* case; the happy path returns as soon as the probe exits. Mirrors
* PROBE_TIMEOUT_SECONDS in the enforcement half
* (framework/tools/lease-broker/activation_version_gate.py).
*/
export const LEASE_CAPABILITY_PROBE_TIMEOUT_MS = 10_000;
function capabilityMatches(candidate: LeaseActivationCapability | null): boolean {
return (
candidate !== null &&
@@ -110,12 +123,28 @@ export function defaultResolveCliEntry(
return join(dirname(mainEntry), 'cli.js');
}
/** Narrow injectable seam for the synchronous child process used by the
* capability probe. */
export type CapabilityProbeExecFile = (
file: string,
args: string[],
options: {
encoding: BufferEncoding;
timeout: number;
stdio: ['ignore', 'pipe', 'ignore'];
},
) => string;
/** Injectable inputs for {@link defaultCapabilityProbe}. */
export interface CapabilityProbeDeps {
/** Resolve the CLI entrypoint (`cli.js`) to probe. Defaults to
* {@link defaultResolveCliEntry}. Inject to point at an isolated scratch
* location in tests — never at the real package's `dist/`. */
resolveCliEntry?: () => string;
/** Execute the resolved CLI entrypoint. Defaults to the real
* `execFileSync`. Inject so transport behavior and options can be tested
* without spawning a process. */
execFile?: CapabilityProbeExecFile;
}
/**
@@ -139,9 +168,10 @@ export function defaultCapabilityProbe(
const cliEntry = resolveCliEntry();
if (!existsSync(cliEntry)) return null;
const output = execFileSync(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
const execFile: CapabilityProbeExecFile = deps.execFile ?? execFileSync;
const output = execFile(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
encoding: 'utf-8',
timeout: 2000,
timeout: LEASE_CAPABILITY_PROBE_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
});
@@ -24,11 +24,15 @@ from __future__ import annotations
import importlib.util
import io
import os
import shlex
import subprocess
import sys
import tempfile
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest import mock
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
@@ -57,6 +61,20 @@ def matching_capability() -> dict[str, object]:
return dict(VERSION_GATE.EXPECTED_ACTIVATION_CAPABILITY)
def write_fake_mosaic(directory: Path, marker: Path) -> Path:
directory.mkdir(parents=True, exist_ok=True)
executable = directory / "mosaic"
executable.write_text(
"#!/bin/sh\n"
f"printf '%s\\n' executed >> {shlex.quote(str(marker))}\n"
"printf '%s\\n' "
"'{\"name\":\"lease-runtime-activation\",\"version\":1}'\n",
encoding="utf-8",
)
executable.chmod(0o755)
return executable
class AssertActivationCapabilityMatchesTest(unittest.TestCase):
"""Unit-level coverage of `activation_version_gate.py`'s own assertion,
isolated from the launch-runtime.py seam it is wired into below."""
@@ -110,11 +128,102 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
handling — never spawns a real `mosaic` process."""
def test_returns_none_when_mosaic_is_not_resolvable_on_path(self) -> None:
result = VERSION_GATE.default_probe_activation_capability(
{"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
)
# Keep even a deliberate ambient-lookup mutation away from any host
# installation. The dedicated hermeticity tests below provide fake
# ambient executables and markers.
with mock.patch.dict(
os.environ, {"PATH": "/nonexistent-ambient-bin-dir-for-869-c4-test"}
):
result = VERSION_GATE.default_probe_activation_capability(
{"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
)
self.assertIsNone(result)
def test_supplied_path_wins_over_ambient_process_path(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
supplied_marker = root / "supplied.marker"
ambient_marker = root / "ambient.marker"
supplied_bin = root / "supplied-bin"
ambient_bin = root / "ambient-bin"
write_fake_mosaic(supplied_bin, supplied_marker)
write_fake_mosaic(ambient_bin, ambient_marker)
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
result = VERSION_GATE.default_probe_activation_capability(
{"PATH": str(supplied_bin)}
)
self.assertEqual(result, matching_capability())
self.assertTrue(supplied_marker.exists())
self.assertFalse(ambient_marker.exists())
def test_absent_or_empty_supplied_path_never_falls_back_or_executes(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
ambient_marker = root / "ambient.marker"
current_directory_marker = root / "current-directory.marker"
ambient_bin = root / "ambient-bin"
current_directory = root / "current-directory"
write_fake_mosaic(ambient_bin, ambient_marker)
write_fake_mosaic(current_directory, current_directory_marker)
original_directory = Path.cwd()
try:
os.chdir(current_directory)
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
for supplied_environment in ({}, {"PATH": ""}):
with self.subTest(environ=supplied_environment):
result = VERSION_GATE.default_probe_activation_capability(
supplied_environment
)
self.assertIsNone(result)
self.assertFalse(ambient_marker.exists())
self.assertFalse(current_directory_marker.exists())
finally:
os.chdir(original_directory)
def test_valid_override_wins_and_invalid_override_does_not_fall_back_to_path(
self,
) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
supplied_marker = root / "supplied.marker"
ambient_marker = root / "ambient.marker"
override_marker = root / "override.marker"
supplied_bin = root / "supplied-bin"
ambient_bin = root / "ambient-bin"
override_bin = root / "override-bin"
write_fake_mosaic(supplied_bin, supplied_marker)
write_fake_mosaic(ambient_bin, ambient_marker)
override_executable = write_fake_mosaic(override_bin, override_marker)
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
result = VERSION_GATE.default_probe_activation_capability(
{
"PATH": str(supplied_bin),
VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: str(override_executable),
}
)
self.assertEqual(result, matching_capability())
self.assertTrue(override_marker.exists())
self.assertFalse(supplied_marker.exists())
self.assertFalse(ambient_marker.exists())
override_marker.unlink()
result = VERSION_GATE.default_probe_activation_capability(
{
"PATH": str(supplied_bin),
VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: str(
root / "invalid-override" / "mosaic"
),
}
)
self.assertIsNone(result)
self.assertFalse(override_marker.exists())
self.assertFalse(supplied_marker.exists())
self.assertFalse(ambient_marker.exists())
def test_override_command_is_parsed_and_the_probe_subcommand_is_not_double_appended(
self,
) -> None:
@@ -135,6 +244,29 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
self.assertEqual(result, {"name": "lease-runtime-activation", "version": 1})
self.assertEqual(captured, [["/fake/mosaic", "__lease-capability"]])
def test_probe_passes_ten_second_timeout_to_runner(self) -> None:
captured_argv: list[str] = []
captured_kwargs: dict[str, object] = {}
class FakeCompleted:
returncode = 0
stdout = '{"name": "lease-runtime-activation", "version": 1}'
def fake_run(argv: list[str], **kwargs: object) -> FakeCompleted:
captured_argv.extend(argv)
captured_kwargs.update(kwargs)
return FakeCompleted()
result = VERSION_GATE.default_probe_activation_capability(
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic"},
run=fake_run,
)
self.assertEqual(result, matching_capability())
self.assertEqual(captured_argv, ["/fake/mosaic"])
self.assertEqual(captured_kwargs["timeout"], 10.0)
self.assertEqual(captured_kwargs["check"], False)
def test_fails_closed_on_nonzero_exit_malformed_json_and_missing_fields(self) -> None:
class NonZeroExit:
returncode = 1
@@ -174,7 +306,7 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
def test_fails_closed_on_timeout_and_transport_error(self) -> None:
def timeout_run(*_args: object, **_kwargs: object) -> None:
raise subprocess.TimeoutExpired(cmd="mosaic", timeout=2.0)
raise subprocess.TimeoutExpired(cmd="mosaic", timeout=10.0)
def oserror_run(*_args: object, **_kwargs: object) -> None:
raise OSError("no such file or directory")
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Tests for newest_matching_file() in tools/install.sh.
#
# The function answers one question -- "which is the most recent backup / tarball
# here?" -- and its callers act destructively on the answer. Three ways of getting it
# wrong have already been found, and each has a case below:
#
# * `ls -1t | head -1` returns 141 under `set -o pipefail` once the listing fills a
# pipe buffer (~1600 names), because head closes the pipe and ls takes SIGPIPE.
# Callers assign it at top level under `set -e`, so a 141 aborts the run.
# * `mapfile` is a Bash 4 builtin. macOS ships Bash 3.2 and the installer supports
# Darwin, so the whole lookup was unavailable there -- and an empty answer is what
# sends the uninstaller down its delete-the-destination branch.
# * Any line-based parse of `ls` splits a filename containing a newline into two
# wrong answers.
#
# The large-population and newline cases are the point: with two or three ordinary
# names every version of this function passes, which is why the first two went
# unnoticed.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-newest-match-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
# Load the function under test and the mtime helper it depends on, with the same
# shell options install.sh runs under.
eval "$(sed -n '/^_MTIME_STYLE=/,/^}/p' "$ROOT/tools/install.sh")"
eval "$(sed -n '/^newest_matching_file()/,/^}/p' "$ROOT/tools/install.sh")"
POPULATED="$TMP/many"
mkdir -p "$POPULATED"
# Enough names to overflow a 64 KiB pipe buffer several times over.
for i in $(seq 1 5000); do
: > "$POPULATED/mosaicstack-mosaic-0.0.${i}.tgz"
done
sleep 1
: > "$POPULATED/mosaicstack-mosaic-9.9.9.tgz"
echo "[test] the newest match is returned from a directory large enough to fill a pipe"
GOT="$(newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz')"
[[ "$(basename "$GOT")" == "mosaicstack-mosaic-9.9.9.tgz" ]] || {
echo "expected the newest tarball, got '${GOT}'" >&2
exit 1
}
echo "[test] a large population does not make the lookup fail"
set +e
newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz' >/dev/null
RC=$?
set -e
[[ "$RC" -eq 0 ]] || { echo "expected rc=0, got ${RC} (141 means the SIGPIPE regression is back)" >&2; exit 1; }
echo "[test] a small population still works"
SMALL="$TMP/few"
mkdir -p "$SMALL"
: > "$SMALL/mosaicstack-gateway-0.0.1.tgz"
sleep 1
: > "$SMALL/mosaicstack-gateway-0.0.2.tgz"
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
[[ "$(basename "$GOT")" == "mosaicstack-gateway-0.0.2.tgz" ]] || {
echo "expected the newer gateway tarball, got '${GOT}'" >&2
exit 1
}
echo "[test] a name containing a space is returned whole"
SPACED="$TMP/spaced"
mkdir -p "$SPACED"
: > "$SPACED/agents.md.mosaic-bak-one two"
GOT="$(newest_matching_file "$SPACED" 'agents.md.mosaic-bak-*')"
[[ "$GOT" == "$SPACED/agents.md.mosaic-bak-one two" ]] || {
echo "expected the spaced name intact, got '${GOT}'" >&2
exit 1
}
echo "[test] a name containing a newline is returned whole, not split"
# The old `ls -1t` parse reported this file as two separate shorter names, neither of
# which exists -- so the caller saw a backup path that could not be restored.
NEWLINE="$TMP/newline"
mkdir -p "$NEWLINE"
WEIRD="$NEWLINE/agents.md.mosaic-bak-$(printf 'a\nb')"
: > "$WEIRD"
GOT="$(newest_matching_file "$NEWLINE" 'agents.md.mosaic-bak-*')"
[[ "$GOT" == "$WEIRD" ]] || {
echo "expected the newline-containing name intact, got '${GOT}'" >&2
exit 1
}
[[ -f "$GOT" ]] || { echo "the returned path does not name a real file" >&2; exit 1; }
echo "[test] no match is an empty answer, not an error"
EMPTY="$TMP/none"
mkdir -p "$EMPTY"
set +e
GOT="$(newest_matching_file "$EMPTY" 'nothing-*.tgz')"
RC=$?
set -e
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
echo "[test] a directory that does not exist is an empty answer, not an error"
set +e
GOT="$(newest_matching_file "$TMP/absent" 'nothing-*.tgz')"
RC=$?
set -e
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
echo "[test] an unanswerable lookup fails loudly instead of reporting no match"
# This is the distinction the uninstaller depends on. "No backup exists" is licence to
# delete the destination; "I could not tell" must never reach that branch.
_MTIME_STYLE=none
set +e
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
RC=$?
set -e
_MTIME_STYLE=""
[[ "$RC" -ne 0 ]] || {
echo "expected a non-zero rc when no mtime source is usable, got rc=0 output '${GOT}'" >&2
exit 1
}
echo "[test] the installer uses no Bash 4 syntax"
# A lint, not an execution test: this host has no Bash 3.2 to run under. It is still
# the thing that stops the regression, because every Bash 4 construct that has broken
# macOS here was introduced by someone who never ran the script there either.
# Comments are stripped first -- the ones above name these constructs on purpose.
BASH4_HITS="$(
sed 's/#.*$//' "$ROOT/tools/install.sh" \
| grep -nE '(^|[^[:alnum:]_])(mapfile|readarray)([^[:alnum:]_]|$)|declare[[:space:]]+-[a-zA-Z]*A|local[[:space:]]+-[a-zA-Z]*A|\$\{[A-Za-z_][A-Za-z0-9_]*(\^\^|,,)' \
|| true
)"
[[ -z "$BASH4_HITS" ]] || {
echo "tools/install.sh uses Bash 4+ syntax, which macOS's Bash 3.2 cannot run:" >&2
echo "$BASH4_HITS" >&2
exit 1
}
echo "[test] newest_matching_file tests passed"
+6 -2
View File
@@ -153,17 +153,21 @@ reset_state() {
}
reset_state
# The installer now provisions Node itself, so Node 20 no longer stops a --next
# install -- it gets replaced. What still has to hold is that the >= 22 gate fires
# before anything is installed, so this asserts it on the one lane where refusing is
# still the outcome. The replacement path is covered by install-node-provisioning.test.sh.
echo "[test] --next rejects Node 20 before any install action"
if OUTPUT="$(
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NODE_MAJOR=20 PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch --no-node-install 2>&1
)"; then
echo "expected Node 20 next-lane install to fail" >&2
exit 1
fi
grep -qF 'Node.js >= 22 required for the --next lane' <<<"$OUTPUT"
grep -qF 'Node >= 22 required and --no-node-install was given.' <<<"$OUTPUT"
[[ ! -s "$LOG" ]] || { echo "Node 20 gate ran npm actions" >&2; exit 1; }
reset_state
+460
View File
@@ -0,0 +1,460 @@
#!/usr/bin/env bash
# Tests for the installer's Node provisioning.
#
# The installer's whole promise is that one command turns a bare host into a working
# one. Node was the exception: it was a hard prerequisite the installer checked and
# refused, so on a greenfield host the documented one-command install failed first.
# These tests pin the fixed behaviour, including the refusals.
#
# Everything runs 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 and no real Node download.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-provision-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
DIST="$TMP/dist"
FAKE_BIN="$TMP/bin"
HOME_DIR="$TMP/home"
PREFIX="$TMP/prefix"
MOSAIC_HOME_DIR="$TMP/mosaic"
STATE="$TMP/state"
LOG="$TMP/npm.log"
NODE_HOME="$TMP/nodehome"
mkdir -p "$DIST" "$FAKE_BIN" "$HOME_DIR" "$STATE"
REAL_NODE="$(command -v node)"
# The platform triple, derived the same way the installer derives it.
case "$(uname -s)" in
Linux) TEST_OS=linux ;;
Darwin) TEST_OS=darwin ;;
*) echo "[skip] no Node build for $(uname -s)"; exit 0 ;;
esac
case "$(uname -m)" in
x86_64|amd64) TEST_ARCH=x64 ;;
aarch64|arm64) TEST_ARCH=arm64 ;;
armv7l) TEST_ARCH=armv7l ;;
*) echo "[skip] no Node build for $(uname -m)"; exit 0 ;;
esac
PLATFORM="${TEST_OS}-${TEST_ARCH}"
VERSION=v22.99.0 # the one that must be chosen
MID_VERSION=v22.50.0 # same major, older -- catches "take the last match"
OLD_VERSION=v20.99.0 # wrong major
NEWER_MAJOR=v24.99.0 # listed first -- catches "take the first entry"
# ─── fixtures ─────────────────────────────────────────────────────────────────
# A node stub that answers the installer's version probe and defers everything else
# to the real interpreter, so the rest of the install still runs.
#
# The major is baked in per stub rather than read from the environment. A shared env
# var would be read by the downloaded Node too, so the "system Node is too old" case
# would install a replacement that also claimed to be too old.
write_node_stub() {
local path="$1" major="${2:-22}"
cat > "$path" <<STUB
#!/usr/bin/env bash
set -euo pipefail
if [[ "\$*" == *'process.versions.node.split'* ]]; then
printf '%s' "${major}"
exit 0
fi
if [[ "\${1:-}" == "--version" ]]; then
printf 'v%s.99.0\n' "${major}"
exit 0
fi
exec "\${MOSAIC_TEST_REAL_NODE:?}" "\$@"
STUB
chmod +x "$path"
}
write_npm_stub() {
cat > "$1" <<'STUB'
#!/usr/bin/env bash
set -euo pipefail
echo "$*" >> "${MOSAIC_TEST_NPM_LOG:?}"
STATE="${MOSAIC_TEST_STATE:?}"
if [[ "${1:-}" == "view" ]]; then
case "$2 $3" in
"@mosaicstack/mosaic@next version") echo "0.0.50-next.999" ;;
"@mosaicstack/gateway@next version") echo "0.0.7-next.999" ;;
"@mosaicstack/mosaic version") echo "0.0.49" ;;
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "${1:-}" == "install" ]]; then
case "$*" in
*"@mosaicstack/mosaic@"*) echo "0.0.50-next.999" > "$STATE/mosaic" ;;
*"@mosaicstack/gateway@"*) echo "0.0.7-next.999" > "$STATE/gateway" ;;
esac
exit 0
fi
if [[ "${1:-}" == "ls" ]]; then
printf '{"dependencies":{"@mosaicstack/mosaic":{"version":"%s"},"@mosaicstack/gateway":{"version":"%s"}}}\n' \
"$(cat "$STATE/mosaic" 2>/dev/null || echo '')" \
"$(cat "$STATE/gateway" 2>/dev/null || echo '')"
exit 0
fi
exit 0
STUB
chmod +x "$1"
}
# Build a nodejs.org-shaped release: the tarball, and a SHASUMS256.txt over it.
publish_release() {
local version="$1" corrupt_checksum="${2:-false}"
local base="node-${version}-${PLATFORM}"
local stage="$TMP/stage-${version}"
rm -rf "$stage"
mkdir -p "$stage/${base}/bin"
write_node_stub "$stage/${base}/bin/node" "$(sed 's/^v//; s/\..*//' <<<"$version")"
write_npm_stub "$stage/${base}/bin/npm"
mkdir -p "${DIST}/${version}"
tar -czf "${DIST}/${version}/${base}.tar.gz" -C "$stage" "$base"
local sum
if command -v sha256sum &>/dev/null; then
sum="$(sha256sum "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')"
else
sum="$(shasum -a 256 "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')"
fi
if [[ "$corrupt_checksum" == "true" ]]; then
sum="0000000000000000000000000000000000000000000000000000000000000000"
fi
printf '%s %s.tar.gz\n' "$sum" "$base" > "${DIST}/${version}/SHASUMS256.txt"
}
publish_release "$VERSION"
publish_release "$MID_VERSION"
publish_release "$OLD_VERSION"
publish_release "$NEWER_MAJOR"
# Newest-first, as nodejs.org publishes it. Every wrong entry is genuinely installable,
# so a resolver that picks one fails on the assertion rather than on a 404 -- the
# assertion is then about version selection and not about the fixture.
printf '[{"version":"%s"},{"version":"%s"},{"version":"%s"},{"version":"%s"}]\n' \
"$NEWER_MAJOR" "$VERSION" "$MID_VERSION" "$OLD_VERSION" > "$DIST/index.json"
# A PATH with the usual tools but no Node toolchain, so "a host with no Node" is
# actually true on a developer machine and in CI, both of which have one installed.
NONODE_BIN="$TMP/nonode-bin"
mkdir -p "$NONODE_BIN"
for candidate in /usr/bin/* /bin/*; do
[[ -e "$candidate" ]] || continue
case "$(basename "$candidate")" in
node|npm|npx|corepack|nodejs) continue ;;
esac
ln -sf "$candidate" "$NONODE_BIN/$(basename "$candidate")" 2>/dev/null || true
done
if PATH="$NONODE_BIN" command -v node &>/dev/null; then
echo "[skip] could not build a Node-free PATH on this host" >&2
exit 0
fi
reset_home() {
rm -rf "$HOME_DIR" "$PREFIX" "$MOSAIC_HOME_DIR" "$NODE_HOME" "$LOG" "$STATE"
mkdir -p "$HOME_DIR" "$STATE"
: > "$LOG"
}
# Run the installer with no Node anywhere on PATH.
run_bare() {
env -u npm_config_prefix \
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" "$@"
}
# ─── tests ────────────────────────────────────────────────────────────────────
reset_home
echo "[test] a host with no Node gets one, and the CLI install proceeds"
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
grep -qF -- "Node is not installed" <<<"$OUTPUT"
grep -qF -- "Installed Node ${VERSION}" <<<"$OUTPUT"
[[ -x "${NODE_HOME}/${VERSION}/bin/node" ]]
grep -qF -- "install -g @mosaicstack/[email protected]" "$LOG"
echo "[test] the newest release of the required major is chosen"
# The index lists a higher major first and an older release of the right major after
# the right answer, so "first entry" and "last match" both produce a wrong directory.
[[ -d "${NODE_HOME}/${VERSION}" ]]
[[ ! -d "${NODE_HOME}/${NEWER_MAJOR}" ]]
[[ ! -d "${NODE_HOME}/${MID_VERSION}" ]]
[[ ! -d "${NODE_HOME}/${OLD_VERSION}" ]]
echo "[test] future shells can find both Node and the CLI"
grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.profile"
grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.profile"
# Debian's .bashrc returns early when non-interactive, so the login profile is the
# one that matters -- but an interactive non-login shell only reads .bashrc.
grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.bashrc"
grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.bashrc"
echo "[test] a real login shell resolves node, not just the text of a profile line"
# Grepping the file only proves the installer wrote something. This starts an actual
# login shell against that HOME and asks it to find the binary.
RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')"
[[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || {
echo "a login shell resolved node to '${RESOLVED}'" >&2
exit 1
}
echo "[test] a systemd --user unit gets the same PATH, via environment.d"
# Units read no shell file at all, which is how a Mosaic agent seat starts.
ENVD="$HOME_DIR/.config/environment.d/50-mosaic-path.conf"
[[ -f "$ENVD" ]] || { echo "no environment.d drop-in was written" >&2; exit 1; }
grep -qF -- "PATH=${NODE_HOME}/${VERSION}/bin:\${PATH}" "$ENVD"
grep -qF -- "PATH=${PREFIX}/bin:\${PATH}" "$ENVD"
echo "[test] re-running reuses the Node it installed and does not duplicate PATH lines"
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
grep -qF -- "from ${NODE_HOME}" <<<"$OUTPUT"
[[ "$(grep -c 'export PATH=' "$HOME_DIR/.profile")" -eq 2 ]]
[[ "$(grep -c 'export PATH=' "$HOME_DIR/.bashrc")" -eq 2 ]]
[[ "$(grep -c '^PATH=' "$ENVD")" -eq 2 ]]
reset_home
echo "[test] a ~/.bash_profile does not silently swallow the PATH entry"
# A bash login shell reads the first of .bash_profile / .bash_login / .profile that
# exists and never looks at the rest. Writing only .profile is a no-op on such a host,
# and the failure is invisible until something cannot find node.
: > "$HOME_DIR/.bash_profile"
run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1
RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')"
[[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || {
echo "with a .bash_profile present, a login shell resolved node to '${RESOLVED}'" >&2
exit 1
}
reset_home
echo "[test] a commented-out example does not count as the PATH entry already existing"
# The idempotence check used to be an unanchored substring match, so a line like this
# in a user's profile made the installer skip the real entry.
mkdir -p "$HOME_DIR"
printf '# export PATH="%s/%s/bin:$PATH"\n' "$NODE_HOME" "$VERSION" > "$HOME_DIR/.profile"
run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1
[[ "$(grep -c '^export PATH=' "$HOME_DIR/.profile")" -eq 2 ]] || {
echo "expected two real export lines, found:" >&2
cat "$HOME_DIR/.profile" >&2
exit 1
}
reset_home
echo "[test] --no-node-install refuses instead of installing"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-node-install 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "--no-node-install was given" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] --check never provisions Node"
set +e
OUTPUT="$(run_bare --check --cli --next 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "Required command not found: node" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a tampered download is rejected and nothing is installed"
publish_release "$VERSION" true
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "failed checksum verification" <<<"$OUTPUT"
# Not just "no usable node": nothing at all may survive. An unpack that ran before
# verification, or a staging directory left behind, would still satisfy the weaker
# check while leaving unverified bytes on disk for the next run to adopt.
[[ ! -x "${NODE_HOME}/${VERSION}/bin/node" ]]
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
[[ ! -e "${NODE_HOME}/${VERSION}.partial" ]]
[[ ! -d "$NODE_HOME" ]] || [[ -z "$(ls -A "$NODE_HOME")" ]]
publish_release "$VERSION"
reset_home
echo "[test] a system Node that is new enough is used as-is and left alone"
write_node_stub "$FAKE_BIN/node" 22
write_npm_stub "$FAKE_BIN/npm"
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
grep -qF -- "satisfies the >= 22 requirement" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a system Node that is too old is replaced rather than accepted"
write_node_stub "$FAKE_BIN/node" 18
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
grep -qF -- "older than the required >= 22" <<<"$OUTPUT"
[[ -x "${NODE_HOME}/${VERSION}/bin/node" ]]
# ─── refusals: untrusted input that reaches a path or an exec ─────────────────
reset_home
echo "[test] an empty checksum manifest is refused, not read as an empty digest"
: > "${DIST}/${VERSION}/SHASUMS256.txt"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "No checksum published" <<<"$OUTPUT"
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
publish_release "$VERSION"
reset_home
echo "[test] a manifest naming a regex-equivalent file does not vouch for this one"
# The lookup used to interpolate the filename into a grep pattern. A Node tarball name
# is mostly dots, and a dot matches any character, so this line -- which names a
# different file -- was accepted as this file's checksum.
DECOY="node-${VERSION}-${PLATFORM}Xtar.gz"
printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$DECOY" > "${DIST}/${VERSION}/SHASUMS256.txt"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "No checksum published" <<<"$OUTPUT"
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
publish_release "$VERSION"
reset_home
echo "[test] a manifest listing the same file twice is refused rather than guessed at"
BASE="node-${VERSION}-${PLATFORM}.tar.gz"
GOOD="$(awk '{print $1}' "${DIST}/${VERSION}/SHASUMS256.txt")"
{
printf '%s %s\n' "$GOOD" "$BASE"
printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$BASE"
} > "${DIST}/${VERSION}/SHASUMS256.txt"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "refusing to guess" <<<"$OUTPUT"
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
publish_release "$VERSION"
echo "[test] a version string is checked before it becomes a path"
# MOSAIC_NODE_VERSION becomes a directory name under NODE_HOME, and that directory is
# later handed to `rm -rf`. This is defence in depth, and the honest scope should be
# recorded: the plain 'v..' case is separately refused by rm itself, and a traversal
# value breaks the download URL before the removal is reached. Measured, not assumed.
# What the check buys is that neither of those accidents is what is protecting us, and
# that a typo is refused with its own name on it rather than a curl error.
eval "$(sed -n '/^node_valid_version()/,/^}/p' "$ROOT/tools/install.sh")"
for good in v22.99.0 v0.0.0 v22.11.0 v100.0.1; do
node_valid_version "$good" || { echo "rejected a real version: ${good}" >&2; exit 1; }
done
for bad in 'v..' '..' 'v9.9.9/../../elsewhere' '/etc' 'v22' 'v22.1' '22.1.0' 'v22.1.0-rc1' '' 'v1.0.0 ' '$(id)'; do
! node_valid_version "$bad" || { echo "accepted a bad version: '${bad}'" >&2; exit 1; }
done
reset_home
echo "[test] a bad MOSAIC_NODE_VERSION is refused by name, before any download"
set +e
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" MOSAIC_NODE_VERSION="v9.9.9/../../elsewhere" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "MOSAIC_NODE_VERSION" <<<"$OUTPUT"
grep -qF -- "Downloading Node" <<<"$OUTPUT" && {
echo "the download started despite an invalid version" >&2
exit 1
}
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a download location with no transport integrity is refused"
set +e
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="http://example.invalid/dist" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "MOSAIC_NODE_DIST must be" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a path containing shell syntax is not written into a profile"
# The PATH line is executed by every future shell that reads the file, so a directory
# holding $() or a quote would run there as code.
EVIL="$TMP/ev\$(touch $TMP/pwned)il"
set +e
env -u npm_config_prefix \
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$EVIL" \
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch >/dev/null 2>&1
set -e
if [[ -f "$HOME_DIR/.profile" ]]; then
grep -qF -- 'touch' "$HOME_DIR/.profile" && {
echo "a command substitution was written into .profile" >&2
exit 1
}
fi
[[ ! -e "$TMP/pwned" ]] || { echo "the embedded command ran" >&2; exit 1; }
echo "[test] installer node provisioning tests passed"
+471 -34
View File
@@ -25,6 +25,9 @@
# tarballs and installs them globally. Use to test a branch
# end-to-end before cutting a release.
# --yes Accept all defaults; headless/non-interactive install
# --no-node-install Do not provision Node; fail if Node >= 20 (>= 22 with
# --next) is not already present. Default is to install a
# user-local Node under ~/.mosaic/node when it is missing.
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
#
@@ -38,6 +41,11 @@
# MOSAIC_NEXT — equivalent to --next (set to 1)
# MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# MOSAIC_NODE_HOME — user-local Node install dir (default: ~/.mosaic/node)
# MOSAIC_NODE_VERSION — pin the Node release (default: latest of the
# required major, e.g. v22.23.2)
# MOSAIC_NODE_DIST — Node download mirror (default: nodejs.org/dist)
# MOSAIC_NO_NODE_INSTALL — equivalent to --no-node-install (set to 1)
# ──────────────────────────────────────────────────────────────────────────────
#
# Wrapped in main() for safe curl-pipe usage.
@@ -82,7 +90,7 @@ if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
fi
installer_usage() {
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--no-node-install] [--uninstall]\n' >&2
}
while [[ $# -gt 0 ]]; do
@@ -109,6 +117,7 @@ while [[ $# -gt 0 ]]; do
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
--yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--no-node-install) MOSAIC_NO_NODE_INSTALL=1; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;;
*)
printf 'Error: Unknown argument: %s\n' "$1" >&2
@@ -150,6 +159,43 @@ fi
WORK_DIR=""
EXTRACTED_DIR=""
# Modification time of one file, as an integer. GNU/BusyBox stat takes -c, BSD/macOS
# stat takes -f, and there is no flag both accept -- so probe once and remember.
_MTIME_STYLE=""
file_mtime() {
if [[ -z "$_MTIME_STYLE" ]]; then
if stat -c %Y . >/dev/null 2>&1; then
_MTIME_STYLE=gnu
elif stat -f %m . >/dev/null 2>&1; then
_MTIME_STYLE=bsd
else
_MTIME_STYLE=none
fi
fi
case "$_MTIME_STYLE" in
gnu) stat -c %Y -- "$1" 2>/dev/null ;;
bsd) stat -f %m -- "$1" 2>/dev/null ;;
*) return 1 ;;
esac
}
# The most recently modified file in "$dir" matching "$pattern".
#
# Three separate contracts, and callers must tell them apart:
# rc=0 with output — this is the newest match
# rc=0, no output — the directory or the pattern matched nothing
# rc=1 — the answer could not be determined
#
# The third one exists because the uninstall path treats "no backup" as licence to
# delete the destination. A lookup that fails must never be mistaken for a lookup
# that succeeded and found nothing.
#
# The candidates come from a glob and are compared in-shell, never rendered as text.
# That is deliberate, and it closes three bugs at once: `mapfile` is a Bash 4 builtin
# and macOS ships Bash 3.2, which this installer supports (see node_platform); piping
# `ls` into `head` dies on SIGPIPE under `set -o pipefail` once the listing fills a
# pipe buffer, returning 141 with no output; and any line-based parse of `ls` splits a
# filename that contains a newline into two wrong answers.
newest_matching_file() {
local dir="$1"
local pattern="$2"
@@ -160,8 +206,17 @@ newest_matching_file() {
matches=("$dir"/$pattern)
shopt -u nullglob
[[ "${#matches[@]}" -gt 0 ]] || return 0
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
ls -1t "${matches[@]}" 2>/dev/null | head -1
local newest="" newest_t="" candidate t
for candidate in "${matches[@]}"; do
t="$(file_mtime "$candidate")" || return 1
[[ -n "$t" ]] || return 1
if [[ -z "$newest_t" ]] || [[ "$t" -gt "$newest_t" ]]; then
newest="$candidate"
newest_t="$t"
fi
done
printf '%s\n' "$newest"
}
# ─── uninstall path ───────────────────────────────────────────────────────────
@@ -224,12 +279,17 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
for dest in "${RUNTIME_DESTS[@]}"; do
base="$(basename "$dest")"
dir="$(dirname "$dest")"
# Find most recent backup
# Find most recent backup. A lookup that could not answer is not the same as
# "there is no backup": removing the destination on a failed lookup would destroy
# the file the backup exists to restore.
backup=""
backup_lookup_ok=true
if [[ -d "$dir" ]]; then
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" || backup_lookup_ok=false
fi
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
if [[ "$backup_lookup_ok" != "true" ]]; then
echo " Skipped: $dest (could not check for a backup; left in place)"
elif [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
cp "$backup" "$dest"
rm -f "$backup"
echo " Restored: $dest"
@@ -309,6 +369,378 @@ require_cmd() {
fi
}
# ─── node provisioning ────────────────────────────────────────────────────────
#
# Node is a hard prerequisite for everything below, and a greenfield host does not
# have it. Treating that as the operator's problem made the documented one-command
# install a two-command install that fails first — so the installer provisions Node
# itself.
#
# It installs into the user's own tree rather than through 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 always preferred and left untouched.
NODE_HOME="${MOSAIC_NODE_HOME:-$HOME/.mosaic/node}"
NODE_DIST="${MOSAIC_NODE_DIST:-https://nodejs.org/dist}"
FLAG_NO_NODE_INSTALL=false
if [[ "${MOSAIC_NO_NODE_INSTALL:-0}" == "1" ]]; then
FLAG_NO_NODE_INSTALL=true
fi
# A Node version string is about to become a directory name under NODE_HOME, and that
# directory is passed to `rm -rf`. Nothing reaches a filesystem operation until it has
# matched this. `v..` is the case that matters: it resolves to NODE_HOME's parent.
node_valid_version() {
[[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
# The download location is executable code. Refuse a scheme that carries no transport
# integrity at all, and say plainly what an override does and does not buy, since the
# tarball and the checksum that vouches for it then come from the same place.
case "$NODE_DIST" in
https://*) ;;
file://*) ;;
*)
if [[ -n "${MOSAIC_NODE_DIST:-}" ]]; then
fail "MOSAIC_NODE_DIST must be an https:// or file:// URL; got '${NODE_DIST}'"
exit 1
fi
;;
esac
node_major_of() {
# Read the major from the binary rather than parsing `node --version` text, so a
# build with a suffix (v22.1.0-nightly…) does not read as a different major.
"$1" -e 'process.stdout.write(String(process.versions.node.split(".")[0]))' 2>/dev/null || echo 0
}
# The platform triple in a nodejs.org tarball name, or empty where nodejs.org
# publishes no build we can use.
node_platform() {
local os arch
case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) return 1 ;;
esac
# Official Linux builds are glibc-linked; on musl they install and then fail to run.
if [[ "$os" == "linux" ]] && ldd --version 2>&1 | grep -qi musl; then
return 1
fi
case "$(uname -m)" in
x86_64|amd64) arch=x64 ;;
aarch64|arm64) arch=arm64 ;;
armv7l) arch=armv7l ;;
*) return 1 ;;
esac
printf '%s-%s' "$os" "$arch"
}
# Newest release of the wanted major. Resolved rather than pinned so a fresh install
# picks up security releases; MOSAIC_NODE_VERSION pins it when reproducibility matters.
node_resolve_version() {
local want="$1" index resolved
if [[ -n "${MOSAIC_NODE_VERSION:-}" ]]; then
if ! node_valid_version "$MOSAIC_NODE_VERSION"; then
fail "MOSAIC_NODE_VERSION must look like v22.11.0; got '${MOSAIC_NODE_VERSION}'"
return 1
fi
printf '%s' "$MOSAIC_NODE_VERSION"
return 0
fi
index="$(curl -fsSL --retry 3 "${NODE_DIST}/index.json" 2>/dev/null)" || return 1
# index.json is newest-first, so the first match is the latest of that major.
# grep/sed rather than a JSON parser because node is the thing we do not have yet.
# No `| head -1` here: head closes the pipe, grep takes SIGPIPE, and under
# `set -o pipefail` the whole substitution returns 141 -- the bug already fixed in
# newest_matching_file. Take the first line in the shell instead.
local found
found="$(printf '%s' "$index" | grep -o "\"version\":\"v${want}\.[0-9]\+\.[0-9]\+\"")" || return 1
found="${found%%$'\n'*}"
resolved="${found#\"version\":\"}"
resolved="${resolved%\"}"
# The index is remote input, and what comes out of it becomes a path.
[[ -n "$resolved" ]] || return 1
node_valid_version "$resolved" || return 1
printf '%s' "$resolved"
}
node_verify_checksum() {
local dir="$1" file="$2" expected="" line name matched=0
local manifest="${dir}/SHASUMS256.txt"
if [[ ! -f "$manifest" ]]; then
fail "No checksum manifest was downloaded for ${file}"
return 1
fi
# Compare filenames exactly rather than `grep " ${file}$"`. A Node tarball name is
# mostly dots, and in a regex a dot matches any character -- so a manifest line for
# a name that merely looks like this one would be accepted as this one's checksum.
#
# Every line is read, not just the first match: two entries for the same file mean
# the manifest is not trustworthy, and picking either one is a decision this code
# has no basis to make.
while IFS= read -r line || [[ -n "$line" ]]; do
name="${line#* }"
[[ "$name" == "$file" ]] || continue
expected="${line%% *}"
matched=$(( matched + 1 ))
done < "$manifest"
if [[ "$matched" -eq 0 ]]; then
fail "No checksum published for ${file}"
return 1
fi
if [[ "$matched" -gt 1 ]]; then
fail "Checksum manifest lists ${file} ${matched} times; refusing to guess."
return 1
fi
if [[ ! "$expected" =~ ^[0-9a-fA-F]{64}$ ]]; then
fail "Checksum for ${file} is not a SHA-256 digest: '${expected}'"
return 1
fi
local actual
if command -v sha256sum &>/dev/null; then
actual="$(sha256sum "${dir}/${file}" | awk '{print $1}')"
elif command -v shasum &>/dev/null; then
actual="$(shasum -a 256 "${dir}/${file}" | awk '{print $1}')"
else
fail "Cannot verify the Node download: neither sha256sum nor shasum is present."
return 1
fi
if [[ "$actual" != "$expected" ]]; then
fail "Node download failed checksum verification (${file})"
dim " expected ${expected}"
dim " got ${actual}"
return 1
fi
}
# Download, verify and unpack one Node release into a scratch dir, then move it into
# place. Staging first means a failed or interrupted download never leaves a half-tree
# that the next run would mistake for an installed Node.
node_fetch_and_unpack() {
local version="$1" platform="$2" work="$3"
local base="node-${version}-${platform}"
local tarball="${base}.tar.gz"
local dest="${NODE_HOME}/${version}"
info "Downloading Node ${version} (${platform})…"
curl -fsSL --retry 3 -o "${work}/${tarball}" "${NODE_DIST}/${version}/${tarball}" || {
fail "Could not download ${NODE_DIST}/${version}/${tarball}"
return 1
}
curl -fsSL --retry 3 -o "${work}/SHASUMS256.txt" "${NODE_DIST}/${version}/SHASUMS256.txt" || {
fail "Could not download the Node checksum file"
return 1
}
node_verify_checksum "$work" "$tarball" || return 1
mkdir -p "$NODE_HOME"
tar -xzf "${work}/${tarball}" -C "$work" || { fail "Could not unpack ${tarball}"; return 1; }
rm -rf "${dest}.partial"
mv "${work}/${base}" "${dest}.partial" || { fail "Could not stage Node into ${NODE_HOME}"; return 1; }
rm -rf "$dest"
mv "${dest}.partial" "$dest" || { fail "Could not install Node into ${dest}"; return 1; }
ok "Installed Node ${version}${dest}"
}
# Install one Node release, reusing it if this installer already put it there.
#
# The scratch dir is removed here rather than by a RETURN trap inside the worker: a
# RETURN trap set inside a function stays installed after that function returns, so it
# fires again on the next unrelated function return, where its variables are gone.
node_install() {
local version="$1" platform="$2"
local dest="${NODE_HOME}/${version}"
# Re-checked here, not only where the version was resolved: `dest` is about to be
# handed to `rm -rf`, and this is the last place before that happens. A version of
# `..` would point the removal at NODE_HOME's parent.
if ! node_valid_version "$version"; then
fail "Refusing to install Node from an unexpected version string: '${version}'"
return 1
fi
if [[ -x "${dest}/bin/node" ]]; then
info "Reusing Node ${version} already at ${dest}"
return 0
fi
local work rc=0
work="$(mktemp -d)" || return 1
node_fetch_and_unpack "$version" "$platform" "$work" || rc=$?
rm -rf "$work"
return "$rc"
}
# Put a directory on PATH for future processes, once. A user-local Node and a
# user-local npm prefix are only useful if the next process can still find them, and
# the installer used to do no more than warn about it.
#
# There is no one file that covers this. Each target below is the only thing that
# works for some way a user -- or an agent seat -- actually starts a process:
#
# ~/.profile POSIX login shells, and `bash -lc` when no bash-specific
# profile exists.
# ~/.bash_profile A bash login shell reads the first of these that exists and
# ~/.bash_login then never reads ~/.profile. On a host with one of them,
# writing only ~/.profile is a silent no-op. Appended to when
# present, never created -- creating one would itself start
# shadowing ~/.profile for everything else the user has there.
# ~/.bashrc Interactive non-login shells. Debian's returns early when the
# shell is not interactive, so it cannot stand in for a profile.
# ~/.zshenv Every zsh invocation, including `ssh host cmd`. A remote
# non-interactive zsh reads neither ~/.zprofile nor ~/.zshrc,
# which is what the previous version of this function wrote.
# environment.d systemd --user units, which read no shell file at all. A
# Mosaic agent seat starts as a unit, so this one is the point.
persist_path_line() {
local dir="$1" line rc wrote=""
# This text is written into files that a future shell will execute, so a directory
# containing shell syntax would run there as code. Refuse rather than escape: such
# a path can only arrive through MOSAIC_NODE_HOME or MOSAIC_PREFIX, and a real
# install directory never needs these characters.
if [[ "$dir" =~ [\"\$\`\\] ]] || [[ "$dir" == *"'"* ]] || [[ "$dir" == *$'\n'* ]]; then
warn "Not adding ${dir} to PATH automatically: the path contains shell syntax."
dim " Put it on PATH by hand, or reinstall to a path without those characters."
return 0
fi
line="export PATH=\"${dir}:\$PATH\""
local files=("$HOME/.profile")
case "$(basename "${SHELL:-/bin/bash}")" in
zsh)
files+=("$HOME/.zshenv")
;;
*)
files+=("$HOME/.bashrc")
if [[ -f "$HOME/.bash_profile" ]]; then files+=("$HOME/.bash_profile"); fi
if [[ -f "$HOME/.bash_login" ]]; then files+=("$HOME/.bash_login"); fi
;;
esac
for rc in "${files[@]}"; do
# -x anchors the match to a whole line. Without it, a commented-out example of
# this same export counts as already present and the real entry never gets
# written -- the failure then looks like the installer simply did nothing.
if [[ -f "$rc" ]] && grep -Fqx "$line" "$rc"; then
continue
fi
{
printf '\n# Added by the Mosaic Stack installer\n'
printf '%s\n' "$line"
} >> "$rc"
wrote+="${wrote:+, }${rc}"
done
# systemd --user units inherit from the user manager, not from any shell.
local envd="$HOME/.config/environment.d"
local envd_file="$envd/50-mosaic-path.conf"
local envd_line="PATH=${dir}:\${PATH}"
if mkdir -p "$envd" 2>/dev/null; then
if [[ ! -f "$envd_file" ]] || ! grep -Fqx "$envd_line" "$envd_file"; then
printf '%s\n' "$envd_line" >> "$envd_file"
wrote+="${wrote:+, }${envd_file}"
fi
fi
if [[ -n "$wrote" ]]; then
ok "Added ${dir} to PATH in ${wrote}"
dim " This shell: export PATH=\"${dir}:\$PATH\""
dim " systemd --user: systemctl --user daemon-reload (or log in again)"
fi
}
# Make the installed `mosaic` reachable, now and in the next shell. Warning about
# this and moving on left a completed install whose CLI could not be found, which
# reads to an operator as a failed install.
ensure_prefix_on_path() {
persist_path_line "$PREFIX/bin"
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
PATH="$PREFIX/bin:$PATH"
export PATH
fi
}
# Guarantee a Node of at least $1 on PATH for the rest of this run.
ensure_node() {
local want="$1" current=0
if command -v node &>/dev/null; then
current="$(node_major_of node)"
if [[ "$current" -ge "$want" ]]; then
ok "Node $(node --version) satisfies the >= ${want} requirement"
return 0
fi
fi
# A Node this installer put there previously, from an earlier run or another lane.
local candidate
for candidate in "$NODE_HOME"/*/bin/node; do
[[ -x "$candidate" ]] || continue
if [[ "$(node_major_of "$candidate")" -ge "$want" ]]; then
PATH="$(dirname "$candidate"):$PATH"
export PATH
ok "Using Node $(node --version) from ${NODE_HOME}"
persist_path_line "$(dirname "$candidate")"
return 0
fi
done
if [[ "$current" == "0" ]]; then
info "Node is not installed; the Mosaic CLI needs Node >= ${want}."
else
info "Node v${current} is older than the required >= ${want}."
fi
if [[ "$FLAG_NO_NODE_INSTALL" == "true" ]]; then
fail "Node >= ${want} required and --no-node-install was given."
echo " Install Node >= ${want} and re-run, or drop --no-node-install."
exit 1
fi
local platform
if ! platform="$(node_platform)"; then
fail "No official Node build for $(uname -s)/$(uname -m)."
echo " Install Node >= ${want} with your system package manager and re-run."
exit 1
fi
require_cmd curl
require_cmd tar
local version
version="$(node_resolve_version "$want")" || true
if [[ -z "$version" ]]; then
fail "Could not resolve a Node ${want}.x release from ${NODE_DIST}."
echo " Check network access, or pin one: MOSAIC_NODE_VERSION=v${want}.0.0"
exit 1
fi
info "Installing Node ${version} into ${NODE_HOME} (no root required)…"
if ! node_install "$version" "$platform"; then
fail "Node installation failed."
echo " Install Node >= ${want} manually and re-run, or re-run with --no-node-install"
echo " once it is present."
exit 1
fi
PATH="${NODE_HOME}/${version}/bin:$PATH"
export PATH
persist_path_line "${NODE_HOME}/${version}/bin"
# Prove it, rather than assuming the unpack produced a working binary.
if ! command -v node &>/dev/null || [[ "$(node_major_of node)" -lt "$want" ]]; then
fail "Node ${version} was installed but is not usable on PATH."
exit 1
fi
ok "Node $(node --version) ready"
}
installed_cli_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
@@ -450,8 +882,10 @@ install_cli_from_source() {
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
local cli_tgz gw_tgz
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"
# An unanswerable lookup becomes an empty path, which the -f guards below report
# properly. Nothing destructive happens on this path, so failing soft is safe here.
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" || cli_tgz=""
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')" || gw_tgz=""
if [[ ! -f "$cli_tgz" ]]; then
fail "CLI tarball was not produced by pnpm pack."
@@ -518,17 +952,26 @@ install_next_cli_from_registry() {
# ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node
require_cmd npm
NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')"
if [[ "$NODE_MAJOR" -lt 20 ]]; then
fail "Node.js >= 20 required (found v$(node --version))"
exit 1
NODE_REQUIRED=20
if [[ "$FLAG_NEXT" == "true" ]]; then
NODE_REQUIRED=22
fi
if [[ "$FLAG_NEXT" == "true" && "$NODE_MAJOR" -lt 22 ]]; then
fail "Node.js >= 22 required for the --next lane (found v$(node --version))"
exit 1
if [[ "$FLAG_CHECK" == "true" || "$FLAG_UNINSTALL" == "true" ]]; then
# Neither lane installs anything, so neither one may install Node.
require_cmd node
require_cmd npm
NODE_MAJOR="$(node_major_of node)"
if [[ "$NODE_MAJOR" -lt "$NODE_REQUIRED" ]]; then
fail "Node.js >= ${NODE_REQUIRED} required (found $(node --version))"
exit 1
fi
else
ensure_node "$NODE_REQUIRED"
# npm ships inside the Node tarball, so this only fails on a system Node that
# was packaged without it — which is worth saying out loud rather than dying later.
require_cmd npm
NODE_MAJOR="$(node_major_of node)"
fi
echo ""
@@ -682,11 +1125,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ensure_monorepo
install_cli_from_source
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
ensure_prefix_on_path
elif is_next_registry_lane; then
info "Next mode — trying fast npm @next install from ${REGISTRY}"
if install_next_cli_from_registry; then
@@ -699,11 +1138,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
ensure_prefix_on_path
else
if [[ -z "$LATEST" ]]; then
warn "Could not reach registry at $REGISTRY — skipping npm CLI."
@@ -721,11 +1156,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ok "CLI is at or ahead of registry ($CURRENT$LATEST)."
fi
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
ensure_prefix_on_path
fi
fi
@@ -810,7 +1241,13 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
local base dir backup_path backup_val
base="$(basename "$dest")"
dir="$(dirname "$dest")"
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
# Recording null here would tell a later uninstall that no backup exists, and
# it would then delete the destination instead of restoring it. An unanswerable
# lookup must stop the manifest, not guess at it.
if ! backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"; then
fail "Could not determine the backup state of ${dest}; refusing to write a manifest."
return 1
fi
if [[ -n "$backup_path" ]]; then
backup_val="\"$backup_path\""
else