ci/woodpecker/pr/ci Pipeline was canceled
The framework's install.sh ends by running mosaic-link-runtime-assets, which asks the `mosaic` CLI whether lease enforcement can be activated before deciding whether to wire the #828 hooks into settings.json. Part 1 (framework) runs before Part 2 (npm CLI), so on a first install there is no CLI to ask. The script takes its fail-safe branch, prints a four-line ERROR, and writes settings.json with mutator-gate.py and receipt-observer-client.py stripped out. Measured on canary 1125, rolled back to greenfield, `--next --yes`, no TTY: framework template ~/.config/mosaic/runtime/claude/settings.json mutator-gate.py 1 occurrence receipt-observer-client.py 1 occurrence installed ~/.claude/settings.json after a clean rc=0 install mutator-gate.py wired: False receipt-observer-client.py wired: False So enforcement ends up off because of the order the two halves install in, not because of anything about the host. Falsified by running the same script by hand once the CLI existed: rc=0, both hooks wired: True. The guard's real verdict on that host was 'activatable' the whole time. This adds one more pass after Part 2. The script is idempotent (unchanged files are skipped), so on an upgrade — CLI already present, first pass already correct — it is a no-op. It deliberately does not pass --allow-inactive-enforcement: Part 1 does not either, and a repair pass must not be more permissive than the pass it corrects. Co-Authored-By: Claude Opus 5 <[email protected]>
1133 lines
43 KiB
Bash
Executable File
1133 lines
43 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)"
|
||
}
|
||
|
||
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
|
||
|
||
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
|
||
|
||
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
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Post-install repair: link runtime assets again, now that the CLI exists
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
#
|
||
# The framework's own install.sh ends by running `mosaic-link-runtime-assets`,
|
||
# and that script asks the `mosaic` CLI whether lease enforcement can be
|
||
# activated on this host before deciding whether to wire the #828 enforcement
|
||
# hooks into settings.json. On a first install there is no CLI to ask — Part 1
|
||
# runs before Part 2 — so it takes its fail-safe branch, prints a four-line
|
||
# ERROR, and writes settings.json with the hooks stripped out.
|
||
#
|
||
# The result is that a greenfield host always ends up with enforcement
|
||
# disabled, decided by the order the two halves install in rather than by
|
||
# anything about the host. Measured on canary 1125, greenfield, both the
|
||
# `--ref` and `--next` lanes.
|
||
#
|
||
# Running it once more here lets the guard reach its real verdict. The script
|
||
# is idempotent — unchanged files are skipped — so on an upgrade, where the CLI
|
||
# was already present and the first pass already succeeded, this is a no-op.
|
||
# No `--allow-inactive-enforcement`: Part 1 does not pass it either, and this
|
||
# pass must not be more permissive than the one it is correcting.
|
||
if [[ "$FLAG_CHECK" == "false" && "$FLAG_FRAMEWORK" == "true" && "$FLAG_CLI" == "true" ]]; then
|
||
RELINK_SCRIPT="$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets"
|
||
if [[ -x "$RELINK_SCRIPT" && -x "$PREFIX/bin/mosaic" ]]; then
|
||
step "Runtime assets (re-check with CLI present)"
|
||
if PATH="$PREFIX/bin:$PATH" "$RELINK_SCRIPT" >/dev/null; then
|
||
ok "Runtime assets linked"
|
||
else
|
||
warn "Runtime asset linking is still degraded — see message above."
|
||
fi
|
||
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
|
||
|
||
} # end main
|
||
|
||
main "$@"
|