feat(install): Plan-21 hybrid global install + casan init (pin+verify)

Adopt CASAN like a normal tool (codegraph-style): install the harness ONCE
per machine, then `casan init` per project writes CONFIG ONLY — the harness
is no longer copied into every repo.

- install.sh / install.ps1: global bootstrap (curl|sh / irm|iex or local
  source). Installs harness to $CASAN_HOME/versions/<ver>, writes a `casan`
  launcher that resolves the shared harness + the current project's .specify,
  and records a gate-code integrity hash. CASAN_NO_PATH_LINK for tests.
- harness_hash.py: deterministic content hash over gate code (scripts/bash,
  scripts/python, security, level5) — the pin+verify anchor.
- casan-init.py: `casan init` writes .casan/{config,version.lock,agentic.env},
  .specify/ marker, and the Plan-20 client hooks — no harness copy. `verify`
  recomputes the harness hash LIVE and compares to the project pin (drift/
  tamper -> rc 3), preserving the Plan-16 trusted-gates guarantee off-repo.
- bin/casan: new `init` and `verify-harness` commands.
- hybrid-install-tests.sh: 21/21 (install, config-only init, no-copy, pin,
  verify ok, tamper drift, bridge runs against project state via global harness).
- docs: CASAN_INSTALL_HYBRID.md + Plan-21.

