All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Invert the framework updater from a denylist ("framework owns everything unless
preserved") to an explicit allow-list manifest ("operator owns everything unless
framework"). A path the manifest never anticipated resolves to operator-owned by
the fail-safe default, so it is structurally unreachable by any write or prune.
Root cause (#791): `mosaic update` re-seeds via `install.sh` keep-mode, whose
`rsync -a --delete` + hand-maintained PRESERVE_PATHS denylist wiped operator
paths the denylist forgot (agents/*.conf, policy/*.md, *.local.md, harvester
SOP, tools/_lib/credentials.json, unanticipated fleet files).
- framework-manifest.txt: single SSOT ([framework]/[operator], deny-wins,
UNKNOWN=>operator fail-safe), read by BOTH installers.
- src/framework/manifest.ts: pure resolver (parse/matchGlob/resolveOwnership/
frameworkSubtreeRoots/planPrune) — the testable seam.
- tools/_lib/manifest.sh: bash resolver (compiled globs, fork-free hot path),
sourced by install.sh; parity-tested against the TS resolver.
- install.sh keep mode is now manifest-driven (no --delete): overlay-copy
framework files, scoped-prune only retired framework files inside shipped
subtrees. Operator + unknown paths are never written or deleted.
- file-ops.syncDirectory gains an isOperatorOwned guard; file-adapter derives it
from the shared manifest, replacing the drifted hardcoded preservePaths.
Tests (TDD, red->green):
- HARD GATE test-upgrade-manifest-guard.sh: 10 operator sentinels (incl. an
unanticipated one) survive a keep-mode reseed byte-identical + mtime-unchanged;
retired framework file pruned; secret value absent from output. RED 31 fail on
the old installer -> GREEN 48 pass. Wired merge-blocking into CI.
- manifest-parity.spec.ts (§6.1): bash<->TS agree on 34 paths + subtree roots.
- manifest.spec.ts: 18 tests incl. planPrune property test + shipped-tree
completeness (§6.2).
- test-install-migration.sh F6 flipped: an unanticipated operator fleet file now
MUST survive keep-mode reseed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
404 lines
17 KiB
Bash
Executable File
404 lines
17 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}"
|
|
|
|
# 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
|
|
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
|
|
# 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.
|
|
manifest_load
|
|
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
|
|
}
|
|
|
|
# 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
|
|
|
|
# 1) Overlay copy — every framework-owned source file, refreshed only when its
|
|
# bytes changed (no mtime churn on unchanged files, never on operator files).
|
|
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 < <(find "$src" -type f -print0)
|
|
|
|
# 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.
|
|
while IFS= read -r root; do
|
|
[[ -n "$root" && -d "$dst/$root" ]] || continue
|
|
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 < <(find "$dst/$root" -type f -print0)
|
|
# Drop framework dirs left empty by the prune (never touches a dir that still
|
|
# holds an operator file — those are never emptied).
|
|
find "$dst/$root" -type d -empty -delete 2>/dev/null || true
|
|
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 <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"
|
|
|
|
# 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
|