Unify halt and fire cancellation by verified run identity with durable first-owner receipts and shared deadlines. Integrate the instance control with the current host, library and tester-socket paths. Focused nine-file evidence is 62 passing tests; broad suites, mutants and review remain pending.
192 lines
12 KiB
Bash
Executable File
192 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# One immutable pushed tree for the bus, Console and human CLI (#1579).
|
|
# copy creates files only; wrapper is a separately Mos-gated live step.
|
|
# wrapper and check take --instance NAME (row 190): the units mosaic-bus-NAME@ and mosaic-webui-NAME
|
|
# and the wrapper $BIN/mosaic-NAME, which sets NAME's MOSAIC_CONFIG. check needs --business for NAME.
|
|
# Row 232: check --units external reads root's system units. copy --bundle makes the copy from a git
|
|
# bundle on a host with no checkout, and wrapper --create writes the first wrapper on a new host.
|
|
set -euo pipefail
|
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
|
|
BIN="$HOME/.npm-global/bin"
|
|
BUSINESS=mosaic-stack BUSINESS_SET=0
|
|
ALLOW_WORKTREE=0 UNITS=user BUNDLE="" CREATE=0 CLEAN=""
|
|
MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)'
|
|
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | copy --bundle FILE SHA [--copies DIR] | wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] [--create] | check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID] [--units user|external]' >&2; exit "${1:-4}"; }
|
|
die() { echo "stack-copy: $*" >&2; exit 1; }
|
|
plain_path() { case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac; case "$1" in *[!A-Za-z0-9/._+-]*) die "$2 contains a character the unit cannot carry: $1" ;; esac; }
|
|
parse() { while [ $# -gt 0 ]; do case "$1" in
|
|
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2;;
|
|
--bin) [ "$CMD" != copy ] && [ $# -ge 2 ] || usage; BIN="$2";shift 2;;
|
|
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";BUSINESS_SET=1;shift 2;;
|
|
--allow-worktree) [ "$CMD" = copy ] && [ -z "$BUNDLE" ] || usage; ALLOW_WORKTREE=1;shift;;
|
|
--units) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; case "$2" in user|external) UNITS="$2";; *) usage;; esac; shift 2;;
|
|
--create) [ "$CMD" = wrapper ] || usage; CREATE=1;shift;;
|
|
*) usage;; esac; done; plain_path "$COPIES" 'copies directory'; }
|
|
copy_root() { [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || die '--copy must be a full 40-character commit id'; [ -f "$COPIES/$ID/scripts/mosaic" ] || die "no copy $ID in $COPIES"; echo "$COPIES/$ID"; }
|
|
# Closure of scripts/mosaic, bus process/notifier/launcher, Console serve,
|
|
# human-cli and both harness adapters, including on-demand children/data.
|
|
COPY_PATHS=(
|
|
scripts/mosaic scripts/mosaic-config.mjs
|
|
adapters roles RELEASE package.json package-lock.json
|
|
packages/bus/src packages/bus/schema.sql packages/business/src packages/cli/src packages/project
|
|
packages/conversation/src packages/discord/src packages/discord/bin packages/discord/extension packages/harness/src
|
|
packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json
|
|
packages/processing-control
|
|
packages/webui/src packages/webui/README.md packages/cli/README.md
|
|
packages/queue/src packages/library/src packages/library/bin
|
|
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
|
|
packages/plugins
|
|
# The unslop check both harness modules mount and list in the bundle (row 66).
|
|
tools/unslop-hook
|
|
)
|
|
# stack-release.sh sources this file and calls reachable before an activate (D1).
|
|
reachable() {
|
|
local ref
|
|
for ref in refs/heads/refactor refs/remotes/origin/refactor; do
|
|
git -C "$REPO" rev-parse --verify --quiet "$ref^{commit}" >/dev/null || die "no $ref in $REPO"
|
|
git -C "$REPO" merge-base --is-ancestor "$1" "$ref" || die "refusing: $1 is not reachable from $ref"
|
|
done
|
|
}
|
|
copy_commit() {
|
|
[ -d "$REPO/.git" ] || [ "$ALLOW_WORKTREE" = 1 ] || die "refusing: $REPO is not the canonical checkout (its .git is not a directory); run copy from the canonical checkout"
|
|
[[ "$ID" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] || die "commit must be a commit id or branch name: $ID"
|
|
local full
|
|
full="$(git -C "$REPO" rev-parse --verify --quiet "$ID^{commit}")" || die "not a commit: $ID"
|
|
reachable "$full"
|
|
copy_tree "$REPO" "$full"
|
|
}
|
|
# The copy of commit $2 from repository $1. CLEAN names a bundle's temporary repository, which the
|
|
# trap removes with the temporary copy.
|
|
copy_tree() {
|
|
local full="$2" target tmp
|
|
target="$COPIES/$full"
|
|
[ ! -L "$target" ] || die "refusing: copy target is a symlink: $target"
|
|
if [ -e "$target" ]; then echo "exists: $target"; return; fi
|
|
mkdir -p "$COPIES"
|
|
tmp="$(mktemp -d "$COPIES/.$full.XXXXXX")"
|
|
trap "chmod -R u+w '$tmp' 2>/dev/null || true; rm -rf '$tmp'$CLEAN" EXIT
|
|
git -C "$1" archive --format=tar "$full" -- "${COPY_PATHS[@]}" | tar -xf - -C "$tmp" || die "git archive failed; no copy written"
|
|
[ -f "$tmp/package.json" ] && [ -f "$tmp/package-lock.json" ] || die 'package.json or package-lock.json missing in temp copy; npm ci not run'
|
|
(cd "$tmp" && npm ci --no-audit --no-fund) >&2 || die 'npm ci failed; no copy written'
|
|
find "$tmp" -type f -exec chmod a-w {} +
|
|
find "$tmp" -type d -exec chmod 0555 {} +
|
|
if ! mv -T "$tmp" "$target" 2>/dev/null; then
|
|
[ -d "$target" ] && [ ! -L "$target" ] || die "could not rename copy into $target"
|
|
echo "exists: $target"; return
|
|
fi
|
|
trap - EXIT
|
|
echo "copied: $target"
|
|
}
|
|
# Row 232: the copy of SHA from a git bundle, for a host with no refactor checkout. The bundle must
|
|
# verify, carry refs/heads/refactor, and reach SHA from it, as reachable asks of a checkout. Prints
|
|
# the RELEASE value at SHA and the bundle's sha256, the release identity on such a host.
|
|
copy_bundle() {
|
|
[[ "$ID" =~ ^[0-9a-f]{40}$ ]] || die "--bundle needs a full 40-character commit id: $ID"
|
|
[ -f "$BUNDLE" ] && [ ! -L "$BUNDLE" ] || die "refusing: bundle is absent, not regular, or a symlink: $BUNDLE"
|
|
local g sum release heads
|
|
case "$BUNDLE" in /*) ;; *) BUNDLE="$PWD/$BUNDLE";; esac
|
|
g="$(mktemp -d)"
|
|
CLEAN=" '$g'"
|
|
trap "rm -rf '$g'" EXIT
|
|
git init -q --bare "$g" || die 'git init failed; no copy written'
|
|
git -C "$g" bundle verify -q "$BUNDLE" >/dev/null 2>&1 || die "refusing: git bundle verify failed: $BUNDLE"
|
|
heads="$(git -C "$g" bundle list-heads "$BUNDLE" refs/heads/refactor)" || die "refusing: cannot list the bundle's heads: $BUNDLE"
|
|
grep -qx '[0-9a-f]\{40\} refs/heads/refactor' <<< "$heads" || die "refusing: the bundle has no refs/heads/refactor: $BUNDLE"
|
|
# Every ref, so a SHA the bundle carries on another branch reaches the reachability check.
|
|
git -C "$g" fetch -q "$BUNDLE" 'refs/*:refs/*' 2>/dev/null || die "refusing: cannot fetch from $BUNDLE"
|
|
git -C "$g" cat-file -e "$ID^{commit}" 2>/dev/null || die "refusing: $ID is not in the bundle"
|
|
git -C "$g" merge-base --is-ancestor "$ID" refs/heads/refactor || die "refusing: $ID is not reachable from the bundle's refs/heads/refactor"
|
|
release="$(git -C "$g" show "$ID:RELEASE")" || die "no RELEASE at $ID"
|
|
sum="$(sha256sum < "$BUNDLE" | cut -d' ' -f1)"
|
|
copy_tree "$g" "$ID"
|
|
rm -rf "$g"; trap - EXIT
|
|
echo "release: $release"
|
|
echo "bundle sha256: $sum"
|
|
}
|
|
# Row 232: --create writes the wrapper only where none is. ln refuses any existing path, a symlink
|
|
# included, so this is the one check, and a wrapper that appears mid-run is never replaced.
|
|
place() {
|
|
if [ "$CREATE" = 1 ]; then ln "$1" "$2" 2>/dev/null || die "refusing: --create and a wrapper exists: $2"; rm -f "$1"
|
|
else mv -f "$1" "$2"; fi
|
|
}
|
|
wrapper() {
|
|
[ -z "$MOSAIC_UNIT_SUFFIX" ] || { wrapper_instance; return; }
|
|
plain_path "$BIN" 'bin directory'
|
|
local root file lease tmp
|
|
root="$(copy_root)";file="$BIN/mosaic"
|
|
lease='[ "${1:-}" = __lease-capability ] && exec "'"$BIN"'/mosaic-old" "$@"'
|
|
if [ "$CREATE" = 0 ]; then
|
|
[ -f "$file" ] && [ ! -L "$file" ] || die "refusing: wrapper is absent, not regular, or a symlink: $file"
|
|
[ "$(sed -n '2p' "$file")" = "$MARKER" ] || die "refusing: foreign wrapper (missing Q81 marker on line 2): $file"
|
|
[ "$(grep -Fxc "$lease" "$file" || true)" = 1 ] || die 'refusing: Q81 lease line missing or ambiguous'
|
|
fi
|
|
tmp="$(mktemp "$BIN/.mosaic.XXXXXX")"
|
|
trap 'rm -f "$tmp"' EXIT
|
|
printf '#!/usr/bin/env bash\n%s\n# CLI runs from pinned copy: %s\n# The Q81 lease probe still answers at mosaic-old.\n%s\nexec "%s/scripts/mosaic" "$@"\n' "$MARKER" "$root" "$lease" "$root" > "$tmp"
|
|
chmod 0755 "$tmp";place "$tmp" "$file";trap - EXIT
|
|
echo "wrapper: $root"
|
|
}
|
|
# Row 190: $BIN/mosaic-NAME. It creates the file or replaces one with its own marker, never another.
|
|
# NAME's config path is plain (instance.sh refuses any other), so it goes in single quotes as is.
|
|
wrapper_instance() {
|
|
plain_path "$BIN" 'bin directory'
|
|
local root file tmp
|
|
root="$(copy_root)";file="$BIN/$WRAPPER_NAME"
|
|
if [ -e "$file" ] || [ -L "$file" ]; then
|
|
[ -f "$file" ] && [ ! -L "$file" ] || die "refusing: wrapper is not regular, or a symlink: $file"
|
|
[ "$(sed -n '2p' "$file")" = "$INSTANCE_MARKER" ] || die "refusing: foreign wrapper (missing the instance $MOSAIC_INSTANCE marker on line 2): $file"
|
|
fi
|
|
tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")"
|
|
trap 'rm -f "$tmp"' EXIT
|
|
printf '#!/usr/bin/env bash\n%s\n# CLI runs from pinned copy: %s\nexport MOSAIC_CONFIG='"'"'%s'"'"'\nexec "%s/scripts/mosaic" "$@"\n' "$INSTANCE_MARKER" "$root" "$MOSAIC_INSTANCE_CONFIG" "$root" > "$tmp"
|
|
chmod 0755 "$tmp";place "$tmp" "$file";trap - EXIT
|
|
echo "wrapper: $root"
|
|
}
|
|
# --units external reads root's system units, which the stack user can read and not change.
|
|
units_show() { if [ "$UNITS" = external ]; then systemctl show "$@"; else systemctl --user show "$@"; fi; }
|
|
unit_root() {
|
|
local out load root exec_start
|
|
out="$(units_show "$1" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || { echo 'absent (systemctl read failed)';return; }
|
|
load="$(sed -n 's/^LoadState=//p' <<< "$out")"; root="$(sed -n 's/^WorkingDirectory=//p' <<< "$out")"
|
|
exec_start="$(sed -n 's/^ExecStart=//p' <<< "$out")"
|
|
if [ "$load" != loaded ] || [ -z "$root" ];then echo absent;return;fi
|
|
case "$1" in
|
|
mosaic-bus@*|mosaic-bus-*@*) [[ "$exec_start" == *"path=$root/scripts/mosaic ;"* ]] || { echo "$root (ExecStart mismatch)";return; };;
|
|
mosaic-webui*) [[ "$exec_start" == *"$root/packages/webui/src/cli.mjs serve"* ]] || { echo "$root (ExecStart mismatch)";return; };;
|
|
esac
|
|
echo "$root"
|
|
}
|
|
check() {
|
|
plain_path "$BIN" 'bin directory';[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die 'invalid business id'
|
|
local bus console cli=absent line path marker="$MARKER" wfile="$BIN/$WRAPPER_NAME"
|
|
[ -z "$MOSAIC_UNIT_SUFFIX" ] || marker="$INSTANCE_MARKER"
|
|
bus="$(unit_root "${BUS_TEMPLATE%.service}$BUSINESS.service")";console="$(unit_root "$CONSOLE_UNIT")"
|
|
if [ -f "$wfile" ] && [ ! -L "$wfile" ] && [ "$(sed -n '2p' "$wfile")" = "$marker" ]; then
|
|
line="$(tail -n 1 "$wfile")"
|
|
path="${line#exec \"}";path="${path%\" \"\$@\"}"
|
|
if [ "$line" = "exec \"$path\" \"\$@\"" ] && [[ "$path" == */scripts/mosaic ]];then cli="${path%/scripts/mosaic}";fi
|
|
# A named wrapper must also carry NAME's config, as wrapper_instance writes it.
|
|
if [ -n "$MOSAIC_UNIT_SUFFIX" ] && [ "$cli" != absent ] && [ "$(sed -n '4p' "$wfile")" != "export MOSAIC_CONFIG='$MOSAIC_INSTANCE_CONFIG'" ];then cli="$cli (MOSAIC_CONFIG mismatch)";fi
|
|
fi
|
|
printf 'bus: %s\nConsole: %s\nCLI: %s\n' "$bus" "$console" "$cli"
|
|
[[ "$bus" == "$COPIES/"* ]] && [[ "${bus#"$COPIES/"}" =~ ^[0-9a-f]{40}$ ]] && [ -f "$bus/scripts/mosaic" ] && [ "$bus" = "$console" ] && [ "$bus" = "$cli" ]
|
|
}
|
|
# Sourced (stack-release.sh): define the functions, run nothing.
|
|
[ "${BASH_SOURCE[0]}" = "$0" ] || return 0
|
|
. "$REPO/scripts/instance.sh"
|
|
instance_args "$@"
|
|
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
|
|
[ $# -gt 0 ] || usage
|
|
CMD="$1";shift
|
|
case "$CMD" in check|wrapper) instance_select;; *) [ -z "$INSTANCE_FLAG" ] || usage;; esac
|
|
INSTANCE_MARKER="# mosaic-cli wrapper, instance ${MOSAIC_INSTANCE:-dev} (row 190)"
|
|
case "$CMD" in
|
|
copy|wrapper) [ $# -gt 0 ] || usage
|
|
if [ "$CMD" = copy ] && [ "$1" = --bundle ];then [ $# -ge 3 ] && [ -n "$2" ] || usage;BUNDLE="$2";shift 2;fi
|
|
ID="$1";shift;case "$ID" in -*)usage;;esac;parse "$@"
|
|
if [ -n "$BUNDLE" ];then copy_bundle;elif [ "$CMD" = copy ];then copy_commit;else wrapper;fi;;
|
|
check) parse "$@";[ -z "$MOSAIC_UNIT_SUFFIX" ] || [ "$BUSINESS_SET" = 1 ] || usage;check;;
|
|
-h|--help) usage 0;;
|
|
*) usage;;esac
|