The path model (casan-paths.sh) already separated harness/state/domain roots,
so this is installer + init, not a core rewrite. Remote dist tarball, real
Windows run, and signed .harness-hash are the documented next steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-23 21:26:54 +07:00
co-authored by Claude Opus 4.8
parent f6d28a3163
commit 8450f8ca1a
8 changed files with 904 additions and 0 deletions
Executable
+132
View File
@@ -0,0 +1,132 @@
#!/bin/sh
# CASAN global installer (Plan-21 hybrid model) — macOS / Linux.
#
# curl -fsSL https://<your-gitea>/admin/casan5/raw/branch/main/install.sh | sh
# # or, from a local CASAN source checkout:
# sh install.sh
#
# Installs the CASAN harness ONCE under $CASAN_HOME (default ~/.casan), puts a
# `casan` launcher on PATH, and records a content-integrity hash of the gate code.
# Projects then run `casan init` to adopt CASAN with only per-project config —
# the harness itself is NOT copied into every repo (that is the hybrid model).
#
# POSIX sh (runs under `curl | sh`). The harness itself needs bash + python3 to
# run; this bootstrap only needs sh, cp and python3.
set -eu
CASAN_HOME="${CASAN_HOME:-$HOME/.casan}"
log() { printf '[casan-install] %s\n' "$1"; }
die() { printf '[casan-install] ERROR: %s\n' "$1" >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || die "python3 is required on PATH."
# ── 1) Locate the source (local checkout, explicit CASAN_SRC, or a dist tarball)
SRC=""
CLEANUP=""
if [ -n "${CASAN_SRC:-}" ] && [ -d "$CASAN_SRC/packages/casan-harness" ]; then
SRC="$CASAN_SRC"
elif [ -d "./packages/casan-harness" ]; then
SRC="$(pwd)"
elif [ -n "${CASAN_DIST_URL:-}" ]; then
command -v curl >/dev/null 2>&1 || die "curl is required to fetch CASAN_DIST_URL."
TMP="$(mktemp -d)"
CLEANUP="$TMP"
log "downloading CASAN from $CASAN_DIST_URL"
curl -fsSL "$CASAN_DIST_URL" -o "$TMP/casan.tar.gz" || die "download failed: $CASAN_DIST_URL"
tar -xzf "$TMP/casan.tar.gz" -C "$TMP" || die "extract failed."
# Accept either a flat tarball or a single top-level dir.
if [ -d "$TMP/packages/casan-harness" ]; then
SRC="$TMP"
else
SRC="$(find "$TMP" -maxdepth 2 -type d -name casan-harness -exec dirname {} \; -quit)"
SRC="$(dirname "$SRC" 2>/dev/null || true)"
[ -n "$SRC" ] && [ -d "$SRC/packages/casan-harness" ] || die "tarball has no packages/casan-harness."
fi
else
die "no CASAN source found. Run from a CASAN checkout, set CASAN_SRC=<dir>, or set CASAN_DIST_URL=<tarball>."
fi
VERSION="$(cat "$SRC/VERSION" 2>/dev/null || echo "0.0.0")"
log "source : $SRC"
log "version : $VERSION"
log "install : $CASAN_HOME"
# ── 2) Copy harness + devkit + CLI into a versioned dir ──────────────────────
DEST="$CASAN_HOME/versions/$VERSION"
rm -rf "$DEST"
mkdir -p "$DEST/packages" "$DEST/bin"
copy_tree() { # <relpath>
[ -e "$SRC/$1" ] || return 0
if command -v rsync >/dev/null 2>&1; then
rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' \
--exclude='*.log' "$SRC/$1/" "$DEST/$1/"
else
mkdir -p "$DEST/$1"
cp -R "$SRC/$1/." "$DEST/$1/"
fi
}
copy_tree "packages/casan-harness"
copy_tree "packages/casan-devkit"
cp "$SRC/bin/casan" "$DEST/bin/casan"
chmod +x "$DEST/bin/casan"
cp "$SRC/VERSION" "$DEST/VERSION" 2>/dev/null || printf '%s\n' "$VERSION" > "$DEST/VERSION"
# Prune stray caches the copy may have carried.
find "$DEST" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
find "$DEST" -name '*.pyc' -delete 2>/dev/null || true
# ── 3) Record the gate-code integrity hash (pin+verify anchor) ───────────────
HASHER="$DEST/packages/casan-harness/scripts/python/harness_hash.py"
HHASH="$(python3 "$HASHER" compute "$DEST/packages/casan-harness")" || die "failed to compute harness hash."
printf '%s\n' "$HHASH" > "$DEST/.harness-hash"
log "integrity: $HHASH"
# ── 4) Point `current` at this version ───────────────────────────────────────
ln -sfn "$DEST" "$CASAN_HOME/current"
# ── 5) Write the global launcher (shim) ──────────────────────────────────────
mkdir -p "$CASAN_HOME/bin"
cat > "$CASAN_HOME/bin/casan" <<'LAUNCH'
#!/usr/bin/env bash
# CASAN global launcher — resolves the shared harness and the CURRENT project.
set -uo pipefail
CASAN_HOME="${CASAN_HOME:-$HOME/.casan}"
CUR="$CASAN_HOME/current"
[[ -d "$CUR" ]] || { echo "casan: no install at $CUR (run install.sh)" >&2; exit 1; }
export CASAN_HARNESS_ROOT="$CUR/packages/casan-harness"
export CASAN_DEVKIT_ROOT="$CUR/packages/casan-devkit"
export CASAN_INSTALL_ROOT="$CUR"
# Project (app) root = nearest ancestor of CWD carrying a .casan/.specify marker.
if [[ -z "${CASAN_APP_ROOT:-}" ]]; then
_d="$PWD"
while [[ "$_d" != "/" && -n "$_d" ]]; do
if [[ -d "$_d/.casan" || -d "$_d/.specify" ]]; then export CASAN_APP_ROOT="$_d"; break; fi
_d="$(dirname "$_d")"
done
fi
exec bash "$CUR/bin/casan" "$@"
LAUNCH
chmod +x "$CASAN_HOME/bin/casan"
# ── 6) Put the launcher on PATH (skip with CASAN_NO_PATH_LINK=1, e.g. in tests)
LINKED=""
if [ "${CASAN_NO_PATH_LINK:-0}" != "1" ]; then
for d in "$HOME/.local/bin" "/usr/local/bin"; do
if [ -d "$d" ] && printf '%s' ":$PATH:" | grep -q ":$d:"; then
if ln -sf "$CASAN_HOME/bin/casan" "$d/casan" 2>/dev/null; then LINKED="$d/casan"; break; fi
fi
done
fi
log "installed CASAN $VERSION"
if [ -n "$LINKED" ]; then
log "launcher : $LINKED (on PATH)"
else
log "launcher : $CASAN_HOME/bin/casan"
log "add to PATH: export PATH=\"$CASAN_HOME/bin:\$PATH\" (add to your shell rc)"
fi
log "next: cd <your-project> && casan init"
[ -n "$CLEANUP" ] && rm -rf "$CLEANUP" || true