Files
stack/tools/install.sh
T
fred b7a6179a58
ci/woodpecker/pr/ci Pipeline is pending approval
installer: harden the Node provisioning path against its own inputs
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

1313 lines
50 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ─── Mosaic Stack Installer / Upgrader ────────────────────────────────────────
#
# Installs both components:
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
# 2. @mosaicstack/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
#
# Quick: curl -fsSL https://mosaicstack.dev/install.sh | bash
# Direct: bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
#
# Remote install (alternative — use -s -- to pass flags):
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh | bash -s --
#
# Flags:
# --check Version check only, no install
# --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main)
# --next Prerelease lane: try fast npm @next install for CLI +
# gateway from the Gitea registry, then fall back to a
# source build at next if unavailable. Explicit
# --ref/MOSAIC_REF wins and uses the source path.
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the
# registry @latest. Zero registry writes — packs local
# 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
#
# Environment:
# MOSAIC_HOME — framework install dir (default: ~/.config/mosaic)
# MOSAIC_REGISTRY — npm registry URL (default: Gitea instance)
# MOSAIC_SCOPE — npm scope (default: @mosaicstack)
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main)
# 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.
set -euo pipefail
main() {
# ─── parse flags ──────────────────────────────────────────────────────────────
FLAG_CHECK=false
FLAG_FRAMEWORK=true
FLAG_CLI=true
FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false
FLAG_UNINSTALL=false
FLAG_DEV=false
FLAG_NEXT=false
GIT_REF="${MOSAIC_REF:-main}"
GIT_REF_EXPLICIT=false
if [[ -n "${MOSAIC_REF:-}" ]]; then
GIT_REF_EXPLICIT=true
fi
# MOSAIC_ASSUME_YES env var acts the same as --yes
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
FLAG_YES=true
fi
# MOSAIC_DEV env var acts the same as --dev
if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true
fi
# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with
# source fallback from the permanent next integration branch unless
# MOSAIC_REF/--ref explicitly wins.
if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
FLAG_NEXT=true
if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then
GIT_REF="next"
fi
fi
installer_usage() {
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
case "$1" in
--check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;;
--ref)
if [[ $# -lt 2 ]] || [[ -z "$2" ]]; then
printf 'Error: Missing value for --ref\n' >&2
installer_usage
exit 2
fi
if [[ "$2" == -* ]]; then
printf 'Error: Unknown argument: %s\n' "$2" >&2
installer_usage
exit 2
fi
GIT_REF="$2"
GIT_REF_EXPLICIT=true
shift 2
;;
--dev) FLAG_DEV=true; shift ;;
--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
installer_usage
exit 2
;;
esac
done
# Explicit refs represent a request for that exact source tree. Keep --next as
# a lane selector, but do not install the registry @next package for a different
# ref than the permanent next branch.
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then
FLAG_DEV=true
fi
if [[ "$FLAG_YES" == "true" ]]; then
export MOSAIC_ASSUME_YES=1
fi
# ─── constants ────────────────────────────────────────────────────────────────
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/mosaic"
GATEWAY_PKG="${SCOPE}/gateway"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
# In dev (build-from-source) mode the gateway is installed globally from a
# locally-built tarball. Tell the wizard / gateway-config stage NOT to overwrite
# it with the registry @latest build (honored by gatewayConfigStage).
if [[ "$FLAG_DEV" == "true" ]]; then
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# Shared monorepo checkout (populated on demand by ensure_monorepo).
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"
local matches=()
[[ -d "$dir" ]] || return 0
shopt -s nullglob
# shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern.
matches=("$dir"/$pattern)
shopt -u nullglob
[[ "${#matches[@]}" -gt 0 ]] || return 0
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 ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line.
# Gateway teardown: if mosaic CLI is still available, delegates to it.
# Does NOT touch gateway DB/storage — user must handle that separately.
if [[ "$FLAG_UNINSTALL" == "true" ]]; then
echo ""
echo "${BOLD:-}Mosaic Uninstaller (shell fallback)${RESET:-}"
echo ""
SCOPE_LINE="${SCOPE:-@mosaicstack}:registry=${REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
NPMRC_FILE="$HOME/.npmrc"
# Gateway: try mosaic CLI first, then check pid file
if command -v mosaic &>/dev/null; then
echo "${B:-}${RESET:-} Attempting gateway uninstall via mosaic CLI…"
if mosaic gateway uninstall --yes 2>/dev/null; then
echo "${G:-}${RESET:-} Gateway uninstalled via CLI."
else
echo "${Y:-}${RESET:-} Gateway uninstall via CLI failed or not installed — skipping."
fi
else
# Look for pid file and stop daemon if running
GATEWAY_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}/../mosaic-gateway"
PID_FILE="$GATEWAY_HOME/gateway.pid"
if [[ -f "$PID_FILE" ]]; then
PID="$(cat "$PID_FILE" 2>/dev/null || true)"
if [[ -n "$PID" ]] && kill -0 "$PID" 2>/dev/null; then
echo "${B:-}${RESET:-} Stopping gateway daemon (pid $PID)…"
kill "$PID" 2>/dev/null || true
sleep 1
fi
fi
echo "${Y:-}${RESET:-} mosaic CLI not found — skipping full gateway teardown."
echo " Run 'mosaic gateway uninstall' separately if the CLI is available."
fi
# Framework directory
if [[ -d "$MOSAIC_HOME" ]]; then
echo "${B:-}${RESET:-} Removing framework: $MOSAIC_HOME"
rm -rf "$MOSAIC_HOME"
echo "${G:-}${RESET:-} Framework removed."
else
echo "${Y:-}${RESET:-} Framework directory not found: $MOSAIC_HOME"
fi
# Runtime assets: restore backups or remove managed copies
echo "${B:-}${RESET:-} Reversing runtime asset copies…"
declare -a RUNTIME_DESTS=(
"$HOME/.claude/CLAUDE.md"
"$HOME/.claude/settings.json"
"$HOME/.claude/hooks-config.json"
"$HOME/.claude/context7-integration.md"
"$HOME/.config/opencode/AGENTS.md"
"$HOME/.codex/instructions.md"
)
for dest in "${RUNTIME_DESTS[@]}"; do
base="$(basename "$dest")"
dir="$(dirname "$dest")"
# 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_lookup_ok=false
fi
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"
elif [[ -f "$dest" ]]; then
rm -f "$dest"
echo " Removed: $dest"
fi
done
# npmrc scope line
if [[ -f "$NPMRC_FILE" ]] && grep -qF "$SCOPE_LINE" "$NPMRC_FILE" 2>/dev/null; then
echo "${B:-}${RESET:-} Removing $SCOPE_LINE from $NPMRC_FILE…"
# Use sed to remove the exact line (in-place, portable)
if sed -i.mosaic-uninstall-bak "\|^${SCOPE_LINE}\$|d" "$NPMRC_FILE" 2>/dev/null; then
rm -f "${NPMRC_FILE}.mosaic-uninstall-bak"
echo "${G:-}${RESET:-} npmrc entry removed."
else
# BSD sed syntax (macOS)
sed -i '' "\|^${SCOPE_LINE}\$|d" "$NPMRC_FILE" 2>/dev/null || \
echo "${Y:-}${RESET:-} Could not auto-remove npmrc line — remove it manually: $SCOPE_LINE"
fi
fi
# npm CLI package
echo "${B:-}${RESET:-} Uninstalling npm package: ${CLI_PKG}…"
if npm uninstall -g "${CLI_PKG}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
echo "${G:-}${RESET:-} CLI package removed."
else
echo "${Y:-}${RESET:-} npm uninstall failed — you may need to run manually:"
echo " npm uninstall -g ${CLI_PKG}"
fi
echo ""
echo "${G:-}${RESET:-} Uninstall complete."
exit 0
fi
# ─── colours ──────────────────────────────────────────────────────────────────
if [[ "${MOSAIC_NO_COLOR:-0}" == "1" ]] || ! [[ -t 1 ]]; then
R="" G="" Y="" B="" C="" DIM="" BOLD="" RESET=""
else
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m'
B=$'\033[0;34m' C=$'\033[0;36m' DIM=$'\033[2m'
BOLD=$'\033[1m' RESET=$'\033[0m'
fi
info() { echo "${B}${RESET} $*"; }
ok() { echo "${G}${RESET} $*"; }
warn() { echo "${Y}${RESET} $*"; }
fail() { echo "${R}${RESET} $*" >&2; }
dim() { echo "${DIM}$*${RESET}"; }
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
is_next_registry_lane() {
[[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]]
}
source_ref_details() {
if is_next_registry_lane; then
echo "ref: next, --next prerelease lane"
elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
echo "ref: next, --next prerelease lane (build-from-source)"
elif [[ "$FLAG_NEXT" == "true" ]]; then
echo "ref: ${GIT_REF}, --next requested, explicit ref wins"
else
echo "ref: ${GIT_REF}"
fi
}
# ─── helpers ──────────────────────────────────────────────────────────────────
require_cmd() {
if ! command -v "$1" &>/dev/null; then
fail "Required command not found: $1"
echo " Install it and re-run this script."
exit 1
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
if [[ -n "$json" ]]; then
node -e "
const d = JSON.parse(process.argv[1]);
const v = d?.dependencies?.['${CLI_PKG}']?.version ?? '';
process.stdout.write(v);
" "$json" 2>/dev/null || true
fi
}
installed_gateway_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
if [[ -n "$json" ]]; then
node -e "
const d = JSON.parse(process.argv[1]);
const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? '';
process.stdout.write(v);
" "$json" 2>/dev/null || true
fi
}
latest_cli_version() {
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
}
next_cli_version() {
npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_gateway_version() {
npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_pipeline_suffix() {
printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p'
}
next_versions_share_pipeline() {
local cli_next="$1"
local gateway_next="$2"
local cli_pipeline gateway_pipeline
cli_pipeline="$(next_pipeline_suffix "$cli_next")"
gateway_pipeline="$(next_pipeline_suffix "$gateway_next")"
[[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]]
}
version_lt() {
node -e "
const a=process.argv[1], b=process.argv[2];
const sp = v => { const i=v.indexOf('-'); return i===-1 ? [v,null] : [v.slice(0,i),v.slice(i+1)]; };
const [cA,pA]=sp(a.replace(/^v/,'')), [cB,pB]=sp(b.replace(/^v/,''));
const nA=cA.split('.').map(Number), nB=cB.split('.').map(Number);
for(let i=0;i<3;i++){if((nA[i]||0)<(nB[i]||0))process.exit(0);if((nA[i]||0)>(nB[i]||0))process.exit(1);}
if(pA!==null&&pB===null)process.exit(0);
if(pA===null)process.exit(1);
if(pA<pB)process.exit(0);
process.exit(1);
" "$1" "$2" 2>/dev/null
}
framework_version() {
# Read framework schema version stamp
local vf="$MOSAIC_HOME/.framework-version"
if [[ -f "$vf" ]]; then
cat "$vf" 2>/dev/null || true
fi
}
# Download + extract the monorepo archive at $GIT_REF exactly once per run.
# Sets the script-level EXTRACTED_DIR to the repo root. Reused by both the
# framework install (Part 1) and the dev build-from-source path (Part 2).
ensure_monorepo() {
if [[ -n "$EXTRACTED_DIR" ]] && [[ -d "$EXTRACTED_DIR" ]]; then
return 0
fi
require_cmd tar
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
# shellcheck disable=SC2317
cleanup_work() { [[ -n "$WORK_DIR" ]] && rm -rf "$WORK_DIR"; }
trap cleanup_work EXIT
info "Downloading source from ${GIT_REF}…"
if command -v curl &>/dev/null; then
curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
elif command -v wget &>/dev/null; then
wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
else
fail "curl or wget required to download source."
exit 1
fi
# Gitea archives extract to <repo-name>/ inside the work dir
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
if [[ -z "$EXTRACTED_DIR" ]] || [[ ! -d "$EXTRACTED_DIR" ]]; then
fail "Could not locate extracted source in archive."
ls -la "$WORK_DIR" >&2
exit 1
fi
}
# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
# globally from locally-packed tarballs. ZERO registry writes. Workspace deps
# (brain/config/db/…) are pulled from the registry at the versions pinned in
# each package.json — `pnpm pack` rewrites `workspace:*` to those versions.
install_cli_from_source() {
local src="$EXTRACTED_DIR"
local out_dir="$WORK_DIR/dist-tarballs"
mkdir -p "$out_dir"
# pnpm via corepack (ships with Node >= 16.9; required by Node >= 20 preflight).
# Pin to the repo's packageManager version so the build matches CI. Surface
# corepack failures so the fresh-machine case gives an actionable error
# instead of a bare "command not found".
if ! command -v pnpm &>/dev/null; then
info "Activating pnpm via corepack…"
corepack enable 2>&1 | sed 's/^/ /' || warn "corepack enable failed — pnpm may need manual install."
corepack prepare [email protected] --activate 2>&1 | sed 's/^/ /' \
|| warn "corepack prepare failed — pnpm may need manual install."
fi
if ! command -v pnpm &>/dev/null; then
fail "pnpm not available after corepack activation."
echo " Install pnpm manually (https://pnpm.io/installation) and re-run with --dev."
exit 1
fi
info "Installing workspace dependencies (pnpm install)…"
( cd "$src" && pnpm install ) 2>&1 | sed 's/^/ /'
info "Building CLI + gateway from source…"
( cd "$src" && pnpm --filter "@mosaicstack/mosaic..." --filter "@mosaicstack/gateway..." run build ) 2>&1 | sed 's/^/ /'
info "Packing local tarballs…"
( cd "$src/packages/mosaic" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
local cli_tgz gw_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."
exit 1
fi
if [[ ! -f "$gw_tgz" ]]; then
fail "Gateway tarball was not produced by pnpm pack."
exit 1
fi
# Gateway first so it is present globally before the CLI's wizard runs (which
# skips its own gateway install via MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1).
info "Installing gateway from source tarball (global)…"
npm install -g "$gw_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
info "Installing CLI from source tarball (global)…"
npm install -g "$cli_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
ok "Installed from source: CLI $(installed_cli_version)"
}
install_next_cli_from_registry() {
local cli_next gateway_next
cli_next="$(next_cli_version)"
gateway_next="$(next_gateway_version)"
if [[ -z "$cli_next" ]]; then
warn "${CLI_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if [[ -z "$gateway_next" ]]; then
warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then
warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})."
return 1
fi
info "Installing ${CLI_PKG}@${cli_next} from registry…"
if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast CLI @next install failed."
return 1
fi
info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…"
if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast gateway @next install failed."
return 1
fi
local installed_cli installed_gateway
installed_cli="$(installed_cli_version)"
installed_gateway="$(installed_gateway_version)"
if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then
warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})."
return 1
fi
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
}
# ─── preflight ────────────────────────────────────────────────────────────────
NODE_REQUIRED=20
if [[ "$FLAG_NEXT" == "true" ]]; then
NODE_REQUIRED=22
fi
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 ""
echo "${BOLD}Mosaic Stack Installer${RESET}"
echo ""
# ═══════════════════════════════════════════════════════════════════════════════
# PART 1: Framework (bash launcher + guides + runtime configs + tools)
# ═══════════════════════════════════════════════════════════════════════════════
if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
step "Framework (~/.config/mosaic)"
FRAMEWORK_CURRENT="$(framework_version)"
HAS_FRAMEWORK=false
[[ -f "$MOSAIC_HOME/AGENTS.md" ]] || [[ -f "$MOSAIC_HOME/.framework-version" ]] && HAS_FRAMEWORK=true
if [[ -n "$FRAMEWORK_CURRENT" ]]; then
dim " Installed: framework v${FRAMEWORK_CURRENT}"
elif [[ "$HAS_FRAMEWORK" == "true" ]]; then
dim " Installed: framework (version unknown)"
else
dim " Installed: (none)"
fi
dim " Source: ${REPO_BASE} ($(source_ref_details))"
echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$HAS_FRAMEWORK" == "true" ]]; then
ok "Framework is installed."
else
warn "Framework not installed."
fi
else
# Download repo archive and extract framework (shared with the dev build)
ensure_monorepo
FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework"
if [[ ! -d "$FRAMEWORK_SRC" ]]; then
fail "Framework not found in archive at packages/mosaic/framework/"
fail "Archive contents:"
ls -la "$WORK_DIR" >&2
exit 1
fi
# Run the framework's own install.sh (handles keep/overwrite for SOUL.md etc.)
info "Installing framework to ${MOSAIC_HOME}…"
MOSAIC_INSTALL_MODE="${MOSAIC_INSTALL_MODE:-keep}" \
MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 \
MOSAIC_SKIP_SKILLS_SYNC="${MOSAIC_SKIP_SKILLS_SYNC:-0}" \
bash "$FRAMEWORK_SRC/install.sh"
ok "Framework installed"
echo ""
# Framework bin is no longer needed on PATH — the npm CLI delegates
# to mosaic-launch directly via its absolute path.
fi
fi
# ═══════════════════════════════════════════════════════════════════════════════
# PART 2: @mosaicstack/mosaic (npm — TUI, gateway client, wizard, CLI)
# ═══════════════════════════════════════════════════════════════════════════════
if [[ "$FLAG_CLI" == "true" ]]; then
step "@mosaicstack/mosaic (npm package)"
# Ensure prefix dir
if [[ ! -d "$PREFIX" ]]; then
info "Creating global prefix directory: $PREFIX"
mkdir -p "$PREFIX"/{bin,lib}
fi
# Ensure npmrc scope mapping
NPMRC="$HOME/.npmrc"
SCOPE_LINE="${SCOPE}:registry=${REGISTRY}"
if ! grep -qF "$SCOPE_LINE" "$NPMRC" 2>/dev/null; then
info "Adding ${SCOPE} registry to $NPMRC"
echo "$SCOPE_LINE" >> "$NPMRC"
ok "Registry configured"
fi
if ! grep -qF "prefix=$PREFIX" "$NPMRC" 2>/dev/null; then
if ! grep -q '^prefix=' "$NPMRC" 2>/dev/null; then
echo "prefix=$PREFIX" >> "$NPMRC"
info "Set npm global prefix to $PREFIX"
fi
fi
CURRENT="$(installed_cli_version)"
NEXT_GATEWAY=""
if [[ "$FLAG_DEV" == "true" ]]; then
LATEST=""
elif is_next_registry_lane; then
LATEST="$(next_cli_version)"
NEXT_GATEWAY="$(next_gateway_version)"
else
LATEST="$(latest_cli_version)"
fi
if [[ -n "$CURRENT" ]]; then
dim " Installed: ${CLI_PKG}@${CURRENT}"
else
dim " Installed: (none)"
fi
if [[ "$FLAG_DEV" == "true" ]]; then
dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)"
elif is_next_registry_lane; then
if [[ -n "$LATEST" ]]; then
dim " Next CLI: ${CLI_PKG}@${LATEST}"
else
dim " Next CLI: (registry @next unreachable)"
fi
if [[ -n "$NEXT_GATEWAY" ]]; then
dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}"
else
dim " Next GW: (registry @next unreachable)"
fi
dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)"
elif [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}"
else
dim " Latest: (registry unreachable)"
fi
echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
elif is_next_registry_lane; then
if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then
ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}."
else
warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source."
fi
elif [[ -z "$LATEST" ]]; then
warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then
warn "Not installed."
elif [[ "$CURRENT" == "$LATEST" ]]; then
ok "Up to date."
elif version_lt "$CURRENT" "$LATEST"; then
warn "Update available: $CURRENT$LATEST"
else
ok "Up to date (or ahead of registry)."
fi
elif [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode — building CLI + gateway from source at ref ${GIT_REF}…"
ensure_monorepo
install_cli_from_source
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
:
else
warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues."
unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL
ensure_monorepo
install_cli_from_source
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
ensure_prefix_on_path
else
if [[ -z "$LATEST" ]]; then
warn "Could not reach registry at $REGISTRY — skipping npm CLI."
elif [[ -z "$CURRENT" ]]; then
info "Installing ${CLI_PKG}@${LATEST}…"
npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
ok "CLI installed: $(installed_cli_version)"
elif [[ "$CURRENT" == "$LATEST" ]]; then
ok "Already at latest version ($LATEST)."
elif version_lt "$CURRENT" "$LATEST"; then
info "Upgrading ${CLI_PKG}: $CURRENT$LATEST…"
npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
ok "CLI upgraded: $(installed_cli_version)"
else
ok "CLI is at or ahead of registry ($CURRENT$LATEST)."
fi
ensure_prefix_on_path
fi
fi
# ═══════════════════════════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════════════════════════
if [[ "$FLAG_CHECK" == "false" ]]; then
step "Summary"
echo " ${BOLD}mosaic:${RESET} $PREFIX/bin/mosaic"
dim " Framework data: $MOSAIC_HOME/"
echo ""
# First install guidance / auto-launch
if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then
echo ""
if [[ "$FLAG_NO_AUTO_LAUNCH" == "false" ]] && [[ -t 0 ]] && [[ -t 1 ]]; then
# Interactive TTY and auto-launch not suppressed: run the unified wizard.
# `mosaic wizard` now runs the full first-run flow end-to-end: identity
# setup → runtimes → hooks preview → skills → finalize → gateway
# config → admin bootstrap. No second call needed.
info "First install detected — launching unified setup wizard…"
echo ""
MOSAIC_BIN="$PREFIX/bin/mosaic"
if ! command -v "$MOSAIC_BIN" &>/dev/null && ! command -v mosaic &>/dev/null; then
warn "mosaic binary not found on PATH — skipping auto-launch."
warn "Add $PREFIX/bin to PATH and run: mosaic wizard"
else
# Prefer the absolute path from the prefix we just installed to
MOSAIC_CMD="mosaic"
if [[ -x "$MOSAIC_BIN" ]]; then
MOSAIC_CMD="$MOSAIC_BIN"
fi
if "$MOSAIC_CMD" wizard; then
ok "Wizard complete."
else
fail "Wizard failed; installation is incomplete."
echo " Completed: framework and CLI installation"
echo " Failed: gateway configuration or admin bootstrap"
echo " You can retry with: ${C}mosaic wizard${RESET}"
echo " Or run gateway install alone: ${C}mosaic gateway install${RESET}"
exit 1
fi
fi
else
# Non-interactive or --no-auto-launch: print guidance only
info "First install detected. Set up your agent identity:"
echo " ${C}mosaic wizard${RESET} (unified first-run wizard — identity + gateway + admin)"
echo " ${C}mosaic gateway install${RESET} (standalone gateway (re)configure)"
fi
fi
# ── Write install manifest ──────────────────────────────────────────────────
# Records what was mutated so that `mosaic uninstall` can precisely reverse it.
# Written last (after all mutations) so an incomplete install leaves no manifest.
MANIFEST_PATH="$MOSAIC_HOME/.install-manifest.json"
MANIFEST_CLI_VERSION="$(installed_cli_version)"
MANIFEST_FW_VERSION="$(framework_version)"
MANIFEST_SCOPE_LINE="${SCOPE}:registry=${REGISTRY}"
MANIFEST_TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")"
# Build runtimeAssetCopies array by scanning known destinations for backups
collect_runtime_copies() {
local home_dir="$HOME"
local copies="[]"
local dests=(
"$home_dir/.claude/CLAUDE.md"
"$home_dir/.claude/settings.json"
"$home_dir/.claude/hooks-config.json"
"$home_dir/.claude/context7-integration.md"
"$home_dir/.config/opencode/AGENTS.md"
"$home_dir/.codex/instructions.md"
)
copies="["
local first=true
for dest in "${dests[@]}"; do
[[ -f "$dest" ]] || continue
local base dir backup_path backup_val
base="$(basename "$dest")"
dir="$(dirname "$dest")"
# 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
backup_val="null"
fi
if [[ "$first" == "true" ]]; then
first=false
else
copies="$copies,"
fi
copies="$copies{\"source\":\"\",\"dest\":\"$dest\",\"backup\":$backup_val}"
done
copies="$copies]"
echo "$copies"
}
RUNTIME_COPIES="$(collect_runtime_copies)"
# Check whether the npmrc line was present (we may have added it above)
NPMRC_LINES_JSON="[]"
if grep -qF "$MANIFEST_SCOPE_LINE" "$HOME/.npmrc" 2>/dev/null; then
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
fi
if node -e "
const fs = require('fs');
const path = require('path');
const p = process.argv[1];
const m = {
version: 1,
installedAt: process.argv[2],
cliVersion: process.argv[3] || '(unknown)',
frameworkVersion: parseInt(process.argv[4] || '0', 10),
mutations: {
directories: [path.dirname(p)],
npmGlobalPackages: ['@mosaicstack/mosaic'],
npmrcLines: JSON.parse(process.argv[5]),
shellProfileEdits: [],
runtimeAssetCopies: JSON.parse(process.argv[6]),
}
};
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(m, null, 2) + '\\n', { mode: 0o600 });
" \
"$MANIFEST_PATH" \
"$MANIFEST_TS" \
"$MANIFEST_CLI_VERSION" \
"$MANIFEST_FW_VERSION" \
"$NPMRC_LINES_JSON" \
"$RUNTIME_COPIES" 2>/dev/null; then
ok "Install manifest written: $MANIFEST_PATH"
else
warn "Could not write install manifest (non-fatal)"
fi
echo ""
ok "Done."
fi
} # end main
main "$@"