Files
stack/tools/install.sh

550 lines
21 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ─── Mosaic Stack Installer / Upgrader ────────────────────────────────────────
#
# Installs both components:
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
# 2. @mosaicstack/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
#
# Quick: curl -fsSL https://mosaicstack.dev/install.sh | bash
# Direct: bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
#
# Remote install (alternative — use -s -- to pass flags):
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh | bash -s --
#
# Flags:
# --check Version check only, no install
# --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main)
# --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_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
GIT_REF="${MOSAIC_REF:-main}"
# MOSAIC_ASSUME_YES env var acts the same as --yes
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
FLAG_YES=true
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; shift 2 ;;
--yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;;
*) shift ;;
esac
done
# ─── 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"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
# ─── 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="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
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() { echo ""; echo "${BOLD}$*${RESET}"; }
# ─── 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
}
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
}
latest_cli_version() {
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
}
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
}
# ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node
require_cmd npm
NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')"
if [[ "$NODE_MAJOR" -lt 20 ]]; then
fail "Node.js >= 20 required (found v$(node --version))"
exit 1
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} (ref: ${GIT_REF})"
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
require_cmd tar
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
cleanup_work() { rm -rf "$WORK_DIR"; }
trap cleanup_work EXIT
info "Downloading framework 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 framework."
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)"
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)"
LATEST="$(latest_cli_version)"
if [[ -n "$CURRENT" ]]; then
dim " Installed: ${CLI_PKG}@${CURRENT}"
else
dim " Installed: (none)"
fi
if [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}"
else
dim " Latest: (registry unreachable)"
fi
echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ -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
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
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
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
warn "Wizard exited non-zero."
echo " You can retry with: ${C}mosaic wizard${RESET}"
echo " Or run gateway install alone: ${C}mosaic gateway install${RESET}"
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="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
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
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 \
&& ok "Install manifest written: $MANIFEST_PATH" \
|| warn "Could not write install manifest (non-fatal)"
echo ""
ok "Done."
fi
} # end main
main "$@"