#!/usr/bin/env bash # -E (errtrace): the ERR trap must propagate INTO functions and command # substitutions. Without it the `trap restore_snapshot ERR` set below is dead # code for any failure inside sync_framework_keep() (its whole body runs in a # function) — a mid-sync failure would abort with a half-written target and NO # rollback (#791 B1). Keep -E first so every later function inherits the trap. set -Eeuo 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}" # Shared framework path-ownership manifest reader (#791). Parity with # packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt. # Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0). # shellcheck source=tools/_lib/manifest.sh source "$SOURCE_DIR/tools/_lib/manifest.sh" # Which paths a keep-mode upgrade may touch is no longer a hand-maintained # denylist. It is derived from the shared framework-manifest.txt (#791): the # updater only ever creates/overwrites framework-owned paths and only prunes a # retired framework file inside a shipped framework subtree. Everything else — # every operator file, and every path the manifest never anticipated — is # operator-owned by default (fail-safe) and is never written or deleted. See # sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts. # 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 # mktemp -d creates the dir 0700 — the snapshot (which mirrors operator config, # possibly including secrets) is never world-readable. SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")" # The snapshot MUST be complete: restore rebuilds the target from it, so a # partial capture (unreadable file, disk-full, I/O error) would silently # discard whatever it missed. If cp -a cannot copy the whole tree, abort NOW — # before the restore trap is armed and before anything is mutated. Fail closed # rather than proceed with a snapshot we cannot trust (#791 blocker-2). if ! cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/"; then fail "Could not capture a complete pre-upgrade snapshot of $TARGET_DIR — aborting before any changes were made (fail-closed)." rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR="" exit 1 fi } restore_snapshot() { # Disarm the trap first: restore runs under `set -e`, and a non-zero step # inside it must not re-enter this handler (errtrace makes ERR fire in # functions now). One restore attempt, then let the script exit non-zero. trap - ERR INT TERM [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0 fail "Install interrupted/failed — restoring previous state from snapshot" # Reset the target before rebuilding from the snapshot — but CHECK it. Under # `set -e` (trap already disarmed) a bare `rm -rf; mkdir -p` that fails would # exit the whole script immediately, after `rm` may have deleted part of the # target, WITHOUT ever printing the recovery pointer below — the operator would # be left with a half-removed target and no idea the snapshot survives in /tmp. # Test the reset explicitly (like the cp -a below), and on failure keep the # snapshot and tell the operator where it is (#791 blocker-D2). if ! rm -rf "$TARGET_DIR" || ! mkdir -p "$TARGET_DIR"; then fail "Snapshot restore could not reset $TARGET_DIR. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually." return 1 fi # Surface an incomplete restore instead of swallowing it: the snapshot is the # last good copy, so if cp cannot fully rebuild the target we must NOT delete # the snapshot — point the operator at it for manual recovery (#791 blocker-2). if ! cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/"; then fail "Snapshot restore did not complete cleanly. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually." return 1 fi } 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 # Already current — skip to avoid mtime churn. if [[ -f "$TARGET_DIR/$f" ]] && cmp -s "$TARGET_DIR/$f" "$defaults/$f"; then continue fi if [[ -f "$TARGET_DIR/$f" && ! -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 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 [[ "$INSTALL_MODE" == "keep" ]]; then # The `mosaic update` path. Manifest-driven, never-deleting-outside-framework: # operator config is structurally protected (#791). No rsync --delete here. # The manifest is already loaded+validated in main() BEFORE the snapshot/trap # (a fail-closed manifest must abort without ever restoring over operator # files — see the pre-flight in main, #791 blocker-1). sync_framework_keep return fi # overwrite mode — a full replace, chosen only for a fresh install or when the # operator explicitly asks to replace everything. No operator state to protect. sync_framework_overwrite } # Enumerate a NUL-delimited file list via `find` into the temp file $1, failing # CLOSED if find errors. We capture to a checked file instead of consuming # `< <(find …)` directly because a process substitution discards the producer's # exit status: an EACCES/I/O failure partway through a scan would truncate the # list yet leave the reading `while` loop exiting 0, so a partial upgrade would # commit and report success and the ERR/restore trap would never fire. Running # find to completion first, then checking its status, turns that silent # truncation into a fail-closed abort that the restore trap can act on (#791 # blocker-D1). $1 after the shift is the scan root — named in the error. _scan_or_die() { local out="$1"; shift if ! find "$@" -print0 > "$out"; then fail "Could not enumerate framework files under '$1' — aborting before committing an incomplete sync (fail-closed)." return 1 # D1-GUARD fi } # Keep-mode sync: create/refresh framework-owned files and prune only retired # framework files inside shipped framework subtrees. Operator-owned and unknown # paths (fail-safe default) are never written and never deleted — the #791 HARD # GATE. Single code path (no rsync) so it is byte-for-byte parity-testable. sync_framework_keep() { local src="$SOURCE_DIR" dst="$TARGET_DIR" abs rel root list # 1) Overlay copy — every framework-owned source file, refreshed only when its # bytes changed (no mtime churn on unchanged files, never on operator files). # The source scan is captured fail-closed (#791 blocker-D1): a find failure # aborts the sync (→ ERR trap → restore) rather than silently truncating it. list="$(mktemp)" _scan_or_die "$list" "$src" -type f || { rm -f "$list"; return 1; } while IFS= read -r -d '' abs; do rel="${abs#"$src"/}" case "$rel" in .git|.git/*|.framework-version|*.pre-constitution.bak) continue ;; esac manifest_is_framework "$rel" || continue if [[ -f "$dst/$rel" ]] && cmp -s "$abs" "$dst/$rel"; then continue; fi [[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}" cp "$abs" "$dst/$rel" done < "$list" rm -f "$list" # 2) Scoped prune — within each shipped framework subtree root, remove # framework-owned target files the current source no longer ships. Operator # carve-outs (e.g. tools/_lib/credentials.json) resolve to operator and are # skipped; unknown paths resolve to operator too — both are unreachable here. # Each subtree scan is captured fail-closed for the same reason as the copy. while IFS= read -r root; do [[ -n "$root" && -d "$dst/$root" ]] || continue list="$(mktemp)" _scan_or_die "$list" "$dst/$root" -type f || { rm -f "$list"; return 1; } while IFS= read -r -d '' abs; do rel="${abs#"$dst"/}" case "$rel" in *.pre-constitution.bak) continue ;; esac [[ -f "$src/$rel" ]] && continue # still shipped manifest_is_framework "$rel" || continue rm -f "$abs" done < "$list" rm -f "$list" # Drop framework dirs left empty by the prune (never touches a dir that still # holds an operator file — those are never emptied). A genuine find failure # (unreadable dir) is surfaced as a warning rather than silently swallowed; # the "directory not empty" races we tolerate are ignored via -delete's own # rc, not by hiding stderr — so a real error is still visible to the operator. if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then warn "prune: could not fully sweep empty framework dirs under $root (left as-is)" fi done < <(manifest_subtree_roots) } # Overwrite-mode sync: full replace. Only reached for a fresh install or an # explicit operator "replace everything" choice, so nothing is preserved. sync_framework_overwrite() { if command -v rsync >/dev/null 2>&1; then rsync -a --delete \ --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \ "$SOURCE_DIR/" "$TARGET_DIR/" return 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" } # ═══════════════════════════════════════════════════════════════════════════════ # 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 .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 # Pre-flight (keep mode): load + validate the framework manifest BEFORE taking a # snapshot or arming the restore trap. A fail-closed manifest (missing / empty / # malformed) must abort here WITHOUT deleting or restoring over operator files — # the snapshot/restore path exists only for a genuine mid-sync mutation failure, # not for a validation failure that has touched nothing yet (#791 blocker-1). if [[ "$INSTALL_MODE" == "keep" ]]; then manifest_load fi # Snapshot before any destructive file operation; restore on interrupt/failure. # The trap MUST exit after restoring: a bash INT/TERM handler that merely returns # does NOT terminate the script — execution would resume past the interrupt, # clear the snapshot, and report success, leaving a partial post-interrupt update # (#791 blocker-A). `restore_snapshot; exit 1` guarantees a non-zero exit for # both the errtrace (ERR) and signal (INT/TERM) paths. make_snapshot trap 'restore_snapshot; exit 1' ERR INT TERM sync_framework # Ensure persistent directories exist mkdir -p "$TARGET_DIR/memory" mkdir -p "$TARGET_DIR/credentials" # Reconcile contract files from defaults/ into the framework root: framework-owned # files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent # copy is backed up once); user-seeded files (TOOLS) are written on first install only. # # 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