1775 lines
76 KiB
Bash
Executable File
1775 lines
76 KiB
Bash
Executable File
#!/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_run_captured() {
|
||
local label="$1" output status=0
|
||
shift
|
||
output="$(mktemp "${TMPDIR:-/tmp}/mosaic-phase-command.XXXXXX.log")" || return
|
||
# The command is deliberately called in a conditional so its status can be
|
||
# journaled before the caller's ERR trap rolls back. Bash disables errexit in
|
||
# functions invoked this way, so every multi-command phase helper below must
|
||
# explicitly return on each required command failure.
|
||
if "$@" >"$output" 2>&1; then status=0; else status=$?; fi
|
||
cat "$output" || { rm -f "$output"; return 1; }
|
||
if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$STATE_COMMAND_LOG"; then
|
||
rm -f "$output"
|
||
fail "Could not append '$label' output to $STATE_COMMAND_LOG; refusing to continue."
|
||
return 1
|
||
fi
|
||
if ! sync "$STATE_COMMAND_LOG"; then
|
||
rm -f "$output"
|
||
fail "Could not sync '$label' output in $STATE_COMMAND_LOG; refusing to continue."
|
||
return 1
|
||
fi
|
||
rm -f "$output"
|
||
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_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
|
||
STATE_REASON=""
|
||
case "$phase" in
|
||
P0)
|
||
shell="$(state_target_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)"
|
||
privilege_mode="$([[ "$(id -u)" -eq 0 ]] && echo root-without-explicit-target || echo user)"
|
||
if [[ -n "$HOME" && -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=$(id -un) uid=$(id -u) HOME=$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=$(id -un 2>/dev/null || echo unknown) uid=$(id -u) HOME=${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" ]]; then
|
||
STATE_REASON="absolute_path=$PREFIX/bin/mosaic version=$installed equals resolved lane version"
|
||
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}"
|
||
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 [[ ! -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 \
|
||
|| [[ "$(stat -c '%u' "$path" 2>/dev/null || echo -1)" != "$(id -u)" ]] \
|
||
|| [[ "$(stat -c '%a' "$path" 2>/dev/null || echo 777)" =~ [2367]$ ]]; then
|
||
missing+=("$skill")
|
||
fi
|
||
done
|
||
if [[ "${#missing[@]}" -eq 0 ]]; then STATE_REASON="SOUL.md and USER.md parse and have target owner/mode"; 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
|
||
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"
|
||
: > "$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
|
||
done
|
||
state_json_line snapshot P1 committed "pre-install snapshot=$STATE_SNAPSHOT_DIR"
|
||
}
|
||
|
||
state_snapshot_restore() {
|
||
local status target key saved
|
||
[[ -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"
|
||
}
|
||
|
||
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_self_test() {
|
||
local phase path
|
||
state_begin_install
|
||
state_snapshot_create
|
||
trap 'state_handle_unexpected_failure "$?" "$STATE_CURRENT_PHASE"' ERR INT TERM
|
||
for phase in P2 P3 P4 P5 P6 P7 P8; do
|
||
state_phase_begin "$phase"
|
||
case "$phase" in
|
||
P2) path="$MOSAIC_HOME/.selftest-artifact" ;;
|
||
P3) path="$PREFIX/bin/mosaic" ;;
|
||
P4) path="$MOSAIC_HOME/.selftest-framework" ;;
|
||
P5) path="$MOSAIC_HOME/SOUL.md" ;;
|
||
P6) path="$HOME/.claude/settings.json" ;;
|
||
P7) path="$MOSAIC_HOME/.selftest-service" ;;
|
||
P8) path="$HOME/.bashrc" ;;
|
||
esac
|
||
state_record_mutation "$phase" "$path" "restore representative path from $STATE_SNAPSHOT_DIR"
|
||
mkdir -p "$(dirname "$path")"
|
||
printf 'mutated-by-%s\n' "$phase" > "$path"
|
||
state_phase_finish "$phase" committed "representative mutation committed"
|
||
if [[ "${MOSAIC_INSTALL_FAULT_AFTER:-}" == "$phase" ]]; then
|
||
state_json_line fault "$phase" injected "phase=$phase"
|
||
echo "Injected installer fault: phase=$phase" >&2
|
||
state_snapshot_restore
|
||
state_json_line install "$phase" rolled-back "fault injection restored pre-install snapshot"
|
||
state_write_active "$(printf '{\"status\":\"rolled-back\",\"phase\":\"%s\",\"journal\":\"%s\"}' "$phase" "$STATE_JOURNAL")"
|
||
exit 97
|
||
fi
|
||
done
|
||
fail "self-test requires MOSAIC_INSTALL_FAULT_AFTER=P2..P8"
|
||
exit 2
|
||
}
|
||
|
||
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.XXXXXX.json")" || return
|
||
headers="$(mktemp "${TMPDIR:-/tmp}/mosaic-ref.XXXXXX.headers")" || { 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 extract to <repo-name>/ inside the work dir
|
||
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)" || return
|
||
if [[ -z "$EXTRACTED_DIR" ]] || [[ ! -d "$EXTRACTED_DIR" ]]; then
|
||
fail "Could not locate extracted source in archive."
|
||
ls -la "$WORK_DIR" >&2 || true # Diagnostic only; the named P2 failure is authoritative.
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# 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
|
||
require_cmd node
|
||
require_cmd flock
|
||
state_self_test
|
||
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
|
||
|
||
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}"
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# 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_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_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
|
||
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
|
||
|
||
# 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 ]]; 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 ! command -v "$MOSAIC_BIN" &>/dev/null && ! command -v mosaic &>/dev/null; then
|
||
warn "mosaic binary not found on PATH — skipping auto-launch."
|
||
warn "Add $PREFIX/bin to PATH and run: mosaic wizard"
|
||
else
|
||
# Prefer the absolute path from the prefix we just installed to
|
||
MOSAIC_CMD="mosaic"
|
||
if [[ -x "$MOSAIC_BIN" ]]; then
|
||
MOSAIC_CMD="$MOSAIC_BIN"
|
||
fi
|
||
|
||
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
|
||
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 "$@"
|