Files
stack/packages/mosaic/framework/install.sh
Jason Woltje d2d0279e92
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
feat(framework): P4 (1/2) — install.sh upgrade-safe Constitution migration
Make CONSTITUTION/AGENTS/STANDARDS framework-owned (overwritten on upgrade) while
never losing user data:
- FRAMEWORK_OWNED vs USER_SEEDED lists (append-friendly per Lead's ask)
- reconcile_framework_files: overwrite framework-owned from defaults/, backing up a
  divergent copy ONCE to <file>.pre-constitution.bak (advisory); seed-if-absent for
  USER_SEEDED (TOOLS.md)
- anchor rsync preserve excludes to top-level (/<file>) so defaults/<file> still syncs
- never delete *.pre-constitution.bak across upgrades (rsync + cp-fallback)
- snapshot -> sync -> restore-on-failure (ERR/INT/TERM trap) for crash safety
- FRAMEWORK_VERSION 2 -> 3 + v2->v3 migration advisory
- MOSAIC_SYNC_ONLY hook for testability (file phase only, no env side effects)

Fixture suite (test-install-migration.sh) green 7/7: fresh, legacy-edited AGENTS
(overwrite + backup + SOUL/creds survive + idempotent .bak), tuned STANDARDS,
no-TTY, failure-path data integrity. Two real bugs caught + fixed by the fixtures
(unanchored exclude blocking the overwrite; backup deletion on re-upgrade).

file-adapter.ts TS parity + the vitest fixture matrix land in P4 (2/2).

Refs #542

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:35:54 -05:00

368 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
#
# Installs/upgrades the framework DATA to ~/.config/mosaic/.
# No executables are placed on PATH — the mosaic npm CLI is the only binary.
#
# Called by tools/install.sh (the unified installer). Can also be run directly.
#
# Environment:
# MOSAIC_HOME — target directory (default: ~/.config/mosaic)
# MOSAIC_INSTALL_MODE — prompt|keep|overwrite (default: prompt)
# MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check
# MOSAIC_SKIP_SKILLS_SYNC — 1 to skip skill sync
# ──────────────────────────────────────────────────────────────────────────────
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# Files/dirs preserved across upgrades (never overwritten).
# User-created content in these paths survives rsync --delete.
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
# user must not edit them; a divergent copy is backed up once before overwrite).
# USER_SEEDED files are written once on first install, then owned by the user.
# Both lists are APPEND-FRIENDLY — add a new shipped framework file here and to the
# matching list in packages/mosaic/src/config/file-adapter.ts.
FRAMEWORK_OWNED=("CONSTITUTION.md" "AGENTS.md" "STANDARDS.md")
USER_SEEDED=("TOOLS.md")
# Current framework schema version — bump this when the layout changes.
# The migration system uses this to run upgrade steps.
FRAMEWORK_VERSION=3
# ─── colours ──────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m'
CYAN='\033[0;36m' BOLD='\033[1m' RESET='\033[0m'
else
GREEN='' YELLOW='' RED='' CYAN='' BOLD='' RESET=''
fi
ok() { echo -e " ${GREEN}${RESET} $1"; }
warn() { echo -e " ${YELLOW}${RESET} $1" >&2; }
fail() { echo -e " ${RED}${RESET} $1" >&2; }
step() { echo -e "\n${BOLD}$1${RESET}"; }
# ─── snapshot / restore (crash safety for upgrades) ──────────────────────────
SNAPSHOT_DIR=""
make_snapshot() {
is_existing_install || return 0
SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")"
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
}
restore_snapshot() {
[[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0
fail "Install interrupted/failed — restoring previous state from snapshot"
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
}
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
# Reconcile contract files after sync: framework-owned overwrite (backup-once),
# user-seeded seed-if-absent.
reconcile_framework_files() {
local defaults="$TARGET_DIR/defaults" f
[[ -d "$defaults" ]] || return 0
for f in "${FRAMEWORK_OWNED[@]}"; do
[[ -f "$defaults/$f" ]] || continue
if [[ -f "$TARGET_DIR/$f" ]] && ! cmp -s "$TARGET_DIR/$f" "$defaults/$f"; then
if [[ ! -f "$TARGET_DIR/${f}.pre-constitution.bak" ]]; then
cp "$TARGET_DIR/$f" "$TARGET_DIR/${f}.pre-constitution.bak"
warn "$f is now framework-owned and was updated; your previous copy is saved as ${f}.pre-constitution.bak — re-apply intended changes as a .local overlay or policy/ file (see CONSTITUTION.md / constitution/LAYER-MODEL.md)."
fi
fi
cp "$defaults/$f" "$TARGET_DIR/$f"
done
for f in "${USER_SEEDED[@]}"; do
[[ -f "$defaults/$f" ]] || continue
if [[ ! -f "$TARGET_DIR/$f" ]]; then
cp "$defaults/$f" "$TARGET_DIR/$f"
ok "Seeded $f from defaults"
fi
done
}
# ─── helpers ──────────────────────────────────────────────────────────────────
is_existing_install() {
[[ -d "$TARGET_DIR" ]] || return 1
[[ -f "$TARGET_DIR/AGENTS.md" || -f "$TARGET_DIR/SOUL.md" ]]
}
installed_framework_version() {
local vf="$TARGET_DIR/.framework-version"
if [[ -f "$vf" ]]; then
cat "$vf" 2>/dev/null || echo "0"
else
# No version file = legacy install (version 0 or 1)
if [[ -d "$TARGET_DIR/bin" ]]; then
echo "1" # Has bin/ → pre-migration legacy
else
echo "0" # Fresh or unknown
fi
fi
}
write_framework_version() {
echo "$FRAMEWORK_VERSION" > "$TARGET_DIR/.framework-version"
}
select_install_mode() {
case "$INSTALL_MODE" in
keep|overwrite|prompt) ;;
*)
fail "Invalid MOSAIC_INSTALL_MODE='$INSTALL_MODE'. Use: prompt, keep, overwrite."
exit 1
;;
esac
if ! is_existing_install; then
INSTALL_MODE="overwrite"
return
fi
case "$INSTALL_MODE" in
keep|overwrite) ;;
prompt)
if [[ -t 0 ]]; then
echo ""
echo "Existing Mosaic install detected at: $TARGET_DIR"
echo " 1) keep Update framework, preserve local files (SOUL.md, USER.md, etc.)"
echo " 2) overwrite Replace everything"
echo " 3) cancel Abort"
printf "Selection [1/2/3] (default: 1): "
read -r selection
case "${selection:-1}" in
1|k|K|keep) INSTALL_MODE="keep" ;;
2|o|O|overwrite) INSTALL_MODE="overwrite" ;;
*) fail "Install cancelled."; exit 1 ;;
esac
else
INSTALL_MODE="keep"
fi
;;
esac
}
sync_framework() {
local source_real target_real
source_real="$(cd "$SOURCE_DIR" && pwd -P)"
target_real="$(mkdir -p "$TARGET_DIR" && cd "$TARGET_DIR" && pwd -P)"
if [[ "$source_real" == "$target_real" ]]; then
warn "Source and target are the same directory; skipping file sync."
return
fi
if command -v rsync >/dev/null 2>&1; then
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
if [[ "$INSTALL_MODE" == "keep" ]]; then
# Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL
# ~/.config/mosaic/<file> without also excluding defaults/<file> from sync
# (reconcile_framework_files needs the freshly-synced defaults/ copies).
for path in "${PRESERVE_PATHS[@]}"; do
rsync_args+=(--exclude "/$path")
done
fi
rsync "${rsync_args[@]}" "$SOURCE_DIR/" "$TARGET_DIR/"
return
fi
# Fallback: cp-based sync
local preserve_tmp=""
if [[ "$INSTALL_MODE" == "keep" ]]; then
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
for path in "${PRESERVE_PATHS[@]}"; do
if [[ -e "$TARGET_DIR/$path" ]]; then
mkdir -p "$preserve_tmp/$(dirname "$path")"
cp -R "$TARGET_DIR/$path" "$preserve_tmp/$path"
fi
done
fi
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} +
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
rm -rf "$TARGET_DIR/.git"
if [[ -n "$preserve_tmp" ]]; then
for path in "${PRESERVE_PATHS[@]}"; do
if [[ -e "$preserve_tmp/$path" ]]; then
rm -rf "$TARGET_DIR/$path"
mkdir -p "$TARGET_DIR/$(dirname "$path")"
cp -R "$preserve_tmp/$path" "$TARGET_DIR/$path"
fi
done
rm -rf "$preserve_tmp"
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# Migrations — run sequentially from the installed version to FRAMEWORK_VERSION
# ═══════════════════════════════════════════════════════════════════════════════
run_migrations() {
local from_version
from_version="$(installed_framework_version)"
if [[ "$from_version" -ge "$FRAMEWORK_VERSION" ]]; then
return # Already current
fi
step "Running migrations (v${from_version} → v${FRAMEWORK_VERSION})"
# ── Migration: v0/v1 → v2 ─────────────────────────────────────────────────
# Remove bin/ directory — all executables now live in the npm CLI.
# Scripts that were in bin/ are now in tools/_scripts/.
if [[ "$from_version" -lt 2 ]]; then
if [[ -d "$TARGET_DIR/bin" ]]; then
ok "Removing legacy bin/ directory (executables now in npm CLI)"
rm -rf "$TARGET_DIR/bin"
fi
# Remove old mosaic PATH entry from shell profiles
for profile in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do
if [[ -f "$profile" ]] && grep -qF "$TARGET_DIR/bin" "$profile"; then
# Remove the PATH line and the comment above it
sed -i.mosaic-migration-bak \
-e "\|# Mosaic agent framework|d" \
-e "\|$TARGET_DIR/bin|d" \
"$profile"
ok "Cleaned up old PATH entry from $(basename "$profile")"
rm -f "${profile}.mosaic-migration-bak"
fi
done
# Remove stale rails/ symlink
if [[ -L "$TARGET_DIR/rails" ]]; then
rm -f "$TARGET_DIR/rails"
fi
fi
# ── Migration: v2 → v3 (Constitution split) ───────────────────────────────
# CONSTITUTION.md / AGENTS.md / STANDARDS.md become framework-owned (overwritten
# on upgrade). reconcile_framework_files() has already run before this point: it
# backed up any user-edited copy to <file>.pre-constitution.bak and installed the
# new framework version. Nothing further to do here — the advisory was emitted at
# reconcile time. (STANDARDS.local.md composition lands with the overlay composer.)
if [[ "$from_version" -lt 3 ]]; then
ok "Migrated to the Constitution layout (framework-owned CONSTITUTION/AGENTS/STANDARDS)"
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════════════════════
step "Installing Mosaic framework"
mkdir -p "$TARGET_DIR"
select_install_mode
if [[ "$INSTALL_MODE" == "keep" ]]; then
ok "Install mode: keep local files (SOUL.md, USER.md, TOOLS.md, memory/)"
else
ok "Install mode: overwrite"
fi
# Snapshot before any destructive file operation; restore on interrupt/failure.
make_snapshot
trap 'restore_snapshot' ERR INT TERM
sync_framework
# Ensure persistent directories exist
mkdir -p "$TARGET_DIR/memory"
mkdir -p "$TARGET_DIR/credentials"
# Seed defaults — copy framework contract files from defaults/ to framework
# root if not already present. These ship with sensible defaults but must
# never be overwritten once the user has customized them.
#
# This list must match the framework-contract whitelist in
# packages/mosaic/src/config/file-adapter.ts (FileConfigAdapter.syncFramework).
# SOUL.md and USER.md are intentionally NOT seeded here — they are generated
# by `mosaic init` from templates with user-supplied values.
reconcile_framework_files
# Ensure tool scripts are executable
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
ok "Framework synced to $TARGET_DIR"
# Run migrations before post-install (migrations may remove old bin/ etc.)
run_migrations
# File-system phase complete and consistent — clear the restore trap.
trap - ERR INT TERM
cleanup_snapshot
# Testability / minimal-install hook: stop after the file-system phase, before any
# environment-touching post-install steps (runtime linking, MCP setup, skills, doctor).
if [[ "${MOSAIC_SYNC_ONLY:-0}" == "1" ]]; then
write_framework_version
ok "Sync-only mode: file phase complete"
exit 0
fi
step "Post-install tasks"
SCRIPTS="$TARGET_DIR/tools/_scripts"
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
if "$SCRIPTS/mosaic-link-runtime-assets" >/dev/null 2>&1; then
ok "Runtime assets linked"
else
warn "Runtime asset linking failed (non-fatal)"
fi
fi
if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then
ok "sequential-thinking MCP configured"
else
if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)"
else
fail "sequential-thinking MCP setup failed (hard requirement)."
exit 1
fi
fi
fi
if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
"$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)"
fi
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
"$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)"
fi
if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
"$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)"
fi
if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
"$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details"
fi
# Write version stamp AFTER everything succeeds
write_framework_version
# ── Summary ──────────────────────────────────────────────────
echo ""
echo -e "${GREEN}${BOLD} Mosaic framework installed.${RESET}"
echo ""
if [[ ! -f "$TARGET_DIR/SOUL.md" ]]; then
echo -e " Run ${CYAN}mosaic init${RESET} to set up your agent identity."
echo ""
fi