1154 lines
44 KiB
Bash
Executable File
1154 lines
44 KiB
Bash
Executable File
#!/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-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 <branch>] [--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<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
|
||
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 "$@"
|