Files
stack/tools/install.sh
T
be-coder-07 378bc1afe3
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/greenfield-install Pipeline failed
fix(installer): close detector false-pass gaps
2026-08-05 19:00:22 -05:00

1928 lines
83 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ─── Mosaic Stack Installer / Upgrader ────────────────────────────────────────
#
# Installs both components:
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
# 2. @mosaicstack/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
#
# Quick: curl -fsSL https://mosaicstack.dev/install.sh | bash
# Direct: bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
#
# Remote install (alternative — use -s -- to pass flags):
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh | bash -s --
#
# Flags:
# --check Version check only, no install
# --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main)
# --next Prerelease lane: try fast npm @next install for CLI +
# gateway from the Gitea registry, then fall back to a
# source build at next if unavailable. Explicit
# --ref/MOSAIC_REF wins and uses the source path.
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the
# registry @latest. Zero registry writes — packs local
# tarballs and installs them globally. Use to test a branch
# end-to-end before cutting a release.
# --yes Accept all defaults; headless/non-interactive install
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
#
# Environment:
# MOSAIC_HOME — framework install dir (default: ~/.config/mosaic)
# MOSAIC_REGISTRY — npm registry URL (default: Gitea instance)
# MOSAIC_SCOPE — npm scope (default: @mosaicstack)
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_NEXT — equivalent to --next (set to 1)
# MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# ──────────────────────────────────────────────────────────────────────────────
#
# Wrapped in main() for safe curl-pipe usage.
set -euo pipefail
main() {
# ─── parse flags ──────────────────────────────────────────────────────────────
FLAG_CHECK=false
FLAG_FRAMEWORK=true
FLAG_CLI=true
FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false
FLAG_UNINSTALL=false
FLAG_DEV=false
FLAG_NEXT=false
FLAG_STATE_SELF_TEST=false
GIT_REF="${MOSAIC_REF:-main}"
GIT_REF_EXPLICIT=false
if [[ -n "${MOSAIC_REF:-}" ]]; then
GIT_REF_EXPLICIT=true
fi
# MOSAIC_ASSUME_YES env var acts the same as --yes
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
FLAG_YES=true
fi
# MOSAIC_DEV env var acts the same as --dev
if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true
fi
# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with
# source fallback from the permanent next integration branch unless
# MOSAIC_REF/--ref explicitly wins.
if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
FLAG_NEXT=true
if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then
GIT_REF="next"
fi
fi
installer_usage() {
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;;
--ref)
if [[ $# -lt 2 ]] || [[ -z "$2" ]]; then
printf 'Error: Missing value for --ref\n' >&2
installer_usage
exit 2
fi
if [[ "$2" == -* ]]; then
printf 'Error: Unknown argument: %s\n' "$2" >&2
installer_usage
exit 2
fi
GIT_REF="$2"
GIT_REF_EXPLICIT=true
shift 2
;;
--dev) FLAG_DEV=true; shift ;;
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
--yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;;
# Internal acceptance seam: exercises the real journal/snapshot/rollback
# machinery against representative installer mutations. Not a user mode.
--state-machine-self-test) FLAG_STATE_SELF_TEST=true; shift ;;
*)
printf 'Error: Unknown argument: %s\n' "$1" >&2
installer_usage
exit 2
;;
esac
done
# Explicit refs represent a request for that exact source tree. Keep --next as
# a lane selector, but do not install the registry @next package for a different
# ref than the permanent next branch.
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then
FLAG_DEV=true
fi
if [[ "$FLAG_YES" == "true" ]]; then
export MOSAIC_ASSUME_YES=1
fi
# ─── constants ────────────────────────────────────────────────────────────────
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/mosaic"
GATEWAY_PKG="${SCOPE}/gateway"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
# In dev (build-from-source) mode the gateway is installed globally from a
# locally-built tarball. Tell the wizard / gateway-config stage NOT to overwrite
# it with the registry @latest build (honored by gatewayConfigStage).
if [[ "$FLAG_DEV" == "true" ]]; then
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# Shared monorepo checkout (populated on demand by ensure_monorepo).
WORK_DIR=""
EXTRACTED_DIR=""
newest_matching_file() {
local dir="$1"
local pattern="$2"
local matches=()
[[ -d "$dir" ]] || return 0
shopt -s nullglob
# shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern.
matches=("$dir"/$pattern)
shopt -u nullglob
[[ "${#matches[@]}" -gt 0 ]] || return 0
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
ls -1t "${matches[@]}" 2>/dev/null | head -1
}
# ─── uninstall path ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line.
# Gateway teardown: if mosaic CLI is still available, delegates to it.
# Does NOT touch gateway DB/storage — user must handle that separately.
if [[ "$FLAG_UNINSTALL" == "true" ]]; then
echo ""
echo "${BOLD:-}Mosaic Uninstaller (shell fallback)${RESET:-}"
echo ""
SCOPE_LINE="${SCOPE:-@mosaicstack}:registry=${REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
NPMRC_FILE="$HOME/.npmrc"
# Gateway: try mosaic CLI first, then check pid file
if command -v mosaic &>/dev/null; then
echo "${B:-}${RESET:-} Attempting gateway uninstall via mosaic CLI…"
if mosaic gateway uninstall --yes 2>/dev/null; then
echo "${G:-}${RESET:-} Gateway uninstalled via CLI."
else
echo "${Y:-}${RESET:-} Gateway uninstall via CLI failed or not installed — skipping."
fi
else
# Look for pid file and stop daemon if running
GATEWAY_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}/../mosaic-gateway"
PID_FILE="$GATEWAY_HOME/gateway.pid"
if [[ -f "$PID_FILE" ]]; then
PID="$(cat "$PID_FILE" 2>/dev/null || true)"
if [[ -n "$PID" ]] && kill -0 "$PID" 2>/dev/null; then
echo "${B:-}${RESET:-} Stopping gateway daemon (pid $PID)…"
kill "$PID" 2>/dev/null || true
sleep 1
fi
fi
echo "${Y:-}${RESET:-} mosaic CLI not found — skipping full gateway teardown."
echo " Run 'mosaic gateway uninstall' separately if the CLI is available."
fi
# Framework directory
if [[ -d "$MOSAIC_HOME" ]]; then
echo "${B:-}${RESET:-} Removing framework: $MOSAIC_HOME"
rm -rf "$MOSAIC_HOME"
echo "${G:-}${RESET:-} Framework removed."
else
echo "${Y:-}${RESET:-} Framework directory not found: $MOSAIC_HOME"
fi
# Runtime assets: restore backups or remove managed copies
echo "${B:-}${RESET:-} Reversing runtime asset copies…"
declare -a RUNTIME_DESTS=(
"$HOME/.claude/CLAUDE.md"
"$HOME/.claude/settings.json"
"$HOME/.claude/hooks-config.json"
"$HOME/.claude/context7-integration.md"
"$HOME/.config/opencode/AGENTS.md"
"$HOME/.codex/instructions.md"
)
for dest in "${RUNTIME_DESTS[@]}"; do
base="$(basename "$dest")"
dir="$(dirname "$dest")"
# Find most recent backup
backup=""
if [[ -d "$dir" ]]; then
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
fi
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
cp "$backup" "$dest"
rm -f "$backup"
echo " Restored: $dest"
elif [[ -f "$dest" ]]; then
rm -f "$dest"
echo " Removed: $dest"
fi
done
# npmrc scope line
if [[ -f "$NPMRC_FILE" ]] && grep -qF "$SCOPE_LINE" "$NPMRC_FILE" 2>/dev/null; then
echo "${B:-}${RESET:-} Removing $SCOPE_LINE from $NPMRC_FILE…"
# Use sed to remove the exact line (in-place, portable)
if sed -i.mosaic-uninstall-bak "\|^${SCOPE_LINE}\$|d" "$NPMRC_FILE" 2>/dev/null; then
rm -f "${NPMRC_FILE}.mosaic-uninstall-bak"
echo "${G:-}${RESET:-} npmrc entry removed."
else
# BSD sed syntax (macOS)
sed -i '' "\|^${SCOPE_LINE}\$|d" "$NPMRC_FILE" 2>/dev/null || \
echo "${Y:-}${RESET:-} Could not auto-remove npmrc line — remove it manually: $SCOPE_LINE"
fi
fi
# npm CLI package
echo "${B:-}${RESET:-} Uninstalling npm package: ${CLI_PKG}…"
if npm uninstall -g "${CLI_PKG}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
echo "${G:-}${RESET:-} CLI package removed."
else
echo "${Y:-}${RESET:-} npm uninstall failed — you may need to run manually:"
echo " npm uninstall -g ${CLI_PKG}"
fi
echo ""
echo "${G:-}${RESET:-} Uninstall complete."
exit 0
fi
# ─── colours ──────────────────────────────────────────────────────────────────
if [[ "${MOSAIC_NO_COLOR:-0}" == "1" ]] || ! [[ -t 1 ]]; then
R="" G="" Y="" B="" C="" DIM="" BOLD="" RESET=""
else
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m'
B=$'\033[0;34m' C=$'\033[0;36m' DIM=$'\033[2m'
BOLD=$'\033[1m' RESET=$'\033[0m'
fi
info() { echo "${B}${RESET} $*"; }
ok() { echo "${G}${RESET} $*"; }
warn() { echo "${Y}${RESET} $*"; }
fail() { echo "${R}${RESET} $*" >&2; }
dim() { echo "${DIM}$*${RESET}"; }
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
is_next_registry_lane() {
[[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]]
}
source_ref_details() {
if is_next_registry_lane; then
echo "ref: next, --next prerelease lane"
elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
echo "ref: next, --next prerelease lane (build-from-source)"
elif [[ "$FLAG_NEXT" == "true" ]]; then
echo "ref: ${GIT_REF}, --next requested, explicit ref wins"
else
echo "ref: ${GIT_REF}"
fi
}
# ─── helpers ──────────────────────────────────────────────────────────────────
require_cmd() {
if ! command -v "$1" &>/dev/null; then
fail "Required command not found: $1"
echo " Install it and re-run this script."
return 1
fi
}
installed_cli_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}")" || true
if [[ -n "$json" ]]; then
node -e "
const d = JSON.parse(process.argv[1]);
const v = d?.dependencies?.['${CLI_PKG}']?.version ?? '';
process.stdout.write(v);
" "$json" || true
fi
}
installed_gateway_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}")" || true
if [[ -n "$json" ]]; then
node -e "
const d = JSON.parse(process.argv[1]);
const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? '';
process.stdout.write(v);
" "$json" || true
fi
}
latest_cli_version() {
npm view "${CLI_PKG}" version --registry="$REGISTRY" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}" || true
}
next_cli_version() {
npm view "${CLI_PKG}@next" version --registry="$REGISTRY" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}" || true
}
next_gateway_version() {
npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}" || true
}
next_pipeline_suffix() {
printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p'
}
next_versions_share_pipeline() {
local cli_next="$1"
local gateway_next="$2"
local cli_pipeline gateway_pipeline
cli_pipeline="$(next_pipeline_suffix "$cli_next")"
gateway_pipeline="$(next_pipeline_suffix "$gateway_next")"
[[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]]
}
version_lt() {
node -e "
const a=process.argv[1], b=process.argv[2];
const sp = v => { const i=v.indexOf('-'); return i===-1 ? [v,null] : [v.slice(0,i),v.slice(i+1)]; };
const [cA,pA]=sp(a.replace(/^v/,'')), [cB,pB]=sp(b.replace(/^v/,''));
const nA=cA.split('.').map(Number), nB=cB.split('.').map(Number);
for(let i=0;i<3;i++){if((nA[i]||0)<(nB[i]||0))process.exit(0);if((nA[i]||0)>(nB[i]||0))process.exit(1);}
if(pA!==null&&pB===null)process.exit(0);
if(pA===null)process.exit(1);
if(pA<pB)process.exit(0);
process.exit(1);
" "$1" "$2" 2>/dev/null
}
framework_version() {
# Read framework schema version stamp
local vf="$MOSAIC_HOME/.framework-version"
if [[ -f "$vf" ]]; then
cat "$vf" 2>/dev/null || true
fi
}
# ─── Transactional install state (canonical P0-P9) ───────────────────────────
# The phase numbering and names are an external contract. C2-C5 bind to these
# exact numbers, so do not renumber when filling a failed postcondition.
INSTALL_PHASES=(P0 P1 P2 P3 P4 P5 P6 P7 P8 P9)
STATE_DIR="${MOSAIC_INSTALL_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/install}"
STATE_RUN_DIR=""
STATE_JOURNAL=""
STATE_COMMAND_LOG=""
STATE_SNAPSHOT_DIR=""
STATE_FRAMEWORK_STATUS=""
STATE_INTERRUPTED_ACTIVE=""
STATE_CURRENT_PHASE="P0"
STATE_LOCK_FD=""
STATE_FAILURES=0
STATE_FAILED_PHASES=()
STATE_NPM_CACHE="${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$"
RESOLVED_CLI_VERSION=""
RESOLVED_SOURCE_DIGEST=""
LOCAL_SOURCE_ARCHIVE="${MOSAIC_INSTALL_LOCAL_SOURCE_ARCHIVE:-}"
LOCAL_SOURCE_COMMIT="${MOSAIC_INSTALL_LOCAL_SOURCE_COMMIT:-}"
LOCAL_SOURCE_SHA256="${MOSAIC_INSTALL_LOCAL_SOURCE_SHA256:-}"
phase_name() {
case "$1" in
P0) echo "Resolve context" ;; P1) echo "Preflight" ;;
P2) echo "Acquire artifacts" ;; P3) echo "Install CLI" ;;
P4) echo "Install framework + skills" ;; P5) echo "Identity" ;;
P6) echo "Runtime linking / activation" ;; P7) echo "Services" ;;
P8) echo "Shell discoverability" ;; P9) echo "Verify + commit" ;;
*) echo "unknown" ;;
esac
}
phase_contract() {
case "$1" in
P0) printf 'pre=target context available; action=resolve user/HOME/shell/platform; post=context stated and supported; rollback=n/a' ;;
P1) printf 'pre=P0 supported; action=validate tools/registry/headroom and acquire lock; post=preflight complete and exclusive; rollback=release lock' ;;
P2) printf 'pre=P1 exclusive; action=fetch pinned installer-distribution artifacts with visible output; post=lane/version/digest recorded; rollback=discard temporary artifacts; seam=does not forbid credentialed downstream acquisition' ;;
P3) printf 'pre=P2 pinned CLI; action=install CLI at known prefix; post=absolute binary version equals resolved version; rollback=restore prior prefix' ;;
P4) printf 'pre=P2 framework source and P3 absolute CLI; action=sync framework and skills; post=repository-shipped skills installed and loadable; rollback=restore prior framework/runtime trees' ;;
P5) printf 'pre=P3 absolute CLI; action=establish configured identity and validate any credential capability requested downstream; post=SOUL/USER valid owner/mode and required credential usable; rollback=remove generated identity/credential binding' ;;
P6) printf 'pre=P3 absolute CLI; action=evaluate runtime activation; post=dead #869 hooks never active without broker; rollback=restore runtime assets' ;;
P7) printf 'pre=P6 activation evaluated and applicable P5 credential committed; action=provision/manage requested services and credentialed resources only; post=requested services/resources ready; rollback=stop and restore requested services/resources' ;;
P8) printf 'pre=P3 absolute CLI; action=verify fresh target-user shells; post=login and non-login resolve P3 path; rollback=restore shell profiles' ;;
P9) printf 'pre=P0-P8 evaluated; action=reassert and commit journal/manifest; post=all phases pass and journal committed; rollback=restore pre-install snapshot' ;;
esac
}
state_json_line() {
local event="$1" phase="$2" status="$3" message="$4"
[[ -n "$STATE_JOURNAL" ]] || return 0
if ! EVENT="$event" PHASE="$phase" STATUS="$status" MESSAGE="$message" \
node -e '
const row={timestamp:new Date().toISOString(),event:process.env.EVENT,phase:process.env.PHASE,status:process.env.STATUS,message:process.env.MESSAGE};
process.stdout.write(JSON.stringify(row)+"\\n");
' >> "$STATE_JOURNAL"; then
fail "Journal write failed at phase ${phase}; refusing an unrecorded mutation."
return 1
fi
if ! sync "$STATE_JOURNAL"; then
fail "Journal sync failed at phase ${phase}; refusing an unrecorded mutation."
return 1
fi
}
state_record_mutation() {
local phase="$1" path="$2" reverse="$3" status root key covered=false
local prior="absent" snapshot="none"
if [[ -n "$STATE_SNAPSHOT_DIR" && -s "$STATE_SNAPSHOT_DIR/paths.tsv" ]]; then
while IFS=$'\t' read -r status root key; do
if [[ "$path" == "$root" || "$path" == "$root"/* ]]; then
covered=true
[[ -e "$path" || -L "$path" ]] && prior="present"
snapshot="$STATE_SNAPSHOT_DIR/data/$key"
break
fi
done < "$STATE_SNAPSHOT_DIR/paths.tsv"
fi
if [[ "$covered" != true && ( -e "$path" || -L "$path" ) ]]; then
# A path outside the declared snapshot cannot be mutated safely.
fail "Journal cannot bind prior state for $path before $phase mutation."
return 1
fi
state_json_line mutation "$phase" planned "path=$path prior=$prior snapshot=$snapshot reverse=$reverse"
}
state_seal_journal() {
local digest
state_json_line seal P9 committed "journal closed after manifest commit" || return
digest="$(sha256sum "$STATE_JOURNAL" | awk '{print $1}')" || return
if ! printf '%s %s\n' "$digest" "$(basename "$STATE_JOURNAL")" > "$STATE_JOURNAL.sha256" \
|| ! sync "$STATE_JOURNAL.sha256"; then
fail "Could not durably write the P9 journal seal."
return 1
fi
if ! chmod 0444 "$STATE_JOURNAL" "$STATE_JOURNAL.sha256"; then
fail "Could not make the committed journal and seal immutable."
return 1
fi
printf '%s' "$digest"
}
state_framework_action_failed() {
local phase="$1"
[[ -n "$STATE_FRAMEWORK_STATUS" && -s "$STATE_FRAMEWORK_STATUS" ]] || return 1
grep -q "^${phase}"$'\t'"failed"$'\t' "$STATE_FRAMEWORK_STATUS"
}
state_manifest_action_failed() {
local phase="$1" manifest="$MOSAIC_HOME/.install-manifest.json"
[[ -s "$manifest" ]] || return 1
node -e '
const fs=require("fs");
const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
process.exit(data?.phaseOutcomes?.[process.argv[2]] === "failed" ? 0 : 1);
' "$manifest" "$phase" 2>/dev/null
}
state_action_failed() {
if [[ -n "$STATE_FRAMEWORK_STATUS" ]]; then
state_framework_action_failed "$1"
else
state_manifest_action_failed "$1"
fi
}
state_redact_stream() {
python3 /dev/fd/3 3<<'PY'
import os, re, sys
text = sys.stdin.read()
secret_name = re.compile(r"(?:TOKEN|PASSWORD|PASSWD|SECRET|API_KEY|AUTH|CREDENTIAL|CANARY)", re.I)
secrets = {value for name, value in os.environ.items() if secret_name.search(name) and len(value) >= 4}
for value in sorted(secrets, key=len, reverse=True):
text = text.replace(value, "[REDACTED]")
patterns = (
(re.compile(r"(?im)^(\s*(?:proxy-)?authorization\s*:\s*)[^\r\n]+"), r"\1[REDACTED]"),
(re.compile(r"(?im)^(\s*(?:set-)?cookie\s*:\s*)[^\r\n]+"), r"\1[REDACTED]"),
(re.compile(r"(?i)(Bearer\s+)[^\s'\"]+"), r"\1[REDACTED]"),
(re.compile(r"(?i)((?:[_-]?auth(?:Token)?|token|password|passwd|secret|api[_-]?key)\s*[=:]\s*)[^\s'\"]+"), r"\1[REDACTED]"),
)
for pattern, replacement in patterns:
text = pattern.sub(replacement, text)
url_pattern = re.compile(r"https?://[^\s'\"<>]+", re.I)
def redact_url(match):
url = match.group(0)
scheme_end = url.find("://") + 3
authority_end = len(url)
for separator in "/?#":
position = url.find(separator, scheme_end)
if position != -1:
authority_end = min(authority_end, position)
authority = url[scheme_end:authority_end]
at = authority.rfind("@")
if at != -1:
return url[:scheme_end] + "[REDACTED]@" + authority[at + 1:] + url[authority_end:]
return url
sys.stdout.write(url_pattern.sub(redact_url, text))
PY
}
state_redaction_probe() {
printf '[REDACTION-PROBE] emitted=%s\n' "${MOSAIC_INSTALL_SECRET_CANARY:?redaction probe requires canary}"
}
state_run_captured() {
local label="$1" redacted redactor_pid capture_fd status=0 redact_status=0
shift
redacted="$(mktemp "${TMPDIR:-/tmp}/mosaic-phase-redacted.XXXXXX")" || return
chmod 0600 "$redacted" || { rm -f "$redacted"; return 1; }
# Process substitution preserves in-shell phase side effects while ensuring
# plaintext diagnostics exist only in a pipe, never in a filesystem body.
exec {capture_fd}> >(state_redact_stream > "$redacted")
redactor_pid=$!
set +e
"$@" >&"$capture_fd" 2>&1
status=$?
exec {capture_fd}>&-
wait "$redactor_pid"
redact_status=$?
set -e
if [[ "$redact_status" -ne 0 ]]; then
rm -f "$redacted"
fail "Could not redact '$label' diagnostics; refusing to expose or persist raw command output."
return 1
fi
cat "$redacted" || { rm -f "$redacted"; return 1; }
if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$redacted"; } >> "$STATE_COMMAND_LOG"; then
rm -f "$redacted"
fail "Could not append '$label' output to $STATE_COMMAND_LOG; refusing to continue."
return 1
fi
if ! sync "$STATE_COMMAND_LOG"; then
rm -f "$redacted"
fail "Could not sync '$label' output in $STATE_COMMAND_LOG; refusing to continue."
return 1
fi
rm -f "$redacted"
state_json_line command "$STATE_CURRENT_PHASE" "$([[ "$status" -eq 0 ]] && echo committed || echo failed)" "label=$label output_log=$STATE_COMMAND_LOG exit=$status"
return "$status"
}
state_write_active() {
local content="$1"
if ! printf '%s\n' "$content" > "$STATE_DIR/active.json" || ! sync "$STATE_DIR/active.json"; then
fail "Journal state write failed at $STATE_DIR/active.json; refusing to continue."
return 1
fi
}
state_phase_begin() {
STATE_CURRENT_PHASE="$1"
state_json_line phase "$1" started "$(phase_contract "$1")"
}
state_phase_finish() {
state_json_line phase "$1" "$2" "$3"
}
state_emit() {
local phase="$1" verdict="$2" reason="$3"
printf '[%s] %s: %s\n' "$phase" "$verdict" "$reason"
if [[ "$verdict" == "FAIL" ]]; then
STATE_FAILURES=$((STATE_FAILURES + 1))
STATE_FAILED_PHASES+=("$phase")
fi
}
state_target_shell() {
local shell=""
if command -v getent >/dev/null 2>&1; then
shell="$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f7 || true)"
fi
printf '%s' "${shell:-${SHELL:-}}"
}
STATE_POLICY_REASON=""
state_path_owner_mode_ok() {
local path="$1" policy="${2:-shared-read}" uid gid mode mode_value original resolved
original="$path"
[[ -e "$path" ]] || { STATE_POLICY_REASON="$path missing"; return 1; }
if [[ -L "$path" ]]; then
resolved="$(realpath -e -- "$path" 2>/dev/null)" \
|| { STATE_POLICY_REASON="$path has an unresolved symlink target"; return 1; }
if [[ "$resolved" != "$HOME"/* && "$resolved" != "$PREFIX"/* ]]; then
STATE_POLICY_REASON="$path escapes target-owned roots via symlink to $resolved"
return 1
fi
path="$resolved"
fi
uid="$(stat -c '%u' "$path" 2>/dev/null)" || { STATE_POLICY_REASON="$original owner unreadable"; return 1; }
gid="$(stat -c '%g' "$path" 2>/dev/null)" || { STATE_POLICY_REASON="$path group unreadable"; return 1; }
mode="$(stat -c '%a' "$path" 2>/dev/null)" || { STATE_POLICY_REASON="$path mode unreadable"; return 1; }
[[ "$uid" == "$(id -u)" && "$gid" == "$(id -g)" && "$mode" =~ ^[0-7]{3,4}$ ]] \
|| { STATE_POLICY_REASON="$original owner=$uid group=$gid mode=$mode expected=$(id -u):$(id -g)"; return 1; }
mode_value=$((8#$mode))
case "$policy" in
private)
(( (mode_value & 077) == 0 )) \
|| { STATE_POLICY_REASON="$original mode=$mode exposes private material to group/other"; return 1; }
;;
executable)
(( (mode_value & 0111) != 0 && (mode_value & 022) == 0 )) \
|| { STATE_POLICY_REASON="$original mode=$mode is not executable or is group/world-writable"; return 1; }
;;
shared-read)
(( (mode_value & 022) == 0 )) \
|| { STATE_POLICY_REASON="$original mode=$mode is group/world-writable"; return 1; }
;;
*) STATE_POLICY_REASON="unknown owner/mode policy=$policy for $path"; return 1 ;;
esac
}
state_tree_owner_mode_ok() {
local root="$1" path policy scan valid=true
[[ -e "$root" ]] || return 0
scan="$(mktemp)" \
|| { STATE_POLICY_REASON="$root enumeration staging failed"; return 1; }
if ! find "$root" -xdev -print0 > "$scan"; then
STATE_POLICY_REASON="$root enumeration failed; created-path inventory is incomplete"
rm -f "$scan"
return 1
fi
while IFS= read -r -d '' path; do
policy=shared-read
case "$path" in
"$MOSAIC_HOME/credentials"|"$MOSAIC_HOME/credentials"/*|"$MOSAIC_HOME/SOUL.md"|"$MOSAIC_HOME/USER.md") policy=private ;;
esac
if ! state_path_owner_mode_ok "$path" "$policy"; then
valid=false
break
fi
done < "$scan"
rm -f "$scan"
[[ "$valid" == true ]]
}
state_resolved_version() {
local cli gateway
if [[ "$FLAG_DEV" == "true" ]]; then
return 0
fi
if is_next_registry_lane; then
cli="$(next_cli_version)"
gateway="$(next_gateway_version)"
[[ -n "$cli" && -n "$gateway" ]] && next_versions_share_pipeline "$cli" "$gateway" || return 0
printf '%s' "$cli"
else
latest_cli_version
fi
}
state_expected_cli_version() {
if [[ -n "$RESOLVED_CLI_VERSION" ]]; then
printf '%s' "$RESOLVED_CLI_VERSION"
elif [[ "$FLAG_DEV" == "true" && -s "$MOSAIC_HOME/.install-manifest.json" ]]; then
node -p "require('$MOSAIC_HOME/.install-manifest.json').cliVersion || ''" 2>/dev/null || true
else
state_resolved_version
fi
}
state_predicate() {
local phase="$1" shell node_major installed expected
local missing=() login_path nonlogin_path broker=false dead_hooks=0
local prefix_parent disk_kb inode_count min_disk_kb min_inodes npm_major privilege_mode
local passwd_row passwd_user passwd_uid passwd_home passwd_shell actual_user actual_uid
STATE_REASON=""
case "$phase" in
P0)
actual_uid="$(id -u 2>/dev/null || true)"
actual_user="$(id -un 2>/dev/null || true)"
passwd_row="$(getent passwd "$actual_uid" 2>/dev/null || true)"
IFS=: read -r passwd_user _ passwd_uid _ _ passwd_home passwd_shell <<<"$passwd_row"
shell="$passwd_shell"
node_major="$(node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0)"
npm_major="$(npm --version 2>/dev/null | cut -d. -f1 || echo 0)"
if [[ "$actual_uid" == 0 && -n "${SUDO_USER:-}" ]]; then
privilege_mode="sudo-with-inherited-home"
elif [[ "$actual_uid" == 0 ]]; then
privilege_mode="root-without-explicit-target"
else
privilege_mode="user"
fi
if [[ -z "$passwd_row" || "$actual_uid" != "$passwd_uid" || "$actual_user" != "$passwd_user" \
|| -z "$passwd_home" || "$HOME" != "$passwd_home" ]]; then
STATE_REASON="unsupported or unresolved target account: HOME mismatch or passwd identity mismatch (target=${actual_user:-unknown} uid=${actual_uid:-unknown} HOME=${HOME:-unset} passwd_user=${passwd_user:-unset} passwd_uid=${passwd_uid:-unset} passwd_HOME=${passwd_home:-unset} shell=${passwd_shell:-unset} privilege=$privilege_mode)"
return 1
fi
if [[ -n "$shell" && "$privilege_mode" == "user" && "$(uname -s)" == "Linux" ]] \
&& ldd --version 2>&1 | grep -i 'glibc\|gnu libc' >/dev/null \
&& [[ "$(uname -m)" == "x86_64" ]] && [[ "$node_major" -ge 20 ]] && [[ "$npm_major" -ge 9 ]] \
&& state_validate_target_paths; then
STATE_REASON="target=$actual_user uid=$actual_uid HOME=$HOME passwd_HOME=$passwd_home shell=$shell privilege=$privilege_mode arch=x86_64 libc=glibc node=$(node --version) npm=$(npm --version)"
return 0
fi
STATE_REASON="unsupported, unresolved, or unsafe context (target=${actual_user:-unknown} uid=${actual_uid:-unknown} HOME=${HOME:-unset} passwd_HOME=${passwd_home:-unset} shell=${shell:-unset} privilege=$privilege_mode arch=$(uname -m 2>/dev/null || echo unknown) node_major=$node_major npm_major=$npm_major path_check=${STATE_PATH_REASON:-not-reached})"
return 1
;;
P1)
# Include tools invoked by downstream phases. Omitting git made P1 pass
# while P4's sync was already guaranteed to fail and be suppressed.
for tool in awk bash curl date df find flock git grep install mktemp node npm python3 realpath sed sha256sum stat sync tar; do
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
if [[ "$FLAG_DEV" == "true" ]] && ! command -v corepack >/dev/null 2>&1; then
missing+=("corepack")
fi
# Concurrency authority is the OS-backed flock acquired by
# state_begin_install. active.json is a crash-recovery projection only;
# treating a stale in-progress projection as a live lock permanently
# blocked retries after SIGKILL or power loss.
if [[ -z "$STATE_LOCK_FD" && -f "$STATE_DIR/install.lock" ]]; then
local probe_lock_fd
if exec {probe_lock_fd}<>"$STATE_DIR/install.lock"; then
if ! flock -n "$probe_lock_fd"; then missing+=("concurrent-install-lock-held"); fi
exec {probe_lock_fd}>&-
else
missing+=("install-lock-unreadable")
fi
fi
prefix_parent="$(dirname "$PREFIX")"
[[ -d "$prefix_parent" && -w "$prefix_parent" ]] || missing+=("prefix-parent-not-writable")
min_disk_kb="${MOSAIC_INSTALL_MIN_DISK_KB:-262144}"
min_inodes="${MOSAIC_INSTALL_MIN_INODES:-1000}"
disk_kb="$(df -Pk "$prefix_parent" 2>&1 | awk 'NR==2 {print $4}')"
inode_count="$(df -Pi "$prefix_parent" 2>&1 | awk 'NR==2 {print $4}')"
[[ "$disk_kb" =~ ^[0-9]+$ && "$disk_kb" -ge "$min_disk_kb" ]] || missing+=("disk-headroom")
[[ "$inode_count" =~ ^[0-9]+$ && "$inode_count" -ge "$min_inodes" ]] || missing+=("inode-headroom")
if [[ "$FLAG_DEV" == "true" ]]; then
expected="source-build-at-immutable-ref"
else
expected="$(state_resolved_version)"
[[ -n "$expected" ]] || missing+=("registry-lane-unreachable-or-unauthenticated")
fi
if [[ "${#missing[@]}" -eq 0 ]]; then
STATE_REASON="downstream tool closure present; prefix parent writable; artifact lane resolvable; disk_kb=$disk_kb inodes=$inode_count; concurrency delegated to OS lock"
return 0
fi
STATE_REASON="preflight failures: ${missing[*]}"
return 1
;;
P2)
if [[ "$FLAG_DEV" == "true" ]]; then
local source_commit="${RESOLVED_SOURCE_COMMIT:-}" source_digest="${RESOLVED_SOURCE_DIGEST:-}"
if [[ "$FLAG_CHECK" == "true" && -s "$MOSAIC_HOME/.install-manifest.json" ]]; then
source_commit="$(node -p "require('$MOSAIC_HOME/.install-manifest.json').sourceCommit || ''" 2>/dev/null || true)"
source_digest="$(node -p "require('$MOSAIC_HOME/.install-manifest.json').sourceSha256 || ''" 2>/dev/null || true)"
fi
if [[ "$source_commit" =~ ^[0-9a-f]{40}$ && "$source_digest" =~ ^[0-9a-f]{64}$ ]]; then
STATE_REASON="source_ref=$GIT_REF pinned_commit=$source_commit sha256=$source_digest"
return 0
fi
STATE_REASON="source ref has no installed pinned commit/digest evidence (commit=${source_commit:-unavailable} sha256=${source_digest:-unavailable})"
return 1
fi
expected="${RESOLVED_CLI_VERSION:-$(state_resolved_version)}"
if [[ -n "$expected" ]] && { [[ "$FLAG_CHECK" == "false" ]] || grep -qF "\"lane\": \"$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest)\"" "$MOSAIC_HOME/.install-manifest.json" 2>/dev/null; }; then
STATE_REASON="lane=$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest) pinned_version=$expected"
return 0
fi
STATE_REASON="resolved_version=${expected:-unavailable}; installed manifest does not record the resolved lane"
return 1
;;
P3)
expected="$(state_expected_cli_version)"
installed=""
[[ -x "$PREFIX/bin/mosaic" ]] && installed="$("$PREFIX/bin/mosaic" --version 2>&1 | tail -n 1 | tr -d '\r' || true)"
if [[ -n "$expected" && -x "$PREFIX/bin/mosaic" && "$installed" == "$expected" ]] \
&& state_path_owner_mode_ok "$PREFIX/bin/mosaic" executable; then
STATE_REASON="absolute_path=$PREFIX/bin/mosaic version=$installed equals resolved lane version; owner/mode policy satisfied"
return 0
fi
STATE_REASON="absolute_path=$PREFIX/bin/mosaic executable=$([[ -x "$PREFIX/bin/mosaic" ]] && echo yes || echo no) got=${installed:-missing} expected=${expected:-unresolved}; unsafe owner/group/mode=${STATE_POLICY_REASON:-not-evaluated}"
return 1
;;
P4)
# C1 defines and enforces the assertion surface but does not choose among
# the four disagreeing candidate populations. C5 owns publishing and
# fulfilling the declaration. Until then P4 remains NOT-MEASURED.
local declared_set="$MOSAIC_HOME/.install-shipped-skills.json"
local expected_lane expected_version
expected_lane="$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest)"
expected_version="$(state_expected_cli_version)"
if [[ -e "$MOSAIC_HOME" ]] && ! state_tree_owner_mode_ok "$MOSAIC_HOME"; then
STATE_REASON="framework created-path owner/mode policy failed: $STATE_POLICY_REASON"
return 1
fi
if [[ ! -s "$declared_set" ]]; then
STATE_REASON="NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set"
return 1
fi
if ! EXPECTED_LANE="$expected_lane" EXPECTED_VERSION="$expected_version" MOSAIC_SKILLS_ROOT="$MOSAIC_HOME/skills" \
node - "$declared_set" <<'NODE'
const fs = require('fs');
const path = require('path');
const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const root = path.resolve(process.env.MOSAIC_SKILLS_ROOT);
if (!data || typeof data !== 'object' || data.lane !== process.env.EXPECTED_LANE ||
data.version !== process.env.EXPECTED_VERSION || !Array.isArray(data.skills) || data.skills.length === 0) process.exit(1);
for (const name of data.skills) {
if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) process.exit(1);
const skill = path.join(root, name, 'SKILL.md');
let real;
try { real = fs.realpathSync(skill); } catch { process.exit(1); }
if (!real.startsWith(root + path.sep)) process.exit(1);
const stat = fs.statSync(real);
const text = fs.readFileSync(real, 'utf8');
const declaredName = text.match(/^---\s*$[\s\S]*?^name:\s*([^\s]+)\s*$/m)?.[1];
if (!stat.isFile() || stat.size === 0 || declaredName !== name) process.exit(1);
}
NODE
then
STATE_REASON="declared shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable"
return 1
fi
if state_action_failed P4; then
STATE_REASON="framework/skills action reported a required P4 failure; inspect the transaction command log"
return 1
fi
STATE_REASON="declared shipped-set matches lane=$expected_lane version=$expected_version; every declared skill is contained and loadable"
return 0
;;
P5)
for skill in SOUL.md USER.md; do
local path="$MOSAIC_HOME/$skill"
if [[ ! -s "$path" ]] || ! grep -q '^# ' "$path" 2>/dev/null \
|| ! state_path_owner_mode_ok "$path" private; then
missing+=("$skill")
fi
done
if [[ -e "$MOSAIC_HOME/credentials" ]] && ! state_tree_owner_mode_ok "$MOSAIC_HOME/credentials"; then
missing+=("credentials(owner/mode=$STATE_POLICY_REASON)")
fi
if [[ "${#missing[@]}" -eq 0 ]]; then STATE_REASON="SOUL.md and USER.md parse and have private target owner/mode; credential paths are private"; return 0; fi
STATE_REASON="identity missing, empty, malformed, wrong-owner, or unsafe-mode: ${missing[*]}"
return 1
;;
P6)
if state_action_failed P6; then
STATE_REASON="runtime linking/activation action reported a required P6 failure; inspect the transaction command log"
return 1
fi
[[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker=true
if [[ -f "$HOME/.claude/settings.json" ]]; then
dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$HOME/.claude/settings.json" || true)"
fi
if [[ "$broker" == true || "$dead_hooks" -eq 0 ]]; then
STATE_REASON="$([[ "$broker" == true ]] && echo 'activation broker present' || echo 'broker absent and #869 hooks inactive')"
return 0
fi
STATE_REASON="broker absent but dead #869 enforcement hooks active (count=$dead_hooks)"
return 1
;;
P7)
STATE_REASON="no services requested by this installer invocation"
return 0
;;
P8)
shell="$(state_target_shell)"
case "${shell##*/}" in
bash|zsh)
login_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -lc 'command -v mosaic' 2>&1 || true)"
nonlogin_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -c 'command -v mosaic' 2>&1 || true)"
;;
fish)
login_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -lc 'command -v mosaic' 2>&1 || true)"
nonlogin_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -c 'command -v mosaic' 2>&1 || true)"
;;
*) STATE_REASON="unsupported or unresolved target shell: ${shell:-unset}"; return 1 ;;
esac
if [[ "$login_path" == "$PREFIX/bin/mosaic" && "$nonlogin_path" == "$PREFIX/bin/mosaic" ]]; then
STATE_REASON="login=$login_path nonlogin=$nonlogin_path equals P3 path"
return 0
fi
STATE_REASON="fresh ${shell##*/} login=${login_path:-missing} nonlogin=${nonlogin_path:-missing} expected=$PREFIX/bin/mosaic"
return 1
;;
esac
}
state_check_all() {
local phase
STATE_FAILURES=0
STATE_FAILED_PHASES=()
for phase in "${INSTALL_PHASES[@]:0:9}"; do
if state_predicate "$phase"; then state_emit "$phase" PASS "$STATE_REASON"; else state_emit "$phase" FAIL "$STATE_REASON"; fi
done
[[ "$STATE_FAILURES" -eq 0 ]]
}
# Component-only installs preserve their historical narrow contract. `--check`
# is never narrowed: it always calls state_check_all above and evaluates P0-P8.
state_check_install_scope() {
local phase
STATE_FAILURES=0
STATE_FAILED_PHASES=()
for phase in "${INSTALL_PHASES[@]:0:9}"; do
if [[ "$FLAG_CLI" == "true" && "$FLAG_FRAMEWORK" == "false" && "$phase" =~ ^P[4-8]$ ]]; then
state_emit "$phase" PASS "not requested by --cli component-only install"
continue
fi
if [[ "$FLAG_FRAMEWORK" == "true" && "$FLAG_CLI" == "false" && "$phase" == "P3" ]]; then
state_emit "$phase" PASS "not requested by --framework component-only install"
continue
fi
if state_predicate "$phase"; then state_emit "$phase" PASS "$STATE_REASON"; else state_emit "$phase" FAIL "$STATE_REASON"; fi
done
[[ "$STATE_FAILURES" -eq 0 ]]
}
state_path_is_safe_target() {
local raw="$1" canonical_home normalized owner
canonical_home="$(realpath -e -- "$HOME" 2>/dev/null)" || return 1
[[ "$HOME" == "$canonical_home" && "$raw" == /* && "$raw" != *$'\n'* ]] || return 1
normalized="$(realpath -m -- "$raw" 2>/dev/null)" || return 1
[[ "$normalized" == "$raw" && "$raw" != "$HOME" && "$raw" == "$HOME"/* ]] || return 1
# realpath -m follows every existing symlink component. Equality therefore
# rejects a target or parent redirected outside the rollback tree.
if [[ -e "$raw" || -L "$raw" ]]; then
[[ ! -L "$raw" ]] || return 1
owner="$(stat -c '%u' "$raw" 2>/dev/null)" || return 1
[[ "$owner" == "$(id -u)" ]] || return 1
fi
}
state_validate_target_paths() {
local left right i j
local targets=(
"$MOSAIC_HOME" "$PREFIX" "$HOME/.npmrc" "$HOME/.bashrc" "$HOME/.bash_profile"
"$HOME/.profile" "$HOME/.zshrc" "$HOME/.config/fish/config.fish" "$HOME/.claude"
"$HOME/.pi" "$HOME/.codex" "$HOME/.config/opencode" "$HOME/.config/mosaic-gateway"
"$HOME/.config/systemd" "$HOME/.local/share/systemd" "$HOME/.local/state/mosaic-gateway"
"$HOME/.local/state/mosaic/backups"
)
STATE_PATH_REASON=""
for left in "${targets[@]}"; do
if ! state_path_is_safe_target "$left"; then
STATE_PATH_REASON="unsafe rollback target: $left (must be a non-symlinked, target-user-owned strict descendant of canonical HOME=$HOME)"
return 1
fi
done
for ((i=0; i<${#targets[@]}; i++)); do
for ((j=i+1; j<${#targets[@]}; j++)); do
left="${targets[$i]}"; right="${targets[$j]}"
if [[ "$left" == "$right" || "$left" == "$right"/* || "$right" == "$left"/* ]]; then
STATE_PATH_REASON="overlapping rollback targets are forbidden: $left and $right"
return 1
fi
done
done
}
state_snapshot_create() {
local dst list path key index=0 parent parent_list parent_status
local -A recorded_parents=()
if ! state_validate_target_paths; then
fail "P1 Preflight refused snapshot creation: $STATE_PATH_REASON"
return 1
fi
STATE_SNAPSHOT_DIR="$STATE_RUN_DIR/snapshot"
mkdir -p "$STATE_SNAPSHOT_DIR/data"
list="$STATE_SNAPSHOT_DIR/paths.tsv"
parent_list="$STATE_SNAPSHOT_DIR/parents.tsv"
: > "$list"
: > "$parent_list"
for path in "$MOSAIC_HOME" "$PREFIX" "$HOME/.npmrc" "$HOME/.bashrc" "$HOME/.bash_profile" \
"$HOME/.profile" "$HOME/.zshrc" "$HOME/.config/fish/config.fish" "$HOME/.claude" \
"$HOME/.pi" "$HOME/.codex" "$HOME/.config/opencode" "$HOME/.config/mosaic-gateway" \
"$HOME/.config/systemd" "$HOME/.local/share/systemd" "$HOME/.local/state/mosaic-gateway" \
"$HOME/.local/state/mosaic/backups"; do
key="path-$index"
index=$((index + 1))
if [[ -e "$path" || -L "$path" ]]; then
printf 'present\t%s\t%s\n' "$path" "$key" >> "$list"
dst="$STATE_SNAPSHOT_DIR/data/$key"
cp -a "$path" "$dst"
else
printf 'absent\t%s\t%s\n' "$path" "$key" >> "$list"
fi
parent="$(dirname "$path")"
while [[ "$parent" != "$HOME" && "$parent" == "$HOME"/* ]]; do
if [[ -z "${recorded_parents[$parent]:-}" ]]; then
recorded_parents[$parent]=1
parent_status=absent
[[ -d "$parent" ]] && parent_status=present
printf '%s\t%s\n' "$parent_status" "$parent" >> "$parent_list"
fi
parent="$(dirname "$parent")"
done
done
state_json_line snapshot P1 committed "pre-install snapshot=$STATE_SNAPSHOT_DIR"
}
state_snapshot_restore() {
local status target key saved parent
[[ -s "$STATE_SNAPSHOT_DIR/paths.tsv" ]] || return 1
while IFS=$'\t' read -r status target key; do
[[ -n "$target" ]] || continue
saved="$STATE_SNAPSHOT_DIR/data/$key"
if ! state_path_is_safe_target "$target"; then
fail "Rollback refused unsafe or replaced target path: $target"
return 1
fi
rm -rf -- "$target" || return
if [[ "$status" == "present" ]]; then
mkdir -p "$(dirname "$target")" || return
cp -a "$saved" "$target" || return
fi
done < "$STATE_SNAPSHOT_DIR/paths.tsv"
# Mutating a previously absent nested target can leave empty parents behind
# after the target itself is restored. Remove only parents proven absent in
# the pre-install snapshot; repeated passes handle arbitrary nesting without
# depending on GNU tac/sort behavior.
if [[ -s "$STATE_SNAPSHOT_DIR/parents.tsv" ]]; then
for _ in {1..16}; do
while IFS=$'\t' read -r status parent; do
[[ "$status" == absent ]] || continue
[[ "$parent" != "$HOME" && "$parent" == "$HOME"/* ]] || return 1
rmdir "$parent" 2>/dev/null || true
done < "$STATE_SNAPSHOT_DIR/parents.tsv"
done
fi
}
state_begin_install() {
local run_id
if ! install -d -m 0700 "$STATE_DIR"; then
fail "P1 Preflight failed: cannot create private journal directory $STATE_DIR"
exit 1
fi
exec {STATE_LOCK_FD}>"$STATE_DIR/install.lock"
if ! flock -n "$STATE_LOCK_FD"; then
fail "P1 Preflight failed: another Mosaic install holds $STATE_DIR/install.lock"
echo " Remediation: wait for the active install to finish, then rerun." >&2
exit 1
fi
run_id="$(date -u +%Y%m%dT%H%M%SZ)-$$"
STATE_RUN_DIR="$STATE_DIR/$run_id"
if ! install -d -m 0700 "$STATE_RUN_DIR"; then
fail "P1 Preflight failed: cannot create private journal run directory $STATE_RUN_DIR"
exit 1
fi
if [[ -f "$STATE_DIR/active.json" ]] \
&& grep -q '"status"[[:space:]]*:[[:space:]]*"in-progress"' "$STATE_DIR/active.json"; then
STATE_INTERRUPTED_ACTIVE="$STATE_RUN_DIR/prior-active.json"
if ! cp "$STATE_DIR/active.json" "$STATE_INTERRUPTED_ACTIVE"; then
fail "P1 Preflight failed: could not preserve the interrupted transaction projection."
exit 1
fi
fi
STATE_JOURNAL="$STATE_RUN_DIR/journal.ndjson"
STATE_COMMAND_LOG="$STATE_RUN_DIR/commands.log"
STATE_FRAMEWORK_STATUS="$STATE_RUN_DIR/framework-phase-status.tsv"
if ! install -m 0600 /dev/null "$STATE_JOURNAL" \
|| ! install -m 0600 /dev/null "$STATE_COMMAND_LOG" \
|| ! install -m 0600 /dev/null "$STATE_FRAMEWORK_STATUS"; then
fail "P1 Preflight failed: cannot initialize private journal files in $STATE_RUN_DIR"
exit 1
fi
export MOSAIC_INSTALL_COMMAND_LOG="$STATE_COMMAND_LOG"
export MOSAIC_INSTALL_PHASE_STATUS_FILE="$STATE_FRAMEWORK_STATUS"
export NPM_CONFIG_CACHE="$STATE_RUN_DIR/npm-cache"
state_write_active "$(printf '{\"status\":\"in-progress\",\"run\":\"%s\",\"journal\":\"%s\"}' "$run_id" "$STATE_JOURNAL")"
state_json_line install P0 opened "transaction opened before target mutation"
if [[ -n "$STATE_INTERRUPTED_ACTIVE" ]]; then
state_json_line recovery P1 resumed "stale in-progress projection preserved at $STATE_INTERRUPTED_ACTIVE; OS lock was free; current run starts from the honestly retained partial state"
fi
}
state_handle_unexpected_failure() {
local code="$1" phase="${2:-$STATE_CURRENT_PHASE}"
trap - ERR INT TERM
set +e
state_json_line install "$phase" failed "unexpected command failure exit=$code; rollback started"
if state_snapshot_restore; then
state_json_line install "$phase" rolled-back "pre-install snapshot restored"
state_write_active "$(printf '{\"status\":\"rolled-back\",\"phase\":\"%s\",\"journal\":\"%s\"}' "$phase" "$STATE_JOURNAL")"
fail "$phase $(phase_name "$phase") failed (exit $code); pre-install snapshot restored."
else
state_json_line install "$phase" rollback-failed "snapshot restoration failed or refused an unsafe target"
state_write_active "$(printf '{\"status\":\"rollback-failed\",\"phase\":\"%s\",\"journal\":\"%s\"}' "$phase" "$STATE_JOURNAL")"
fail "$phase $(phase_name "$phase") failed (exit $code); automatic rollback did not complete."
fi
echo " Remediation: inspect $STATE_COMMAND_LOG and $STATE_JOURNAL, correct the named failure, then rerun." >&2
exit "$code"
}
state_mark_resumable_failure() {
local failed="${STATE_FAILED_PHASES[*]}"
trap - ERR INT TERM
state_json_line install P9 failed-resumable "failed phases=$failed; mutations retained for explicit remediation"
state_write_active "$(printf '{\"status\":\"failed-resumable\",\"phases\":\"%s\",\"journal\":\"%s\"}' "$failed" "$STATE_JOURNAL")"
fail "P9 Verify + commit failed: postconditions failed in ${failed:-unknown}."
echo " Remediation: fix each named phase, then run this installer with --check; journal: $STATE_JOURNAL" >&2
}
state_maybe_inject_fault() {
local phase="$1"
[[ "${MOSAIC_INSTALL_FAULT_AFTER:-}" == "$phase" ]] || return 0
state_json_line fault "$phase" injected "phase=$phase after real phase action"
echo "Injected installer fault after real action: phase=$phase" >&2
state_handle_unexpected_failure 97 "$phase"
}
resolve_source_commit() {
local encoded_ref body headers content_type
encoded_ref="$(node -p 'encodeURIComponent(process.argv[1])' "$GIT_REF")"
body="$(mktemp "${TMPDIR:-/tmp}/mosaic-ref-body.XXXXXX")" || return
headers="$(mktemp "${TMPDIR:-/tmp}/mosaic-ref-headers.XXXXXX")" || { rm -f "$body"; return 1; }
if ! curl -fsSL -D "$headers" -o "$body" \
"https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/commits?sha=${encoded_ref}&limit=1"; then
rm -f "$body" "$headers"
fail "P2 Acquire artifacts failed: could not resolve source ref '$GIT_REF'."
return 1
fi
content_type="$(awk 'BEGIN{IGNORECASE=1} /^content-type:/{gsub(/\r/,""); sub(/^[^:]+:[[:space:]]*/,""); print; exit}' "$headers")"
if [[ "$content_type" != application/json* ]]; then
rm -f "$body" "$headers"
fail "P2 Acquire artifacts failed: ref endpoint returned content-type '${content_type:-missing}', not JSON."
return 1
fi
RESOLVED_SOURCE_COMMIT="$(node -e '
const fs=require("fs"); const rows=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
if (!Array.isArray(rows) || rows.length!==1 || typeof rows[0].sha!=="string" || !/^[0-9a-f]{40}$/.test(rows[0].sha)) process.exit(1);
process.stdout.write(rows[0].sha);
' "$body")" || {
rm -f "$body" "$headers"
fail "P2 Acquire artifacts failed: ref endpoint did not return exactly one commit with a sha."
return 1
}
rm -f "$body" "$headers"
ARCHIVE_URL="${REPO_BASE}/archive/${RESOLVED_SOURCE_COMMIT}.tar.gz"
}
# Download + extract the monorepo archive at the resolved immutable commit
# exactly once per run. Sets EXTRACTED_DIR for both P3 source fallback and P4.
ensure_monorepo() {
if [[ -n "$EXTRACTED_DIR" ]] && [[ -d "$EXTRACTED_DIR" ]]; then
return 0
fi
require_cmd tar || return
if [[ -n "$STATE_RUN_DIR" ]]; then
WORK_DIR="$STATE_RUN_DIR/work"
mkdir -p "$WORK_DIR" || return
else
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")" || return
fi
# shellcheck disable=SC2329 # Invoked by the EXIT trap below.
cleanup_work() { [[ -n "$WORK_DIR" ]] && rm -rf "$WORK_DIR"; }
trap cleanup_work EXIT
local archive="$WORK_DIR/source.tar.gz"
local max_archive_bytes="${MOSAIC_INSTALL_MAX_ARCHIVE_BYTES:-268435456}"
local max_expanded_bytes="${MOSAIC_INSTALL_MAX_EXPANDED_BYTES:-1073741824}"
if [[ -n "$LOCAL_SOURCE_ARCHIVE" ]]; then
if [[ ! "$LOCAL_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ || ! "$LOCAL_SOURCE_SHA256" =~ ^[0-9a-f]{64}$ \
|| ! -f "$LOCAL_SOURCE_ARCHIVE" || -L "$LOCAL_SOURCE_ARCHIVE" ]]; then
fail "P2 Acquire artifacts failed: local checkout fixture requires a regular archive plus exact 40-hex source ID and 64-hex SHA-256."
return 1
fi
RESOLVED_SOURCE_COMMIT="$LOCAL_SOURCE_COMMIT"
cp "$LOCAL_SOURCE_ARCHIVE" "$archive" || return
RESOLVED_SOURCE_DIGEST="$(sha256sum "$archive" | awk '{print $1}')" || return
if [[ "$RESOLVED_SOURCE_DIGEST" != "$LOCAL_SOURCE_SHA256" ]]; then
fail "P2 Acquire artifacts failed: local checkout archive digest does not match the fixture-pinned SHA-256."
return 1
fi
info "Acquiring checkout fixture at content ID ${RESOLVED_SOURCE_COMMIT} with pinned SHA-256 ${RESOLVED_SOURCE_DIGEST}…"
else
[[ -n "${RESOLVED_SOURCE_COMMIT:-}" ]] || resolve_source_commit || return
info "Downloading source ref ${GIT_REF} at pinned commit ${RESOLVED_SOURCE_COMMIT}…"
if command -v curl &>/dev/null; then
curl -fsSL --max-filesize "$max_archive_bytes" "$ARCHIVE_URL" -o "$archive" || return
elif command -v wget &>/dev/null; then
wget -O "$archive" "$ARCHIVE_URL" || return
else
fail "curl or wget required to download source."
return 1
fi
RESOLVED_SOURCE_DIGEST="$(sha256sum "$archive" | awk '{print $1}')" || return
fi
local archive_bytes
archive_bytes="$(stat -c '%s' "$archive" 2>/dev/null)" || return
if [[ ! "$archive_bytes" =~ ^[0-9]+$ || "$archive_bytes" -gt "$max_archive_bytes" ]]; then
fail "P2 Acquire artifacts failed: source archive exceeds the configured compressed-size limit."
return 1
fi
# Reject traversal, links, devices, excessive entry counts, and expansion
# bombs before tar writes a byte. The immutable commit + digest are retained
# as provenance; authenticated release metadata remains the trust root for a
# future distribution-artifact lane.
if ! MAX_EXPANDED_BYTES="$max_expanded_bytes" python3 - "$archive" <<'PY'
import os
import pathlib
import sys
import tarfile
archive = sys.argv[1]
limit = int(os.environ["MAX_EXPANDED_BYTES"])
total = 0
with tarfile.open(archive, "r:gz") as tf:
members = tf.getmembers()
if not members or len(members) > 100_000:
raise SystemExit(1)
for member in members:
pure = pathlib.PurePosixPath(member.name)
if pure.is_absolute() or ".." in pure.parts or member.issym() or member.islnk() or member.isdev():
raise SystemExit(1)
if not (member.isfile() or member.isdir()):
raise SystemExit(1)
total += member.size
if total > limit:
raise SystemExit(1)
PY
then
fail "P2 Acquire artifacts failed: archive safety/integrity check failed (sha256=$RESOLVED_SOURCE_DIGEST)."
return 1
fi
tar xzf "$archive" -C "$WORK_DIR" || return
state_json_line artifact P2 committed "lane=$GIT_REF source_commit=$RESOLVED_SOURCE_COMMIT sha256=$RESOLVED_SOURCE_DIGEST" || return
# Gitea archives must extract to exactly one <repo-name>/ directory. Capture
# and check the complete walk before selecting it: `find | head -1` both hides
# a failed enumeration and makes multiple roots depend on filesystem order.
local extracted_roots_file
local -a extracted_roots=()
extracted_roots_file="$(mktemp)" \
|| { fail "P2 Acquire artifacts failed: could not stage extracted-root inventory."; return 1; }
if ! find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d -print0 > "$extracted_roots_file"; then
rm -f "$extracted_roots_file"
fail "P2 Acquire artifacts failed: could not enumerate extracted source roots."
return 1
fi
mapfile -d '' -t extracted_roots < "$extracted_roots_file"
rm -f "$extracted_roots_file"
if [[ "${#extracted_roots[@]}" -ne 1 ]] || [[ ! -d "${extracted_roots[0]:-}" ]]; then
fail "P2 Acquire artifacts failed: expected exactly one extracted source root; found ${#extracted_roots[@]}."
return 1
fi
EXTRACTED_DIR="${extracted_roots[0]}"
}
# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
# globally from locally-packed tarballs. ZERO registry writes. Workspace deps
# (brain/config/db/…) are pulled from the registry at the versions pinned in
# each package.json — `pnpm pack` rewrites `workspace:*` to those versions.
install_cli_from_source() {
local src="$EXTRACTED_DIR"
local out_dir="$WORK_DIR/dist-tarballs"
mkdir -p "$out_dir" || return
# pnpm via corepack (ships with Node >= 16.9; required by Node >= 20 preflight).
# Pin to the repo's packageManager version so the build matches CI. Surface
# corepack failures so the fresh-machine case gives an actionable error
# instead of a bare "command not found".
if ! command -v pnpm &>/dev/null; then
info "Activating pnpm via corepack…"
corepack enable 2>&1 | sed 's/^/ /' || warn "corepack enable failed — pnpm may need manual install."
corepack prepare [email protected] --activate 2>&1 | sed 's/^/ /' \
|| warn "corepack prepare failed — pnpm may need manual install."
fi
if ! command -v pnpm &>/dev/null; then
fail "pnpm not available after corepack activation."
echo " Install pnpm manually (https://pnpm.io/installation) and re-run with --dev."
return 1
fi
info "Installing workspace dependencies (pnpm install)…"
( cd "$src" && pnpm install ) 2>&1 | sed 's/^/ /' || return
info "Building CLI + gateway from source…"
( cd "$src" && pnpm --filter "@mosaicstack/mosaic..." --filter "@mosaicstack/gateway..." run build ) 2>&1 | sed 's/^/ /' || return
info "Packing local tarballs…"
( cd "$src/packages/mosaic" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' || return
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' || return
local cli_tgz gw_tgz
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"
if [[ ! -f "$cli_tgz" ]]; then
fail "CLI tarball was not produced by pnpm pack."
return 1
fi
if [[ ! -f "$gw_tgz" ]]; then
fail "Gateway tarball was not produced by pnpm pack."
return 1
fi
# Gateway first so it is present globally before the CLI's wizard runs (which
# skips its own gateway install via MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1).
info "Installing gateway from source tarball (global)…"
npm install -g "$gw_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return
info "Installing CLI from source tarball (global)…"
npm install -g "$cli_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return
# Source fallback replaces the registry candidate with the package version
# produced by the pinned source commit. P3 must compare against what P2
# actually selected, not the failed registry candidate.
RESOLVED_CLI_VERSION="$(installed_cli_version)" || return
[[ -n "$RESOLVED_CLI_VERSION" ]] || { fail "Source install did not expose an installed CLI version."; return 1; }
state_json_line artifact P2 committed "source fallback selected cli_version=$RESOLVED_CLI_VERSION source_commit=${RESOLVED_SOURCE_COMMIT:-unknown}" || return
ok "Installed from source: CLI $RESOLVED_CLI_VERSION"
}
install_next_cli_from_registry() {
local cli_next gateway_next
cli_next="$(next_cli_version)"
gateway_next="$(next_gateway_version)"
if [[ -z "$cli_next" ]]; then
warn "${CLI_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if [[ -z "$gateway_next" ]]; then
warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then
warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})."
return 1
fi
info "Installing ${CLI_PKG}@${cli_next} from registry…"
if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast CLI @next install failed."
return 1
fi
info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…"
if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast gateway @next install failed."
return 1
fi
local installed_cli installed_gateway
installed_cli="$(installed_cli_version)"
installed_gateway="$(installed_gateway_version)"
if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then
warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})."
return 1
fi
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
}
# ─── preflight / state-machine dispatch ──────────────────────────────────────
if [[ "$FLAG_STATE_SELF_TEST" == "true" ]]; then
if [[ "${MOSAIC_INSTALL_SELF_TEST_ALLOW:-0}" != 1 || ! "${MOSAIC_INSTALL_FAULT_AFTER:-}" =~ ^P[2-8]$ ]]; then
fail "state-machine self-test requires MOSAIC_INSTALL_SELF_TEST_ALLOW=1 and MOSAIC_INSTALL_FAULT_AFTER=P2..P8"
exit 2
fi
fi
# `--check` exits before mkdir, npm-prefix setup, locks, snapshots, downloads, or
# any other target mutation. Temporary observation files live under TMPDIR and
# are removed in the predicate that creates them.
if [[ "$FLAG_CHECK" == "true" ]]; then
check_status=0
state_check_all || check_status=$?
rm -rf "$STATE_NPM_CACHE"
exit "$check_status"
fi
require_cmd node
require_cmd npm
require_cmd flock
NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')"
if [[ "$NODE_MAJOR" -lt 20 ]]; then
fail "Node.js >= 20 required (found v$(node --version))"
exit 1
fi
echo ""
echo "${BOLD}Mosaic Stack Installer${RESET}"
echo ""
# P0/P1 are pure preconditions. Open the durable journal and snapshot only after
# they pass, but before P2 performs the first target mutation.
if state_predicate P0; then
P0_REASON="$STATE_REASON"
state_emit P0 PASS "$P0_REASON"
else
state_emit P0 FAIL "$STATE_REASON"
fail "P0 Resolve context failed."
echo " Remediation: run as a supported non-root target user with explicit HOME/shell, glibc x86_64, and Node.js >=20." >&2
exit 1
fi
if state_predicate P1; then
P1_REASON="$STATE_REASON"
state_emit P1 PASS "$P1_REASON"
else
state_emit P1 FAIL "$STATE_REASON"
fail "P1 Preflight failed."
echo " Remediation: install the named prerequisites, clear any active transaction, and ensure the npm prefix parent is writable." >&2
exit 1
fi
state_begin_install
state_phase_finish P0 committed "$P0_REASON"
state_phase_finish P1 committed "$P1_REASON; exclusive lock acquired; journal opened"
state_snapshot_create
trap 'state_handle_unexpected_failure "$?" "$STATE_CURRENT_PHASE"' ERR INT TERM
if [[ "${MOSAIC_INSTALL_REDACTION_PROBE:-0}" == 1 ]]; then
state_run_captured "credential redaction acceptance probe" state_redaction_probe
fi
state_phase_begin P2
state_record_mutation P2 "$STATE_RUN_DIR/work" "discard acquired temporary artifacts"
if [[ "$FLAG_DEV" == "true" ]]; then
RESOLVED_CLI_VERSION=""
else
RESOLVED_CLI_VERSION="$(state_resolved_version)"
if [[ -z "$RESOLVED_CLI_VERSION" ]]; then
fail "P2 Acquire artifacts failed: could not resolve a pinned CLI version for the requested lane."
false
fi
fi
if [[ "$FLAG_FRAMEWORK" == "true" || "$FLAG_DEV" == "true" ]]; then
state_run_captured "P2 acquire pinned source archive" ensure_monorepo
fi
state_phase_finish P2 committed "lane=$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest) cli_version=${RESOLVED_CLI_VERSION:-pending-source-package-build} source_commit=${RESOLVED_SOURCE_COMMIT:-deferred-until-source-fallback} sha256=${RESOLVED_SOURCE_DIGEST:-deferred-until-source-fallback}"
state_maybe_inject_fault P2
# ═══════════════════════════════════════════════════════════════════════════════
# PART 1: Framework (bash launcher + guides + runtime configs + tools)
# ═══════════════════════════════════════════════════════════════════════════════
install_phase_p4_action() {
if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
step "Framework (~/.config/mosaic)"
FRAMEWORK_CURRENT="$(framework_version)"
HAS_FRAMEWORK=false
[[ -f "$MOSAIC_HOME/AGENTS.md" ]] || [[ -f "$MOSAIC_HOME/.framework-version" ]] && HAS_FRAMEWORK=true
if [[ -n "$FRAMEWORK_CURRENT" ]]; then
dim " Installed: framework v${FRAMEWORK_CURRENT}"
elif [[ "$HAS_FRAMEWORK" == "true" ]]; then
dim " Installed: framework (version unknown)"
else
dim " Installed: (none)"
fi
dim " Source: ${REPO_BASE} ($(source_ref_details))"
echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$HAS_FRAMEWORK" == "true" ]]; then
ok "Framework is installed."
else
warn "Framework not installed."
fi
else
# Download repo archive and extract framework (shared with the dev build).
ensure_monorepo || return
FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework"
if [[ ! -d "$FRAMEWORK_SRC" ]]; then
fail "Framework not found in archive at packages/mosaic/framework/"
fail "Archive contents:"
ls -la "$WORK_DIR" >&2 || true # Diagnostic only; missing framework remains fatal.
return 1
fi
# Run the framework's own install.sh (handles keep/overwrite for SOUL.md etc.)
info "Installing framework to ${MOSAIC_HOME}…"
MOSAIC_INSTALL_MODE="${MOSAIC_INSTALL_MODE:-keep}" \
MOSAIC_CLI_PATH="$PREFIX/bin/mosaic" \
MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 \
MOSAIC_SKIP_SKILLS_SYNC="${MOSAIC_SKIP_SKILLS_SYNC:-0}" \
bash "$FRAMEWORK_SRC/install.sh" || return
ok "Framework installed"
echo ""
# Framework bin is no longer needed on PATH — the npm CLI delegates
# to mosaic-launch directly via its absolute path.
fi
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# PART 2: @mosaicstack/mosaic (npm — TUI, gateway client, wizard, CLI)
# ═══════════════════════════════════════════════════════════════════════════════
install_phase_p3_action() {
if [[ "$FLAG_CLI" == "true" ]]; then
step "@mosaicstack/mosaic (npm package)"
# Ensure prefix dir
if [[ ! -d "$PREFIX" ]]; then
info "Creating global prefix directory: $PREFIX"
mkdir -p "$PREFIX"/{bin,lib} || return
fi
# Ensure npmrc scope mapping
NPMRC="$HOME/.npmrc"
SCOPE_LINE="${SCOPE}:registry=${REGISTRY}"
if ! grep -qF "$SCOPE_LINE" "$NPMRC" 2>/dev/null; then
info "Adding ${SCOPE} registry to $NPMRC"
echo "$SCOPE_LINE" >> "$NPMRC" || return
ok "Registry configured"
fi
if ! grep -qF "prefix=$PREFIX" "$NPMRC" 2>/dev/null; then
if ! grep -q '^prefix=' "$NPMRC" 2>/dev/null; then
echo "prefix=$PREFIX" >> "$NPMRC" || return
info "Set npm global prefix to $PREFIX"
fi
fi
CURRENT="$(installed_cli_version)"
NEXT_GATEWAY=""
if [[ "$FLAG_DEV" == "true" ]]; then
LATEST=""
elif is_next_registry_lane; then
LATEST="$(next_cli_version)"
NEXT_GATEWAY="$(next_gateway_version)"
else
LATEST="$(latest_cli_version)"
fi
if [[ -n "$CURRENT" ]]; then
dim " Installed: ${CLI_PKG}@${CURRENT}"
else
dim " Installed: (none)"
fi
if [[ "$FLAG_DEV" == "true" ]]; then
dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)"
elif is_next_registry_lane; then
if [[ -n "$LATEST" ]]; then
dim " Next CLI: ${CLI_PKG}@${LATEST}"
else
dim " Next CLI: (registry @next unreachable)"
fi
if [[ -n "$NEXT_GATEWAY" ]]; then
dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}"
else
dim " Next GW: (registry @next unreachable)"
fi
dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)"
elif [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}"
else
dim " Latest: (registry unreachable)"
fi
echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
elif is_next_registry_lane; then
if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then
ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}."
else
warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source."
fi
elif [[ -z "$LATEST" ]]; then
warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then
warn "Not installed."
elif [[ "$CURRENT" == "$LATEST" ]]; then
ok "Up to date."
elif version_lt "$CURRENT" "$LATEST"; then
warn "Update available: $CURRENT$LATEST"
else
ok "Up to date (or ahead of registry)."
fi
elif [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode — building CLI + gateway from source at ref ${GIT_REF}…"
ensure_monorepo || return
install_cli_from_source || return
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
elif is_next_registry_lane; then
info "Next mode — trying fast npm @next install from ${REGISTRY}…"
if install_next_cli_from_registry; then
:
else
warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues."
unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL
ensure_monorepo || return
install_cli_from_source || return
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
else
if [[ -z "$LATEST" ]]; then
warn "Could not reach registry at $REGISTRY — skipping npm CLI."
elif [[ -z "$CURRENT" ]]; then
info "Installing ${CLI_PKG}@${LATEST}…"
npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return
ok "CLI installed: $(installed_cli_version)"
elif [[ "$CURRENT" == "$LATEST" ]]; then
ok "Already at latest version ($LATEST)."
elif version_lt "$CURRENT" "$LATEST"; then
info "Upgrading ${CLI_PKG}: $CURRENT$LATEST…"
npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return
ok "CLI upgraded: $(installed_cli_version)"
else
ok "CLI is at or ahead of registry ($CURRENT$LATEST)."
fi
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
fi
fi
}
# Execute actions in canonical order. The old installer ran P4 before P3, which
# made runtime-link diagnostics depend on shell discovery instead of P3's known
# absolute binary. P3 now commits before P4 begins.
state_phase_begin P3
if [[ "$FLAG_CLI" == "true" ]]; then
state_record_mutation P3 "$PREFIX" "restore prefix from $STATE_SNAPSHOT_DIR"
state_record_mutation P3 "$HOME/.npmrc" "restore npmrc from $STATE_SNAPSHOT_DIR"
fi
state_run_captured "P3 install CLI" install_phase_p3_action
if [[ "$FLAG_CLI" == "false" ]]; then
state_phase_finish P3 not-requested "CLI component excluded by --framework"
elif state_predicate P3; then
state_phase_finish P3 committed "$STATE_REASON"
else
state_phase_finish P3 failed "$STATE_REASON"
fail "P3 Install CLI failed: $STATE_REASON"
false
fi
state_maybe_inject_fault P3
state_phase_begin P4
if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
state_record_mutation P4 "$MOSAIC_HOME" "restore framework tree from $STATE_SNAPSHOT_DIR"
state_record_mutation P4 "$HOME/.pi" "restore Pi runtime assets from $STATE_SNAPSHOT_DIR"
state_record_mutation P4 "$HOME/.claude" "restore Claude runtime assets from $STATE_SNAPSHOT_DIR"
state_record_mutation P4 "$HOME/.codex" "restore Codex runtime assets from $STATE_SNAPSHOT_DIR"
state_record_mutation P4 "$HOME/.config/opencode" "restore OpenCode runtime assets from $STATE_SNAPSHOT_DIR"
state_record_mutation P4 "$HOME/.local/state/mosaic/backups" "restore framework backup state from $STATE_SNAPSHOT_DIR"
state_record_mutation P6 "$HOME/.claude/settings.json" "restore activation settings from $STATE_SNAPSHOT_DIR"
fi
export MOSAIC_CLI_PATH="$PREFIX/bin/mosaic"
state_run_captured "P4 install framework and skills; P6 evaluate activation" install_phase_p4_action
if [[ "$FLAG_FRAMEWORK" == "false" ]]; then
state_phase_finish P4 not-requested "framework component excluded by --cli"
elif state_predicate P4; then
state_phase_finish P4 committed "$STATE_REASON"
else
# C1 intentionally cannot commit P4 while the shipped-set declaration is
# absent. Keep the partial state for P5-P8 diagnostics; P9 fails non-zero.
state_phase_finish P4 failed-resumable "$STATE_REASON"
fi
state_maybe_inject_fault P4
# P5/P7 actions (wizard/service requests) live in the summary flow below and
# bind their mutation records immediately before the wizard executes. P8 is
# observation-only today, so it must not fabricate planned mutation entries.
# ═══════════════════════════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════════════════════════
if [[ "$FLAG_CHECK" == "false" ]]; then
step "Summary"
echo " ${BOLD}mosaic:${RESET} $PREFIX/bin/mosaic"
dim " Framework data: $MOSAIC_HOME/"
echo ""
# First install guidance / auto-launch
if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then
echo ""
if [[ "$FLAG_NO_AUTO_LAUNCH" == "false" ]] && { { [[ -t 0 ]] && [[ -t 1 ]]; } || [[ "$FLAG_STATE_SELF_TEST" == true ]]; }; then
# Interactive TTY and auto-launch not suppressed: run the unified wizard.
# `mosaic wizard` now runs the full first-run flow end-to-end: identity
# setup → runtimes → hooks preview → skills → finalize → gateway
# config → admin bootstrap. No second call needed.
info "First install detected — launching unified setup wizard…"
echo ""
MOSAIC_BIN="$PREFIX/bin/mosaic"
if [[ ! -x "$MOSAIC_BIN" ]]; then
warn "P3 absolute mosaic binary is unavailable — skipping auto-launch."
warn "Repair $MOSAIC_BIN and run it with: $MOSAIC_BIN wizard"
else
MOSAIC_CMD="$MOSAIC_BIN"
state_record_mutation P5 "$MOSAIC_HOME/SOUL.md" "restore identity from $STATE_SNAPSHOT_DIR"
state_record_mutation P5 "$MOSAIC_HOME/USER.md" "restore identity from $STATE_SNAPSHOT_DIR"
state_record_mutation P7 "$HOME/.config/mosaic-gateway" "stop requested services and restore service state"
state_record_mutation P7 "$HOME/.config/systemd" "stop requested services and restore user units"
state_record_mutation P7 "$HOME/.local/share/systemd" "stop requested services and restore user units"
state_record_mutation P7 "$HOME/.local/state/mosaic-gateway" "stop requested services and restore service state"
if state_run_captured "P5 identity and P7 service wizard" "$MOSAIC_CMD" wizard; then
ok "Wizard complete."
else
warn "Wizard exited non-zero."
echo " You can retry with: ${C}mosaic wizard${RESET}"
echo " Or run gateway install alone: ${C}mosaic gateway install${RESET}"
fi
fi
else
# Non-interactive or --no-auto-launch: print guidance only
info "First install detected. Set up your agent identity:"
echo " ${C}mosaic wizard${RESET} (unified first-run wizard — identity + gateway + admin)"
echo " ${C}mosaic gateway install${RESET} (standalone gateway (re)configure)"
fi
fi
# ── Write install manifest ──────────────────────────────────────────────────
# The mutation journal was opened before P2. This projection is written as
# pending-verification and becomes committed only after P9 reasserts P0-P8.
MANIFEST_PATH="$MOSAIC_HOME/.install-manifest.json"
MANIFEST_CLI_VERSION="$(installed_cli_version)"
MANIFEST_FW_VERSION="$(framework_version)"
MANIFEST_SCOPE_LINE="${SCOPE}:registry=${REGISTRY}"
MANIFEST_TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")"
# Build runtimeAssetCopies array by scanning known destinations for backups
collect_runtime_copies() {
local home_dir="$HOME"
local copies="[]"
local dests=(
"$home_dir/.claude/CLAUDE.md"
"$home_dir/.claude/settings.json"
"$home_dir/.claude/hooks-config.json"
"$home_dir/.claude/context7-integration.md"
"$home_dir/.config/opencode/AGENTS.md"
"$home_dir/.codex/instructions.md"
)
copies="["
local first=true
for dest in "${dests[@]}"; do
[[ -f "$dest" ]] || continue
local base dir backup_path backup_val
base="$(basename "$dest")"
dir="$(dirname "$dest")"
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
if [[ -n "$backup_path" ]]; then
backup_val="\"$backup_path\""
else
backup_val="null"
fi
if [[ "$first" == "true" ]]; then
first=false
else
copies="$copies,"
fi
copies="$copies{\"source\":\"\",\"dest\":\"$dest\",\"backup\":$backup_val}"
done
copies="$copies]"
echo "$copies"
}
RUNTIME_COPIES="$(collect_runtime_copies)"
MANIFEST_P4_OUTCOME="committed"
MANIFEST_P6_OUTCOME="committed"
state_framework_action_failed P4 && MANIFEST_P4_OUTCOME="failed"
state_framework_action_failed P6 && MANIFEST_P6_OUTCOME="failed"
# Check whether the npmrc line was present (we may have added it above)
NPMRC_LINES_JSON="[]"
if grep -qF "$MANIFEST_SCOPE_LINE" "$HOME/.npmrc" 2>/dev/null; then
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
fi
MANIFEST_TMP="$MOSAIC_HOME/.install-manifest.json.tmp-$$"
state_record_mutation P9 "$MANIFEST_PATH" "restore manifest/framework tree from $STATE_SNAPSHOT_DIR"
state_record_mutation P9 "$MANIFEST_TMP" "remove pending manifest temp or restore framework tree from $STATE_SNAPSHOT_DIR"
if node -e "
const fs = require('fs');
const path = require('path');
const p = process.argv[1];
const m = {
version: 2,
status: 'pending-verification',
installedAt: process.argv[2],
cliVersion: process.argv[3] || '(unknown)',
frameworkVersion: parseInt(process.argv[4] || '0', 10),
lane: process.argv[7],
sourceCommit: process.argv[8],
sourceSha256: process.argv[9],
journal: process.argv[10],
phaseOutcomes: { P4: process.argv[11], P6: process.argv[12] },
mutations: {
directories: [path.dirname(p)],
npmGlobalPackages: ['@mosaicstack/mosaic'],
npmrcLines: JSON.parse(process.argv[5]),
shellProfileEdits: [],
runtimeAssetCopies: JSON.parse(process.argv[6]),
}
};
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp=process.argv[13];
const fd=fs.openSync(tmp,'wx',0o600);
try { fs.writeFileSync(fd,JSON.stringify(m,null,2)+'\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
fs.renameSync(tmp,p);
const dfd=fs.openSync(path.dirname(p),'r');
try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); }
" \
"$MANIFEST_PATH" \
"$MANIFEST_TS" \
"$MANIFEST_CLI_VERSION" \
"$MANIFEST_FW_VERSION" \
"$NPMRC_LINES_JSON" \
"$RUNTIME_COPIES" \
"$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest)" \
"${RESOLVED_SOURCE_COMMIT:-not-requested}" \
"${RESOLVED_SOURCE_DIGEST:-not-requested}" \
"$STATE_JOURNAL" \
"$MANIFEST_P4_OUTCOME" \
"$MANIFEST_P6_OUTCOME" \
"$MANIFEST_TMP"; then
ok "Install manifest written pending P9 verification: $MANIFEST_PATH"
else
fail "P9 Verify + commit could not durably write the install manifest."
false
fi
# Record each deferred phase independently before the aggregate P9 verdict.
for phase in P5 P6 P7 P8; do
state_phase_begin "$phase"
if [[ "$FLAG_CLI" == "true" && "$FLAG_FRAMEWORK" == "false" ]]; then
state_phase_finish "$phase" not-requested "not requested by --cli component-only install"
elif state_predicate "$phase"; then
state_phase_finish "$phase" committed "$STATE_REASON"
else
state_phase_finish "$phase" failed-resumable "$STATE_REASON"
fi
state_maybe_inject_fault "$phase"
done
echo ""
state_phase_begin P9
if state_check_install_scope && [[ -s "$MANIFEST_PATH" ]]; then
MANIFEST_COMMIT_TMP="$MOSAIC_HOME/.install-manifest.json.commit-tmp-$$"
state_record_mutation P9 "$MANIFEST_COMMIT_TMP" "remove committed manifest temp or restore framework tree from $STATE_SNAPSHOT_DIR"
node -e '
const fs=require("fs"), path=require("path"); const p=process.argv[1]; const m=JSON.parse(fs.readFileSync(p,"utf8"));
m.status="committed"; m.committedAt=new Date().toISOString();
const tmp=process.argv[2]; const fd=fs.openSync(tmp,"wx",0o600);
try { fs.writeFileSync(fd,JSON.stringify(m,null,2)+"\n"); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
fs.renameSync(tmp,p); const dfd=fs.openSync(path.dirname(p),"r");
try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); }
' "$MANIFEST_PATH" "$MANIFEST_COMMIT_TMP"
state_json_line install P9 committed "all postconditions verified"
state_phase_finish P9 committed "P0-P8 reasserted; manifest durably committed; journal ready to seal"
state_write_active "$(printf '{\"status\":\"committed\",\"journal\":\"%s\"}' "$STATE_JOURNAL")"
state_seal_journal >/dev/null
trap - ERR INT TERM
ok "Done."
else
state_phase_finish P9 failed-resumable "failed phases=${STATE_FAILED_PHASES[*]}"
state_mark_resumable_failure
exit 1
fi
fi
} # end main
main "$@"