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
+128
View File
@@ -0,0 +1,128 @@
#requires -Version 5.1
<#
CASAN global installer (Plan-21 hybrid model) — Windows PowerShell.
irm https://<your-gitea>/admin/casan5/raw/branch/main/install.ps1 | iex
# or, from a local CASAN source checkout:
pwsh .\install.ps1
Installs the harness ONCE under $env:CASAN_HOME (default %LOCALAPPDATA%\casan),
writes a `casan` launcher, and records the gate-code integrity hash. Projects
then run `casan init` to adopt CASAN with only per-project config.
Requires: python3 on PATH, and bash (Git for Windows / Git Bash) to RUN the
harness — see docs/casan/CASAN_AGENTIC_CLIENTS_WINDOWS.md.
#>
[CmdletBinding()]
param(
[string]$Source,
[switch]$NoPathLink
)
$ErrorActionPreference = 'Stop'
function Log($m) { Write-Host "[casan-install] $m" }
function Die($m) { Write-Error "[casan-install] ERROR: $m"; exit 1 }
$py = (Get-Command python3 -ErrorAction SilentlyContinue) ?? (Get-Command python -ErrorAction SilentlyContinue)
if (-not $py) { Die 'python3 is required on PATH.' }
$CasanHome = if ($env:CASAN_HOME) { $env:CASAN_HOME } else { Join-Path $env:LOCALAPPDATA 'casan' }
# ── 1) Locate source (param, CASAN_SRC, local checkout, or CASAN_DIST_URL) ────
$cleanup = $null
if (-not $Source) { $Source = $env:CASAN_SRC }
if ((-not $Source) -and (Test-Path './packages/casan-harness')) { $Source = (Get-Location).Path }
if ((-not $Source) -and $env:CASAN_DIST_URL) {
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("casan-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
$cleanup = $tmp
Log "downloading CASAN from $($env:CASAN_DIST_URL)"
$tgz = Join-Path $tmp 'casan.tar.gz'
Invoke-WebRequest -UseBasicParsing -Uri $env:CASAN_DIST_URL -OutFile $tgz
tar -xzf $tgz -C $tmp
if (Test-Path (Join-Path $tmp 'packages/casan-harness')) { $Source = $tmp }
else {
$found = Get-ChildItem -Path $tmp -Recurse -Directory -Filter 'casan-harness' -Depth 2 | Select-Object -First 1
if ($found) { $Source = (Split-Path -Parent (Split-Path -Parent $found.FullName)) }
}
}
if ((-not $Source) -or (-not (Test-Path (Join-Path $Source 'packages/casan-harness')))) {
Die 'no CASAN source found. Run from a checkout, pass -Source <dir>, or set CASAN_DIST_URL.'
}
$version = if (Test-Path (Join-Path $Source 'VERSION')) { (Get-Content (Join-Path $Source 'VERSION') -Raw).Trim() } else { '0.0.0' }
Log "source : $Source"
Log "version : $version"
Log "install : $CasanHome"
# ── 2) Copy harness + devkit + CLI into a versioned dir ──────────────────────
$dest = Join-Path $CasanHome "versions\$version"
if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
New-Item -ItemType Directory -Force -Path (Join-Path $dest 'packages'), (Join-Path $dest 'bin') | Out-Null
function Copy-Tree($rel) {
$src = Join-Path $Source $rel
if (-not (Test-Path $src)) { return }
$dst = Join-Path $dest $rel
New-Item -ItemType Directory -Force -Path $dst | Out-Null
Copy-Item -Recurse -Force (Join-Path $src '*') $dst
}
Copy-Tree 'packages\casan-harness'
Copy-Tree 'packages\casan-devkit'
Copy-Item -Force (Join-Path $Source 'bin\casan') (Join-Path $dest 'bin\casan')
Set-Content -Path (Join-Path $dest 'VERSION') -Value $version -Encoding ASCII
Get-ChildItem -Path $dest -Recurse -Directory -Filter '__pycache__' -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $dest -Recurse -File -Filter '*.pyc' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
# ── 3) Record gate-code integrity hash ───────────────────────────────────────
$hasher = Join-Path $dest 'packages\casan-harness\scripts\python\harness_hash.py'
$hhash = (& $py.Source $hasher compute (Join-Path $dest 'packages\casan-harness')).Trim()
if (-not $hhash) { Die 'failed to compute harness hash.' }
Set-Content -Path (Join-Path $dest '.harness-hash') -Value $hhash -Encoding ASCII
Log "integrity: $hhash"
# ── 4) Point current -> this version ─────────────────────────────────────────
$cur = Join-Path $CasanHome 'current'
if (Test-Path $cur) { (Get-Item $cur).Delete() }
New-Item -ItemType Junction -Path $cur -Target $dest | Out-Null
# ── 5) Launcher: casan.cmd -> casan.ps1 (sets env, invokes the bash CLI) ─────
$binDir = Join-Path $CasanHome 'bin'
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
$launcherPs1 = @'
$ErrorActionPreference = "Stop"
$CasanHome = if ($env:CASAN_HOME) { $env:CASAN_HOME } else { Join-Path $env:LOCALAPPDATA "casan" }
$cur = Join-Path $CasanHome "current"
if (-not (Test-Path $cur)) { Write-Error "casan: no install at $cur (run install.ps1)"; exit 1 }
$env:CASAN_HARNESS_ROOT = Join-Path $cur "packages\casan-harness"
$env:CASAN_DEVKIT_ROOT = Join-Path $cur "packages\casan-devkit"
$env:CASAN_INSTALL_ROOT = $cur
if (-not $env:CASAN_APP_ROOT) {
$d = (Get-Location).Path
while ($d -and (Split-Path $d -Parent)) {
if ((Test-Path (Join-Path $d ".casan")) -or (Test-Path (Join-Path $d ".specify"))) { $env:CASAN_APP_ROOT = $d; break }
$d = Split-Path $d -Parent
}
}
$bash = (Get-Command bash -ErrorAction SilentlyContinue)
if (-not $bash) { Write-Error "casan: bash not found. Install Git for Windows (Git Bash)."; exit 1 }
& $bash.Source (Join-Path $cur "bin/casan") @args
exit $LASTEXITCODE
'@
Set-Content -Path (Join-Path $binDir 'casan.ps1') -Value $launcherPs1 -Encoding UTF8
Set-Content -Path (Join-Path $binDir 'casan.cmd') -Value "@echo off`r`npowershell -NoProfile -ExecutionPolicy Bypass -File `"%~dp0casan.ps1`" %*" -Encoding ASCII
# ── 6) Put launcher on PATH (user scope) ─────────────────────────────────────
if (-not $NoPathLink -and $env:CASAN_NO_PATH_LINK -ne '1') {
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath -notlike "*$binDir*") {
[Environment]::SetEnvironmentVariable('Path', "$binDir;$userPath", 'User')
Log "added $binDir to user PATH (restart shell to pick it up)"
}
}
Log "installed CASAN $version"
Log "launcher : $binDir\casan.cmd"
Log "next: cd <your-project>; casan init"
if ($cleanup) { Remove-Item -Recurse -Force $cleanup -ErrorAction SilentlyContinue }