#!/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=""; } # ─── durable operator-config snapshot (#791 PR2) ───────────────────────────── # A SECOND, independent safety layer, distinct from SNAPSHOT_DIR above: # • SNAPSHOT_DIR is ephemeral (/tmp, deleted on success) and mirrors the WHOLE # target for CRASH rollback if the sync aborts mid-write. # • DURABLE_SNAPSHOT_DIR is RETAINED, holds only the operator-owned surface, and # lives OUTSIDE the framework tree and any repo. It exists for the failure the # crash-rollback cannot see: a sync that finishes "successfully" yet a # manifest/logic bug let it modify an operator file. verify_operator_surface() # (post-sync) heals from it; `mosaic restore` recovers from it days later. # Path convention is mirrored in packages/mosaic/src/commands/restore.ts — keep # the two in sync (there is no shared code across the bash/TS boundary). DURABLE_SNAPSHOT_DIR="" backup_root() { printf '%s/mosaic/backups' "${XDG_STATE_HOME:-$HOME/.local/state}"; } # Relative paths that a migration INTENTIONALLY removes from the target (e.g. the # legacy bin/ tree). Such a path is operator-classified by the manifest (unknown⇒ # operator), so the durable snapshot captures it — but its post-migration absence # is correct, NOT a manifest bug. run_migrations() records each removal here so # verify_operator_surface() does not "heal" it back and silently undo the # migration (which would then be skipped forever once the version is stamped). MIGRATION_REMOVED_PATHS=() # True (0) if $1 (a path relative to TARGET_DIR) equals or lives under a path a # migration deliberately removed this run. is_migration_removed() { local rel="$1" removed for removed in ${MIGRATION_REMOVED_PATHS[@]+"${MIGRATION_REMOVED_PATHS[@]}"}; do [[ -n "$removed" ]] || continue [[ "$rel" == "$removed" || "$rel" == "$removed"/* ]] && return 0 done return 1 } # True (0) if any parent directory of $1 (relative to TARGET_DIR) is a symlink. # Restoring THROUGH a symlinked ancestor would let cp write snapshot contents — # possibly secrets — outside the target (CWE-59), so the verify net refuses it. has_symlinked_parent() { local rel="$1" dir p seg dir="$(dirname "$rel")" [[ "$dir" == "." ]] && return 1 p="$TARGET_DIR" local IFS='/' for seg in $dir; do [[ -n "$seg" ]] || continue p="$p/$seg" [[ -L "$p" ]] && return 0 done return 1 } # Emit (NUL-delimited, into file $1) the operator-owned relative paths that exist # under TARGET_DIR, classified via the shared manifest (deny-wins; unknown⇒ # operator). Returns non-zero if the filesystem walk itself failed — we must # NEVER snapshot from a truncated scan (a `< <(find …)` process substitution # would hide that error; capture-then-check does not — cf. #791 blocker-D1). enumerate_operator_files() { local out="$1" scan abs rel scan="$(mktemp)" if ! find "$TARGET_DIR" -type f -print0 > "$scan"; then rm -f "$scan" return 1 # OP-SCAN-GUARD fi : > "$out" while IFS= read -r -d '' abs; do rel="${abs#"$TARGET_DIR"/}" # Not operator config: version marker and any VCS metadata. case "$rel" in .framework-version|.git|.git/*) continue ;; esac manifest_is_framework "$rel" || printf '%s\0' "$rel" >> "$out" done < "$scan" rm -f "$scan" } # Retain only the newest MOSAIC_BACKUP_RETENTION (default 5) snapshots. The # pre-update- names sort lexicographically = chronologically, so a # reverse sort is newest-first. Pruning failures are non-fatal (they only leave # extra old backups); the enclosing find's status is still honored, not swallowed. prune_durable_snapshots() { local root keep list d i=0 root="$(backup_root)" keep="${MOSAIC_BACKUP_RETENTION:-5}" [[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5 list="$(mktemp)" if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then rm -f "$list"; return 0 fi # Newest-first ordering needs `sort` (`-o` writes back in place — no `mv` # dependency); if it is somehow unavailable, leave the backups untouched rather # than risk pruning in an undefined order. if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then rm -f "$list"; return 0 fi while IFS= read -r d; do [[ -n "$d" ]] || continue i=$((i + 1)) (( i > keep )) && rm -rf "$d" done < "$list" rm -f "$list" } # Take the durable pre-update snapshot BEFORE any mutation. Fail-OPEN: the durable # snapshot is a recovery bonus on top of the manifest (which already keeps the # sync out of operator paths) and the crash-rollback — so an un-writable backup # location warns and continues rather than blocking the upgrade. Everything it # creates is private (umask 077 + explicit 0700 dirs / 0600 files): the snapshot # mirrors operator config, which may hold secrets, and must never be world-readable. make_durable_snapshot() { is_existing_install || return 0 local root ts dir list rel src dst count=0 old_umask root="$(backup_root)" # Fail-open if we cannot even stamp a timestamp: the durable snapshot is a # recovery bonus and must never be the thing that aborts an upgrade. ts="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || true)" if [[ -z "$ts" ]]; then warn "Durable snapshot skipped: no UTC timestamp available (upgrade continues)." return 0 fi # umask 077 makes every dir/file the snapshot creates private from birth (it # mirrors operator config, which may hold secrets). It is PROCESS-global, so we # save and restore it around exactly this block — otherwise every later sync # copy and new framework dir would inherit 0600/0700 instead of 0644/0755. old_umask="$(umask)" umask 077 if ! mkdir -p "$root"; then umask "$old_umask" warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)." return 0 fi chmod 700 "$root" 2>/dev/null || true dir="$root/pre-update-$ts" if [[ -e "$dir" ]]; then # same-second re-run: disambiguate local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n" fi if ! mkdir -p "$dir"; then umask "$old_umask" warn "Durable snapshot skipped: cannot create $dir (upgrade continues)." return 0 fi chmod 700 "$dir" list="$(mktemp)" if ! enumerate_operator_files "$list"; then umask "$old_umask" warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)." rm -f "$list"; rmdir "$dir" 2>/dev/null || true return 0 fi while IFS= read -r -d '' rel; do src="$TARGET_DIR/$rel"; dst="$dir/$rel" [[ -f "$src" ]] || continue mkdir -p "$(dirname "$dst")" if ! cp "$src" "$dst"; then warn "Durable snapshot: could not copy operator file '$rel' (skipped)." continue fi chmod 600 "$dst" 2>/dev/null || true count=$((count + 1)) done < "$list" rm -f "$list" # Tighten every dir the copy created (mkdir -p honors umask, but be explicit). find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above) DURABLE_SNAPSHOT_DIR="$dir" ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)" prune_durable_snapshots } # Post-sync safety net: a keep-mode upgrade must NEVER modify an operator file. # Compare every file in the durable snapshot to its current target counterpart; # any that changed (or vanished) was touched by a framework bug — restore it from # the snapshot and warn loudly. This does NOT abort: the framework itself synced # correctly; we only heal the operator collateral. Runs after the restore trap is # disarmed so its corrective copies can't spuriously trip a full rollback, and # every step is guarded so `set -e` cannot exit silently mid-heal (cf. blocker-D2). verify_operator_surface() { [[ -n "$DURABLE_SNAPSHOT_DIR" && -d "$DURABLE_SNAPSHOT_DIR" ]] || return 0 local scan snap rel cur healed=0 scan="$(mktemp)" if ! find "$DURABLE_SNAPSHOT_DIR" -type f -print0 > "$scan"; then rm -f "$scan" warn "Post-upgrade verify skipped: could not enumerate the pre-update snapshot at $DURABLE_SNAPSHOT_DIR." return 0 fi while IFS= read -r -d '' snap; do rel="${snap#"$DURABLE_SNAPSHOT_DIR"/}" cur="$TARGET_DIR/$rel" # A migration may legitimately delete an operator-classified path (e.g. legacy # bin/). Its absence is intended — do not heal it back, or the migration is # silently undone and never re-runs once the version is stamped (#791 PR2). is_migration_removed "$rel" && continue # MIGRATION-SKIP-GUARD if [[ ! -e "$cur" ]] || ! cmp -s "$snap" "$cur"; then # Never restore THROUGH a symlink: an operator path swapped for a link would # otherwise let cp write snapshot contents (possibly secrets) outside the # target (CWE-59). Refuse a symlinked parent; drop a symlinked leaf and write # a real file in its place. if has_symlinked_parent "$rel"; then warn "Operator path '$rel' has a symlinked parent under $TARGET_DIR; refusing to restore through it (possible tampering) — recover it manually from $DURABLE_SNAPSHOT_DIR." continue fi [[ -L "$cur" ]] && rm -f "$cur" # SYMLINK-LEAF-GUARD # Guard mkdir too: under set -e (trap already disarmed) a bare failure would # exit the whole installer before the recovery pointer below is emitted. if ! mkdir -p "$(dirname "$cur")"; then warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored (parent dir unavailable) — recover it manually from $DURABLE_SNAPSHOT_DIR." continue fi if cp "$snap" "$cur"; then chmod 600 "$cur" 2>/dev/null || true warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel" healed=$((healed + 1)) else warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored — recover it manually from $DURABLE_SNAPSHOT_DIR." fi fi done < "$scan" rm -f "$scan" if (( healed > 0 )); then warn "$healed operator file(s) were unexpectedly changed by this upgrade and were restored from the pre-update snapshot. A keep-mode upgrade must never modify operator files — this indicates a framework manifest bug; please report it (#791)." fi } # 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 # bin/ and the rails symlink are operator-classified by the manifest (unknown⇒ # operator) and thus captured in the durable snapshot; record them as # intentional removals so the post-sync verify net does not restore them. MIGRATION_REMOVED_PATHS+=("bin" "rails") 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 # Durable, operator-scoped backup taken BEFORE any mutation (#791 PR2). Kept # outside the framework tree; recovered later via `mosaic restore`. Fail-open. make_durable_snapshot 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 # Post-sync safety net: heal any operator file a manifest bug let the sync touch, # using the durable pre-update snapshot (#791 PR2). Runs with the trap disarmed so # a corrective copy can't spuriously trigger a full rollback. verify_operator_surface # VERIFY-NET (#791 PR2) 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