717 lines
33 KiB
Bash
Executable File
717 lines
33 KiB
Bash
Executable File
#!/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-<UTC-ts> 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 <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
|
|
|
|
# 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
|