#!/usr/bin/env bash # Q81 (sage-0042): put the mosaic-stack CLI on PATH as `mosaic`. # # mosaic-cli.sh install [REF] rename the npm-global `mosaic` to `mosaic-old`, # add a pinned worktree and the wrapper # mosaic-cli.sh update [REF] move the pinned worktree to REF and # rewrite the wrapper # mosaic-cli.sh undo remove the wrapper and the worktree, rename # `mosaic-old` back to `mosaic` # mosaic-cli.sh status what is installed, and whether it drifted # # REF defaults to origin/refactor after a fetch, and must already be on # origin/refactor: the wrapper runs pushed code only, never the working # checkout. The worktree is a detached linked worktree of the canonical # checkout, so `mosaic queue` refuses there (the queue runs only in the # canonical checkout); queue work stays on scripts/mosaic. # # The wrapper sends only `__lease-capability` to mosaic-old, so the old # CLI's lease gate (activation_version_gate.py) still gets its answer. # # Writes only: ~/.npm-global/bin/{mosaic,mosaic-old}, the worktree and # state directory below, and the canonical checkout's worktree list. set -euo pipefail CANON=/mnt/storage/src/mosaic-stack BIN="$HOME/.npm-global/bin" STATE_DIR="$HOME/.local/share/mosaic-cli" WT="$STATE_DIR/stack" LOG="$STATE_DIR/install.log" OLD_TARGET=../lib/node_modules/@mosaicstack/mosaic/dist/cli.js MARKER="# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)" die() { echo "mosaic-cli: $*" >&2; exit 1; } now() { date -u +%FT%TZ; } log() { mkdir -p "$STATE_DIR"; echo "$(now) $*" >>"$LOG"; } is_wrapper() { [ -f "$BIN/mosaic" ] && [ ! -L "$BIN/mosaic" ] && sed -n 2p "$BIN/mosaic" | grep -qxF "$MARKER"; } is_old_link() { [ -L "$1" ] && [ "$(readlink "$1")" = "$OLD_TARGET" ]; } # Fetch, then resolve REF to a full sha that origin/refactor contains. pinned_sha() { local ref="${1:-origin/refactor}" sha git -C "$CANON" fetch -q origin refactor || die "git fetch origin refactor failed" sha="$(git -C "$CANON" rev-parse --verify -q "$ref^{commit}")" || die "$ref is not a commit" git -C "$CANON" merge-base --is-ancestor "$sha" origin/refactor || die "$ref ($sha) is not on origin/refactor; only pushed code is pinned" echo "$sha" } # npm ci only when the lock differs from what node_modules was built from. deps() { if [ -f "$WT/node_modules/.package-lock.json" ] && [ -f "$STATE_DIR/lock.sha256" ] \ && (cd "$WT" && sha256sum -c --status "$STATE_DIR/lock.sha256"); then echo "node_modules: lock unchanged, kept" return fi [ -f "$WT/package.json" ] || die "$WT/package.json missing; refusing npm ci (it would walk up)" (cd "$WT" && npm ci --prefer-offline --no-audit --no-fund >/dev/null) || die "npm ci in $WT failed" (cd "$WT" && sha256sum package-lock.json) >"$STATE_DIR/lock.sha256" echo "node_modules: npm ci done" } render_wrapper() { cat <"$tmp" chmod 0755 "$tmp" mv -f "$tmp" "$BIN/mosaic" } cmd_install() { is_wrapper && die "already installed; use update or undo" is_old_link "$BIN/mosaic" || die "$BIN/mosaic is not the expected symlink to $OLD_TARGET; refusing" [ -e "$BIN/mosaic-old" ] || [ -L "$BIN/mosaic-old" ] && die "$BIN/mosaic-old already exists; refusing" [ -e "$WT" ] && die "$WT already exists; refusing" local sha; sha="$(pinned_sha "${1:-}")" mkdir -p "$STATE_DIR" git -C "$CANON" worktree add -q --detach "$WT" "$sha" || die "worktree add failed" deps mv -n "$BIN/mosaic" "$BIN/mosaic-old" is_old_link "$BIN/mosaic-old" || die "rename to mosaic-old did not happen" write_wrapper log "install $sha" echo "installed: $BIN/mosaic -> $WT/scripts/mosaic at $sha" } cmd_update() { is_wrapper || die "not installed (no wrapper at $BIN/mosaic)" local sha; sha="$(pinned_sha "${1:-}")" [ -z "$(git -C "$WT" status --porcelain --untracked-files=no)" ] || die "$WT has tracked changes; refusing" git -C "$WT" checkout -q --detach "$sha" deps write_wrapper log "update $sha" echo "updated: $WT at $sha" } cmd_undo() { is_wrapper || die "$BIN/mosaic is not this wrapper; refusing (status says why)" is_old_link "$BIN/mosaic-old" || die "$BIN/mosaic-old is not the expected symlink; refusing" rm -f "$BIN/mosaic" mv -n "$BIN/mosaic-old" "$BIN/mosaic" is_old_link "$BIN/mosaic" || die "rename back to mosaic did not happen" if [ -e "$WT" ]; then git -C "$CANON" worktree remove --force "$WT"; fi rm -f "$STATE_DIR/lock.sha256" log "undo" echo "undone: $BIN/mosaic -> $OLD_TARGET again; worktree removed" } cmd_status() { local bad=0 sha origin if is_wrapper; then if cmp -s "$BIN/mosaic" <(render_wrapper); then echo "wrapper: $BIN/mosaic (marker ok, matches this script)" else echo "DRIFT: wrapper differs from what this script writes; run update"; bad=1; fi elif is_old_link "$BIN/mosaic"; then echo "wrapper: none; $BIN/mosaic is the estate symlink" [ -e "$WT" ] && { echo "DRIFT: worktree exists but the wrapper is gone (an npm i -g --force?)"; bad=1; } else echo "DRIFT: $BIN/mosaic is neither the wrapper nor the estate symlink"; bad=1; fi if is_old_link "$BIN/mosaic-old"; then echo "mosaic-old: $BIN/mosaic-old -> $OLD_TARGET" elif [ -e "$BIN/mosaic-old" ] || [ -L "$BIN/mosaic-old" ]; then echo "DRIFT: $BIN/mosaic-old is not the expected symlink"; bad=1 else echo "mosaic-old: none"; fi if [ -d "$WT" ]; then sha="$(git -C "$WT" rev-parse HEAD)" origin="$(git -C "$CANON" rev-parse origin/refactor)" echo "worktree: $WT at $sha" if [ "$sha" = "$origin" ]; then echo "pin: origin/refactor (as last fetched)" else echo "pin: behind origin/refactor $origin by $(git -C "$CANON" rev-list --count "$sha..$origin") commits"; fi [ -z "$(git -C "$WT" status --porcelain --untracked-files=no)" ] || { echo "DRIFT: worktree has tracked changes"; bad=1; } else echo "worktree: none"; fi echo "on PATH: $(command -v mosaic || echo none)" [ -f "$LOG" ] && echo "last: $(tail -n 1 "$LOG")" return "$bad" } case "${1:-}" in install) shift; cmd_install "$@" ;; update) shift; cmd_update "$@" ;; undo) cmd_undo ;; status) cmd_status ;; *) echo "usage: mosaic-cli.sh install [REF] | update [REF] | undo | status" >&2; exit 4 ;; esac