#!/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 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-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) # ────────────────────────────────────────────────────────────────────────────── # # 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 ] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--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 ;; --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="" 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 # shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS. ls -1t "${matches[@]}" 2>/dev/null | head -1 } # ─── 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 backup="" if [[ -d "$dir" ]]; then backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" fi if [[ -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 } # True if any shell rc file already puts $1 on PATH. # # Each file is tested for existence first and grepped one at a time, rather than # handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file # makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q # matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless. # On the 2 path the caller reads "not present yet" and appends a duplicate PATH # line on every single install. path_entry_exists() { local dir="$1" rc_file for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then return 0 fi done return 1 } # Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this # process. An `export` here reaches only the installer; every directory the # install leaves behind has to be written down somewhere a later shell reads. # # Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for # non-interactive shells, so a PATH line appended to the bottom of it is # unreachable to `bash -lc`, to systemd units, and to every agent seat — the # exact consumers that need these binaries. ~/.profile is read by login shells # and Debian's .profile sources .bashrc for interactive ones, so a single line # there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc. # # $1 = directory to add, $2 = label for the comment line. # Returns 1 (having warned) if the profile could not be written. persist_on_path() { local dir="$1" label="$2" profile if path_entry_exists "$dir"; then return 0 fi if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then profile="$HOME/.zshenv" else profile="$HOME/.profile" fi # Probe writability in a subshell. A redirection failure on a special built-in # aborts the shell it runs in, so it has to be a child; and the redirection on # the subshell is what silences the "Permission denied" the shell would # otherwise print ahead of our own message. if ! ( : >>"$profile" ) 2>/dev/null; then warn "$dir is not on your PATH and $profile could not be written" dim " Add to your shell rc: export PATH=\"$dir:\$PATH\"" return 1 fi { echo "" echo "# $label" echo "export PATH=\"$dir:\$PATH\"" } >>"$profile" ok "Added $dir to PATH in $profile" return 0 } # Persist $PREFIX/bin on PATH instead of only warning about it. # # The warning it replaces was the last step of an otherwise successful install, # so the installer reported success and left `mosaic: command not found` — an # unattended install had no operator to read the advice and act on it. ensure_prefix_on_path() { if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then return fi if path_entry_exists "$PREFIX/bin"; then warn "$PREFIX/bin is in your shell profile but not in this shell" elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then return fi dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)" } # Fleet transport binary (#1240). # # `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every # roster the CLI scaffolds sets `transport: tmux`, but nothing in this script # provides tmux and, until now, nothing in it mentioned tmux at all. A # greenfield host came out of this installer able to install a fleet, start a # fleet, and run no seat — the operator's first signal was `mosaic fleet ps`. # # Not a `require_cmd`: tmux is required by the fleet, not by mosaic. Plenty of # hosts install this to run `mosaic claude` and will never scaffold a roster, # and failing their install over a binary they do not need would be wrong. It # is a warning that names precisely what it blocks. # # `tools/_scripts/mosaic-doctor` carries a deliberately parallel check, so the # same host state gets the same answer from an audit as from an install. They # are separate implementations because this one has to work before the # framework's scripts are guaranteed to be on disk; keep their wording in step. check_fleet_transport() { local transport=tmux local roster="$MOSAIC_HOME/fleet/roster.yaml" local declared="" if [[ -f "$roster" ]]; then declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 | tr -d '"'\''' | tr -d '\r' | awk '{print $1}')" [[ -n "$declared" ]] && transport="$declared" fi command -v "$transport" &>/dev/null && return 0 warn "Fleet transport '$transport' is not installed." echo " The Mosaic fleet runs its agent seats inside $transport. Without it," echo " ${C}mosaic fleet start${RESET} reports success and no seat comes up." echo " Install it before using the fleet, e.g. ${C}sudo apt-get install -y $transport${RESET}" echo " (this does not affect ${C}mosaic claude${RESET} or the other single-runtime commands)." } 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/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 / 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 pnpm@10.6.2 --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 cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.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}" } # ─── node bootstrap ─────────────────────────────────────────────────────────── # # Nothing on a greenfield host installs Node.js, yet this installer and the CLI # it installs both hard-require it. Measured on a clean Debian 13 image: the # installer stopped at `require_cmd node` with "Required command not found" and # nothing was installed, with no hint of how to proceed. # # Inlined rather than factored into a sibling file on purpose: this script is # fetched standalone by curl and has nothing to source. # # No-op when a suitable node is already on PATH, so it never fights an # operator's nvm/fnm/distro node. NODE_ROOT="${MOSAIC_NODE_ROOT:-$HOME/.mosaic/node}" NODE_BOOTSTRAP_VERSION="${MOSAIC_NODE_VERSION:-v22.23.2}" NODE_MIN_MAJOR="${MOSAIC_NODE_MIN_MAJOR:-20}" NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}" # Major version of the node at $1, or empty if it will not run. node_major_of() { local candidate="$1" version version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0 printf '%s' "${version%%.*}" } node_is_suitable() { local major major="$(node_major_of "$1")" [[ -n "$major" ]] && [[ "$major" -ge "$NODE_MIN_MAJOR" ]] } install_node() { local node_os node_arch tarball release_url work_dir extracted target node_bin case "$(uname -s)" in Linux) node_os="linux" ;; Darwin) node_os="darwin" ;; *) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;; esac # Linux here means glibc. Node's official linux-x64 build is dynamically # linked against glibc, so on musl (Alpine) the binary will not exec — but it # fails visibly: node_is_suitable rejects it and ensure_node exits with # "install Node.js manually". No silent breakage, just a wasted download. # A musl host needs the unofficial build, which is out of scope here. case "$(uname -m)" in x86_64|amd64) node_arch="x64" ;; aarch64|arm64) node_arch="arm64" ;; armv7l) node_arch="armv7l" ;; *) fail "Unsupported architecture '$(uname -m)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;; esac # .tar.gz rather than the smaller .tar.xz: gzip is universally present, xz is # not, and a minimal image is exactly the case this exists to handle. tarball="node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}.tar.gz" release_url="${NODE_DIST_BASE}/${NODE_BOOTSTRAP_VERSION}" work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-XXXXXX")" info "Installing Node.js $NODE_BOOTSTRAP_VERSION ($node_os-$node_arch) to $NODE_ROOT…" if ! curl -fsSL "${release_url}/${tarball}" -o "$work_dir/$tarball"; then fail "Download failed: ${release_url}/${tarball}" rm -rf "$work_dir"; return 1 fi # Trust assumption, stated so nobody has to infer it: this verifies INTEGRITY # (the tarball matches the manifest), not AUTHENTICITY (the manifest is # genuinely Node's). The only thing establishing that is TLS to # $NODE_DIST_BASE. Node publishes SHASUMS256.txt.sig signed by its release # keys and we do not check it, which is on par with nvm but means pointing # MOSAIC_NODE_DIST_BASE at an untrusted mirror has no signature backstop. # Tracked as a hardening follow-up (raised by scooby in the #1229 review). if ! curl -fsSL "${release_url}/SHASUMS256.txt" -o "$work_dir/SHASUMS256.txt"; then fail "Could not fetch SHASUMS256.txt; refusing to install an unverified runtime." rm -rf "$work_dir"; return 1 fi # Keep only our artifact's line, so a missing entry is an error not a pass. if ! grep " ${tarball}\$" "$work_dir/SHASUMS256.txt" >"$work_dir/expected.sha256"; then fail "$tarball has no entry in SHASUMS256.txt; refusing to install." rm -rf "$work_dir"; return 1 fi if ! (cd "$work_dir" && verify_sha256 expected.sha256); then fail "Checksum mismatch for $tarball; refusing to install." rm -rf "$work_dir"; return 1 fi ok "Checksum verified" tar xzf "$work_dir/$tarball" -C "$work_dir" extracted="$work_dir/node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}" if [[ ! -x "$extracted/bin/node" ]]; then fail "Extracted archive has no bin/node" rm -rf "$work_dir"; return 1 fi mkdir -p "$NODE_ROOT" target="$NODE_ROOT/$NODE_BOOTSTRAP_VERSION" rm -rf "$target.incoming" mv "$extracted" "$target.incoming" rm -rf "$target" mv "$target.incoming" "$target" ln -sfn "$NODE_BOOTSTRAP_VERSION" "$NODE_ROOT/current" rm -rf "$work_dir" node_bin="$NODE_ROOT/current/bin" if ! node_is_suitable "$node_bin/node"; then fail "Installed node at $node_bin/node did not run" return 1 fi export PATH="$node_bin:$PATH" ok "Node.js $(node -v) installed with npm $(npm -v 2>/dev/null || echo '?')" return 0 } # Make the Mosaic-managed Node reachable from the next shell as well as this # one. Measured on a greenfield canary run: without this the install finished # rc=0, wrote $PREFIX/bin to ~/.profile, and the next login shell found `mosaic` # and then died on `env: 'node': No such file or directory` — the CLI is a Node # script, so a CLI on PATH without its runtime is a successful install that # produces a broken command. persist_node_on_path() { persist_on_path "$NODE_ROOT/current/bin" "Mosaic-managed Node.js" || true } ensure_node() { if command -v node &>/dev/null && node_is_suitable node; then return 0 fi # A previous run may have installed one that is not on this shell's PATH. if node_is_suitable "$NODE_ROOT/current/bin/node"; then export PATH="$NODE_ROOT/current/bin:$PATH" persist_node_on_path return 0 fi if [[ "${MOSAIC_SKIP_NODE_BOOTSTRAP:-0}" == "1" ]]; then fail "No suitable Node.js and MOSAIC_SKIP_NODE_BOOTSTRAP=1; refusing to download." echo " Install Node.js >= $NODE_MIN_MAJOR yourself, then re-run this script." exit 1 fi require_cmd curl require_cmd tar # sha256sum on Linux, shasum on macOS. Verification is not optional: without a # checksum this would install an unauthenticated runtime. if command -v sha256sum &>/dev/null; then verify_sha256() { sha256sum -c --status "$1"; } elif command -v shasum &>/dev/null; then verify_sha256() { shasum -a 256 -c --status "$1"; } else fail "sha256sum or shasum required to verify the Node.js download" exit 1 fi if ! install_node; then fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run." exit 1 fi persist_node_on_path } # ─── preflight ──────────────────────────────────────────────────────────────── ensure_node 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 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 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")" backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" 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 # Fleet readiness (#1240). Runs in both normal and --check mode: "what is the # state of this host" is exactly the question --check is asked, and a host that # cannot run a seat should not have to discover it from `fleet ps`. check_fleet_transport } # end main main "$@"