mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
fix(setup): install through install.sh / install.ps1, one install path
scripts/setup.sh and scripts/setup-windows.ps1 carried their own copy of the release download: resolve the latest tag, fetch the archive, unpack it into the install directory. Two implementations of the same step drifted apart: the setup scripts did not follow the installers as those gained their checks, their architecture selection and their staging directory. Both setup scripts now fetch the installer from the origin and branch they are themselves served from and hand the download and install over to it, so there is exactly one implementation and the setup scripts behave like the installers: the same conditions stop them with a non-zero exit before anything is installed, setup-windows.ps1 selects the archive through the installer's architecture detection instead of always amd64 (CBM_ARCH pins it) and stages in a freshly created directory, and the file is pure ASCII again. CBM_DOWNLOAD_URL, the installers' download-base override, also moves the installer fetch so both scripts can be driven against a local fixture. Everything the setup scripts do beyond installing the binary is unchanged: the --from-source / -FromSource builds, the interactive Claude Code configuration, the PATH hint, the git-hook activation and the uninstall notes. tests/test_setup_scripts_contract.sh runs setup.sh against a loopback fixture release built from a stub binary, in file mode and in curl | bash mode, covers the cases in which the installer refuses, and pins the static contract for setup-windows.ps1. It is Step 0z of scripts/test.sh. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
96c3f41cf3
commit
6c00f96311
4 files changed
+483
-91
No files matched your search
+90
-26
@@ -1,5 +1,5 @@
|
||||
# codebase-memory-mcp setup script (Windows)
|
||||
# Default: download pre-built native Windows binary
|
||||
# Default: install the pre-built native Windows binary through install.ps1
|
||||
# -FromSource: build from source inside WSL (requires Go + gcc in WSL)
|
||||
|
||||
param(
|
||||
@@ -123,7 +123,7 @@ if ($Help) {
|
||||
Write-Host ""
|
||||
Write-Host "Usage: .\setup-windows.ps1 [-FromSource] [-Help]"
|
||||
Write-Host ""
|
||||
Write-Host " Default: Download pre-built Windows binary"
|
||||
Write-Host " Default: Download the pre-built Windows binary through install.ps1"
|
||||
Write-Host " -FromSource: Build from source inside WSL (requires Go 1.23+ and gcc in WSL)"
|
||||
Write-Host ""
|
||||
exit 0
|
||||
@@ -225,7 +225,7 @@ if ($FromSource) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Configure — WSL binary needs wsl.exe wrapper
|
||||
# Configure -- WSL binary needs wsl.exe wrapper
|
||||
$mcpConfig = [ordered]@{
|
||||
type = "stdio"
|
||||
command = "wsl.exe"
|
||||
@@ -243,41 +243,105 @@ if ($FromSource) {
|
||||
Write-Host " wsl.exe -- rm -rf ~/.cache/codebase-memory-mcp/"
|
||||
|
||||
} else {
|
||||
# --- Download pre-built native Windows binary ---
|
||||
Write-Host "Fetching latest release..." -ForegroundColor White
|
||||
# --- Download + install through install.ps1 ---
|
||||
#
|
||||
# install.ps1 is the one implementation of "fetch a release and install
|
||||
# it": it downloads checksums.txt next to the archive, verifies the
|
||||
# archive's SHA-256 against it, validates the zip layout and only then
|
||||
# runs the binary's own `install`. This script used to carry a second
|
||||
# copy of that download, which did not keep up with the installer. It
|
||||
# now fetches install.ps1 from the same origin and branch it is itself
|
||||
# served from and hands over to it, so there is exactly one install path.
|
||||
#
|
||||
# CBM_DOWNLOAD_URL (the installers' download-base override, for local
|
||||
# testing) also moves the installer fetch: install.ps1 is then taken from
|
||||
# "$env:CBM_DOWNLOAD_URL/install.ps1".
|
||||
$installerUrl = "https://raw.githubusercontent.com/$Repo/main/install.ps1"
|
||||
if ($env:CBM_DOWNLOAD_URL) {
|
||||
$installerUrl = $env:CBM_DOWNLOAD_URL.TrimEnd('/') + "/install.ps1"
|
||||
}
|
||||
|
||||
$releaseUrl = "https://api.github.com/repos/$Repo/releases/latest"
|
||||
$release = Invoke-RestMethod -Uri $releaseUrl -Headers @{ "User-Agent" = "codebase-memory-mcp-setup" }
|
||||
$tag = $release.tag_name
|
||||
|
||||
if (-not $tag) {
|
||||
Write-Fail "Could not determine latest release."
|
||||
Write-Host " Check: https://github.com/$Repo/releases"
|
||||
# Same transport rule as install.ps1: HTTPS, or plain HTTP for a loopback
|
||||
# authority only (the local test fixture). No redirects are followed: both
|
||||
# origins serve the installer directly.
|
||||
try { $installerUri = [Uri]$installerUrl } catch { $installerUri = $null }
|
||||
$loopbackHttp = (
|
||||
$installerUri -and $installerUri.IsAbsoluteUri -and
|
||||
$installerUri.Scheme -eq "http" -and $installerUri.IsLoopback -and
|
||||
[string]::IsNullOrEmpty($installerUri.UserInfo)
|
||||
)
|
||||
if (-not $installerUri -or -not $installerUri.IsAbsoluteUri -or
|
||||
($installerUri.Scheme -ne "https" -and -not $loopbackHttp) -or
|
||||
-not [string]::IsNullOrEmpty($installerUri.UserInfo)) {
|
||||
Write-Fail "Refusing non-HTTPS installer URL: $installerUrl"
|
||||
exit 1
|
||||
}
|
||||
Write-Ok "Latest release: $tag"
|
||||
|
||||
$asset = "codebase-memory-mcp-windows-amd64.zip"
|
||||
$downloadUrl = "https://github.com/$Repo/releases/download/$tag/$asset"
|
||||
# TLS 1.2+ for the fetch (older Windows PowerShell defaults to TLS 1.0,
|
||||
# which GitHub rejects). TLS 1.3 only where schannel can negotiate it; see
|
||||
# the note in install.ps1.
|
||||
$protocols = [Net.SecurityProtocolType]::Tls12
|
||||
if ([Environment]::OSVersion.Version.Build -ge 20348 -and
|
||||
([enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13')) {
|
||||
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = $protocols
|
||||
|
||||
Write-Host "Downloading $asset..." -ForegroundColor White
|
||||
|
||||
# Create install directory
|
||||
if (-not (Test-Path $InstallDir)) {
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
# A fresh staging directory of its own with an owner-only DACL, the
|
||||
# way install.ps1 stages its own download. The ACL is best effort: a
|
||||
# filesystem that cannot carry one must not fail the install.
|
||||
$stageDir = Join-Path ([System.IO.Path]::GetTempPath()) "cbm-setup-$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $stageDir -Force | Out-Null
|
||||
try {
|
||||
$stageAcl = New-Object System.Security.AccessControl.DirectorySecurity
|
||||
$stageAcl.SetAccessRuleProtection($true, $false)
|
||||
$stageOwner = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User
|
||||
$stageAcl.SetOwner($stageOwner)
|
||||
$stageAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||
$stageOwner, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
|
||||
Set-Acl -Path $stageDir -AclObject $stageAcl -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Host " note: could not harden the staging directory ACL: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$tmpZip = Join-Path $env:TEMP $asset
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing
|
||||
$installer = Join-Path $stageDir "install.ps1"
|
||||
$failure = $null
|
||||
try {
|
||||
Write-Host "Fetching install.ps1..." -ForegroundColor White
|
||||
Invoke-WebRequest -Uri $installerUrl -OutFile $installer -UseBasicParsing -MaximumRedirection 0
|
||||
if (-not (Test-Path -LiteralPath $installer -PathType Leaf) -or
|
||||
(Get-Item -LiteralPath $installer).Length -eq 0) {
|
||||
throw "fetched an empty install.ps1 from $installerUrl"
|
||||
}
|
||||
Write-Ok "install.ps1 fetched"
|
||||
|
||||
# Extract
|
||||
Expand-Archive -Path $tmpZip -DestinationPath $InstallDir -Force
|
||||
Remove-Item $tmpZip -Force
|
||||
Write-Host ""
|
||||
Write-Host "Installing through install.ps1..." -ForegroundColor White
|
||||
# The installer configures nothing (--skip-config): agent configuration
|
||||
# stays this script's interactive step below. It runs in a child host
|
||||
# of the same PowerShell so its exit code is unambiguous and its global
|
||||
# settings do not leak into this session. The pre-seeded exit code
|
||||
# keeps a child that fails to start from reading as success.
|
||||
$hostExe = (Get-Process -Id $PID).Path
|
||||
$global:LASTEXITCODE = 1
|
||||
& $hostExe -NoProfile -ExecutionPolicy Bypass -File $installer "--dir=$InstallDir" --skip-config
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "install.ps1 exited with $LASTEXITCODE"
|
||||
}
|
||||
} catch {
|
||||
$failure = "$_"
|
||||
} finally {
|
||||
Remove-Item -Recurse -Force $stageDir -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($failure) {
|
||||
Write-Fail "Installation through install.ps1 failed: $failure"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$binaryPath = Join-Path $InstallDir "$BinaryName.exe"
|
||||
|
||||
if (-not (Test-Path $binaryPath)) {
|
||||
Write-Fail "Binary not found at $binaryPath after extraction"
|
||||
Write-Fail "Binary not found at $binaryPath after installation"
|
||||
exit 1
|
||||
}
|
||||
Write-Ok "Installed to $binaryPath"
|
||||
|
||||
+61
-65
@@ -2,14 +2,14 @@
|
||||
set -euo pipefail
|
||||
|
||||
# codebase-memory-mcp setup script (macOS + Linux)
|
||||
# Default: download pre-built binary from GitHub Release
|
||||
# Default: install the latest pre-built binary through install.sh
|
||||
# --from-source: build from source (requires Go + C compiler)
|
||||
|
||||
REPO="DeusData/codebase-memory-mcp"
|
||||
INSTALL_DIR="$HOME/.local/bin"
|
||||
BINARY_NAME="codebase-memory-mcp"
|
||||
SOURCE_DIR="$HOME/.local/share/codebase-memory-mcp"
|
||||
CLEANUP_DIR="" # set by download_binary for EXIT trap
|
||||
CLEANUP_DIR="" # set by install_release for EXIT trap
|
||||
|
||||
# --- Colors ---
|
||||
|
||||
@@ -43,7 +43,7 @@ for arg in "$@"; do
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--from-source]"
|
||||
echo ""
|
||||
echo " Default: Download pre-built binary from GitHub Release"
|
||||
echo " Default: Download the pre-built binary through install.sh"
|
||||
echo " --from-source: Clone and build from source (requires Go 1.23+ and a C compiler)"
|
||||
exit 0
|
||||
;;
|
||||
@@ -51,36 +51,6 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Platform detection ---
|
||||
|
||||
detect_platform() {
|
||||
local os arch
|
||||
os=$(uname -s)
|
||||
arch=$(uname -m)
|
||||
|
||||
case "$os" in
|
||||
Darwin) os="darwin" ;;
|
||||
Linux) os="linux" ;;
|
||||
*) die "Unsupported OS: $os. Use WSL2 on Windows." ;;
|
||||
esac
|
||||
|
||||
case "$arch" in
|
||||
arm64|aarch64) arch="arm64" ;;
|
||||
x86_64|amd64)
|
||||
# On macOS, uname -m returns x86_64 under Rosetta even on Apple Silicon.
|
||||
# Check the actual hardware to pick the right binary.
|
||||
if [ "$os" = "darwin" ] && sysctl -n hw.optional.arm64 2>/dev/null | grep -q '1'; then
|
||||
arch="arm64"
|
||||
else
|
||||
arch="amd64"
|
||||
fi
|
||||
;;
|
||||
*) die "Unsupported architecture: $arch" ;;
|
||||
esac
|
||||
|
||||
echo "${os}-${arch}"
|
||||
}
|
||||
|
||||
# --- Prerequisite checks ---
|
||||
|
||||
check_download_tool() {
|
||||
@@ -133,46 +103,74 @@ check_git() {
|
||||
ok "Git found"
|
||||
}
|
||||
|
||||
# --- Download binary ---
|
||||
# --- Download + install through install.sh ---
|
||||
#
|
||||
# install.sh is the one implementation of "fetch a release and install it": it
|
||||
# downloads checksums.txt next to the archive, verifies the archive's SHA-256
|
||||
# against it, checks the archive layout, and only then runs the binary's own
|
||||
# `install`. This script used to carry a second copy of that download,
|
||||
# which did not keep up with the installer. It now fetches install.sh from the
|
||||
# same origin and branch it is itself served from and hands over to it, so
|
||||
# there is exactly one install path.
|
||||
#
|
||||
# CBM_DOWNLOAD_URL (the installers' download-base override, for local testing)
|
||||
# also moves the installer fetch: install.sh is then taken from
|
||||
# "$CBM_DOWNLOAD_URL/install.sh".
|
||||
|
||||
fetch() {
|
||||
local url="$1" tool="$2"
|
||||
if [ "$tool" = "curl" ]; then
|
||||
curl -fsSL "$url"
|
||||
INSTALLER_URL="https://raw.githubusercontent.com/${REPO}/main/install.sh"
|
||||
if [ -n "${CBM_DOWNLOAD_URL:-}" ]; then
|
||||
INSTALLER_URL="${CBM_DOWNLOAD_URL%/}/install.sh"
|
||||
fi
|
||||
|
||||
# Same transport rule as install.sh: HTTPS everywhere; plain HTTP only for an
|
||||
# exact loopback authority (the local test fixture), with redirects disabled
|
||||
# there so a fixture cannot bounce the fetch to the network.
|
||||
is_loopback_http_url() {
|
||||
[[ "$1" =~ ^http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?\#].*)?$ ]]
|
||||
}
|
||||
|
||||
fetch_installer() {
|
||||
local url="$1" destination="$2" tool="$3"
|
||||
if is_loopback_http_url "$url"; then
|
||||
if [ "$tool" = "curl" ]; then
|
||||
curl -fsS --noproxy '*' --proto '=http' -o "$destination" "$url"
|
||||
else
|
||||
wget -q --no-proxy --max-redirect=0 -O "$destination" "$url"
|
||||
fi
|
||||
elif [[ "$url" == https://* ]]; then
|
||||
if [ "$tool" = "curl" ]; then
|
||||
curl -fsSL --max-redirs 5 --proto '=https' --proto-redir '=https' \
|
||||
-o "$destination" "$url"
|
||||
else
|
||||
wget -q --https-only --max-redirect=5 -O "$destination" "$url"
|
||||
fi
|
||||
else
|
||||
wget -qO- "$url"
|
||||
die "Refusing non-HTTPS installer URL: $url"
|
||||
fi
|
||||
}
|
||||
|
||||
download_binary() {
|
||||
local platform="$1" tool="$2"
|
||||
install_release() {
|
||||
local tool="$1"
|
||||
|
||||
echo ""
|
||||
echo "${BOLD}Fetching latest release...${RESET}"
|
||||
local tag
|
||||
tag=$(fetch "https://api.github.com/repos/${REPO}/releases/latest" "$tool" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"//;s/".*//')
|
||||
|
||||
if [ -z "$tag" ]; then
|
||||
die "Could not determine latest release. Check https://github.com/${REPO}/releases"
|
||||
fi
|
||||
ok "Latest release: $tag"
|
||||
|
||||
local asset="codebase-memory-mcp-${platform}.tar.gz"
|
||||
local url="https://github.com/${REPO}/releases/download/${tag}/${asset}"
|
||||
|
||||
echo "${BOLD}Downloading ${asset}...${RESET}"
|
||||
echo "${BOLD}Fetching install.sh...${RESET}"
|
||||
CLEANUP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$CLEANUP_DIR"' EXIT
|
||||
local tmpdir="$CLEANUP_DIR"
|
||||
local installer="$CLEANUP_DIR/install.sh"
|
||||
|
||||
fetch "$url" "$tool" > "${tmpdir}/${asset}"
|
||||
tar -xzf "${tmpdir}/${asset}" -C "$tmpdir"
|
||||
fetch_installer "$INSTALLER_URL" "$installer" "$tool" ||
|
||||
die "Could not fetch install.sh from ${INSTALLER_URL}"
|
||||
[ -s "$installer" ] || die "Fetched an empty install.sh from ${INSTALLER_URL}"
|
||||
ok "install.sh fetched"
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
mv "${tmpdir}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
|
||||
ok "Installed to ${INSTALL_DIR}/${BINARY_NAME}"
|
||||
echo ""
|
||||
echo "${BOLD}Installing through install.sh...${RESET}"
|
||||
# The installer configures nothing (--skip-config): agent configuration
|
||||
# stays this script's interactive step below. stdin is detached so that,
|
||||
# when this script itself arrives through `curl | bash`, nothing the
|
||||
# installer runs can read the rest of this script as its input.
|
||||
bash "$installer" --dir "$INSTALL_DIR" --skip-config </dev/null ||
|
||||
die "install.sh failed (see the messages above)"
|
||||
}
|
||||
|
||||
# --- Build from source ---
|
||||
@@ -297,11 +295,9 @@ echo ""
|
||||
if [ "$FROM_SOURCE" = true ]; then
|
||||
build_from_source
|
||||
else
|
||||
platform=$(detect_platform)
|
||||
ok "Platform: ${platform}"
|
||||
tool=$(check_download_tool)
|
||||
ok "Download tool: ${tool}"
|
||||
download_binary "$platform" "$tool"
|
||||
install_release "$tool"
|
||||
fi
|
||||
|
||||
# Verify binary
|
||||
|
||||
@@ -338,6 +338,9 @@ bash "$ROOT/tests/test_version_metadata_contract.sh"
|
||||
echo "=== Step 0y: VM leg verdict contract ==="
|
||||
bash "$ROOT/tests/test_vm_verdict_contract.sh"
|
||||
|
||||
echo "=== Step 0z: setup scripts install through the installers' verified path ==="
|
||||
bash "$ROOT/tests/test_setup_scripts_contract.sh"
|
||||
|
||||
# Verify compiler supports target arch
|
||||
verify_compiler "$CC"
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env bash
|
||||
# Contract: the setup scripts install through the installers' verified path.
|
||||
#
|
||||
# scripts/setup.sh and scripts/setup-windows.ps1 are the README's "Automated
|
||||
# download + install" one-liners. They used to resolve the latest release,
|
||||
# download the archive and unpack it on their own -- with none of the checks
|
||||
# install.sh and install.ps1 make before anything is installed or run:
|
||||
# checksums.txt must download, must list the archive, a hash tool must exist,
|
||||
# and the digest must match. Both setup scripts now hand the download to the
|
||||
# installer, so there is exactly ONE implementation of download + verify.
|
||||
#
|
||||
# Functional leg (macOS/Linux host): setup.sh runs against a loopback fixture
|
||||
# release built from a harmless stub "binary", in four variants. A correct
|
||||
# checksums.txt installs (file mode and `curl | bash` pipe mode); a wrong
|
||||
# digest, a missing checksums.txt and a PATH without any hash tool each stop
|
||||
# the script with a non-zero exit, nothing installed and the stub never run.
|
||||
# Everything lives in a scratch HOME under a private work directory; no real
|
||||
# release is ever downloaded or executed, and the network is unreachable for
|
||||
# the whole run (every proxy variable points at a closed loopback port; the
|
||||
# fixture is reached with --noproxy).
|
||||
#
|
||||
# Static leg (every host): setup-windows.ps1 must delegate to install.ps1,
|
||||
# must not resolve, download or unpack the release itself, must stage in a
|
||||
# fresh directory of its own (not directly under %TEMP%), must honour CBM_DOWNLOAD_URL, and must stay
|
||||
# pure ASCII (Windows PowerShell 5.1 reads a BOM-less file as ANSI). The
|
||||
# functional equivalent is the Windows VM leg, which serves the same kind of
|
||||
# fixture to install.ps1 (test-infrastructure/vm/vm-smoke.sh).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SETUP_SH="$ROOT/scripts/setup.sh"
|
||||
SETUP_PS1="$ROOT/scripts/setup-windows.ps1"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Static contract: scripts/setup.sh ──────────────────────────────────────
|
||||
#
|
||||
# These gate the functional leg on purpose: a setup.sh that ignores
|
||||
# CBM_DOWNLOAD_URL cannot be pointed at the fixture and would reach for the
|
||||
# real release. The contract refuses to run that script at all.
|
||||
grep -q 'CBM_DOWNLOAD_URL' "$SETUP_SH" ||
|
||||
fail "scripts/setup.sh must honour CBM_DOWNLOAD_URL (the installers' download-base override)"
|
||||
grep -q 'install\.sh' "$SETUP_SH" ||
|
||||
fail "scripts/setup.sh must install through install.sh"
|
||||
if grep -Eq 'tar +-[a-zA-Z]*x' "$SETUP_SH"; then
|
||||
fail "scripts/setup.sh must not unpack the release archive itself"
|
||||
fi
|
||||
if grep -Eq 'api\.github\.com|releases/download' "$SETUP_SH"; then
|
||||
fail "scripts/setup.sh must not resolve or download the release itself (install.sh owns that)"
|
||||
fi
|
||||
|
||||
# ── Static contract: scripts/setup-windows.ps1 ─────────────────────────────
|
||||
if LC_ALL=C grep -n $'[^ -~\t]' "$SETUP_PS1"; then
|
||||
fail "scripts/setup-windows.ps1 must be pure ASCII (PS 5.1 reads BOM-less files as ANSI)"
|
||||
fi
|
||||
if grep -q $'\r' "$SETUP_PS1"; then
|
||||
fail "scripts/setup-windows.ps1 must use LF line endings in the repository"
|
||||
fi
|
||||
grep -q 'install\.ps1' "$SETUP_PS1" ||
|
||||
fail "scripts/setup-windows.ps1 must install through install.ps1"
|
||||
grep -q 'CBM_DOWNLOAD_URL' "$SETUP_PS1" ||
|
||||
fail "scripts/setup-windows.ps1 must honour CBM_DOWNLOAD_URL (the installers' download-base override)"
|
||||
if grep -q 'Expand-Archive' "$SETUP_PS1"; then
|
||||
fail "scripts/setup-windows.ps1 must not unpack the release archive itself"
|
||||
fi
|
||||
if grep -Eq 'api\.github\.com|releases/download' "$SETUP_PS1"; then
|
||||
fail "scripts/setup-windows.ps1 must not resolve or download the release itself (install.ps1 owns that)"
|
||||
fi
|
||||
if grep -q 'env:TEMP' "$SETUP_PS1"; then
|
||||
fail "scripts/setup-windows.ps1 must stage in a fresh directory of its own, not directly under %TEMP%"
|
||||
fi
|
||||
grep -q 'GetTempPath' "$SETUP_PS1" && grep -q 'Get-Random' "$SETUP_PS1" ||
|
||||
fail "scripts/setup-windows.ps1 must stage in a freshly created private directory (as install.ps1 does)"
|
||||
|
||||
# No PowerShell parser is available on every host; a bracket balance over the
|
||||
# file with comments and quoted strings removed still catches a truncated or
|
||||
# mis-pasted script before the Windows leg does.
|
||||
python3 - "$SETUP_PS1" <<'PY' || fail "scripts/setup-windows.ps1 has unbalanced brackets"
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="ascii").read()
|
||||
text = re.sub(r"<#.*?#>", "", text, flags=re.S)
|
||||
text = re.sub(r"#[^\n]*", "", text)
|
||||
text = re.sub(r"'(?:[^']|'')*'", "''", text)
|
||||
text = re.sub(r'"(?:[^"\\`]|`.|\\.)*"', '""', text)
|
||||
for open_ch, close_ch in (("{", "}"), ("(", ")"), ("[", "]")):
|
||||
if text.count(open_ch) != text.count(close_ch):
|
||||
print(f"unbalanced {open_ch}{close_ch}: {text.count(open_ch)} vs {text.count(close_ch)}")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
echo "OK: static contract (setup.sh delegates to install.sh, setup-windows.ps1 to install.ps1)"
|
||||
|
||||
# ── Functional leg: setup.sh against a loopback fixture release ────────────
|
||||
case "$(uname -s)" in
|
||||
Darwin) OS=darwin ;;
|
||||
Linux) OS=linux ;;
|
||||
*)
|
||||
# WHY: setup.sh is the macOS/Linux one-liner; on a Windows host it would
|
||||
# select no archive at all. The Windows setup script's functional
|
||||
# coverage is the VM leg that serves a fixture to install.ps1 (the
|
||||
# script it delegates to). Tried: running the POSIX leg under MSYS --
|
||||
# install.sh then targets a windows-*.zip, which is not what setup.sh
|
||||
# ever installs, so the run would prove nothing about this contract.
|
||||
echo "SKIP: functional leg needs a macOS/Linux host ($(uname -s)); static contract passed"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
case "$(uname -m)" in
|
||||
arm64 | aarch64) ARCH=arm64 ;;
|
||||
x86_64 | amd64)
|
||||
# Match install.sh: a Rosetta shell reports x86_64 on Apple Silicon.
|
||||
if [ "$OS" = "darwin" ] &&
|
||||
sysctl -n machdep.cpu.brand_string 2>/dev/null | grep -qi apple; then
|
||||
ARCH=arm64
|
||||
else
|
||||
ARCH=amd64
|
||||
fi
|
||||
;;
|
||||
*) fail "unsupported host architecture $(uname -m)" ;;
|
||||
esac
|
||||
PORTABLE=""
|
||||
[ "$OS" = "linux" ] && PORTABLE="-portable"
|
||||
ARCHIVE="codebase-memory-mcp-${OS}-${ARCH}${PORTABLE}.tar.gz"
|
||||
|
||||
for tool in python3 curl tar shasum; do
|
||||
command -v "$tool" >/dev/null 2>&1 || command -v sha256sum >/dev/null 2>&1 ||
|
||||
fail "functional leg needs $tool"
|
||||
done
|
||||
|
||||
WORK=$(mktemp -d "${TMPDIR:-/tmp}/cbm-setup-contract-XXXXXX")
|
||||
chmod 700 "$WORK"
|
||||
SERVER_PID=""
|
||||
cleanup() {
|
||||
if [ -n "$SERVER_PID" ]; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
FIXTURE="$WORK/fixture"
|
||||
STAGE="$WORK/stage"
|
||||
mkdir -p "$FIXTURE/good" "$FIXTURE/bad" "$FIXTURE/none" "$STAGE" "$WORK/tmp" \
|
||||
"$WORK/cache" "$WORK/runtime" "$WORK/trace" "$WORK/cwd"
|
||||
|
||||
# The fixture "binary": a stub that answers --version and performs the minimal
|
||||
# `install` the installer asks of the product (copy itself into --dir). It
|
||||
# records every invocation so the failure cases can prove it never ran.
|
||||
cat > "$STAGE/codebase-memory-mcp" <<'STUB'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ -n "${CBM_SETUP_STUB_TRACE:-}" ]; then
|
||||
printf '%s\n' "$*" >> "$CBM_SETUP_STUB_TRACE"
|
||||
fi
|
||||
case "${1:-}" in
|
||||
--version)
|
||||
echo "codebase-memory-mcp setup-contract-stub 0.0.0"
|
||||
;;
|
||||
install)
|
||||
dir=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in --dir=*) dir="${arg#--dir=}" ;; esac
|
||||
done
|
||||
[ -n "$dir" ] || { echo "stub: install needs --dir=" >&2; exit 2; }
|
||||
mkdir -p "$dir"
|
||||
cp "$0" "$dir/codebase-memory-mcp"
|
||||
chmod 755 "$dir/codebase-memory-mcp"
|
||||
;;
|
||||
*)
|
||||
echo "stub: unexpected invocation: $*" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
STUB
|
||||
chmod 755 "$STAGE/codebase-memory-mcp"
|
||||
cp "$ROOT/LICENSE" "$ROOT/install.sh" "$STAGE/"
|
||||
printf 'fixture notices\n' > "$STAGE/THIRD_PARTY_NOTICES.md"
|
||||
|
||||
# Member set and order mirror scripts/package-release.sh; install.sh refuses
|
||||
# any other inventory.
|
||||
tar -czf "$STAGE/$ARCHIVE" -C "$STAGE" \
|
||||
codebase-memory-mcp LICENSE install.sh THIRD_PARTY_NOTICES.md
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
DIGEST=$(sha256sum "$STAGE/$ARCHIVE" | awk '{print $1}')
|
||||
else
|
||||
DIGEST=$(shasum -a 256 "$STAGE/$ARCHIVE" | awk '{print $1}')
|
||||
fi
|
||||
|
||||
for variant in good bad none; do
|
||||
cp "$STAGE/$ARCHIVE" "$ROOT/install.sh" "$FIXTURE/$variant/"
|
||||
done
|
||||
printf '%s %s\n' "$DIGEST" "$ARCHIVE" > "$FIXTURE/good/checksums.txt"
|
||||
WRONG_DIGEST=$(printf '%064d' 0)
|
||||
printf '%s %s\n' "$WRONG_DIGEST" "$ARCHIVE" > "$FIXTURE/bad/checksums.txt"
|
||||
# "none" ships no checksums.txt at all.
|
||||
|
||||
# The server owns the ephemeral bind and prints its banner only after the
|
||||
# port is published; reading that banner from a FIFO is the readiness signal
|
||||
# (no polling, no timer). Request logs go to a file so nothing is written to
|
||||
# the FIFO after the banner.
|
||||
mkfifo "$WORK/server.fifo"
|
||||
python3 "$ROOT/scripts/smoke-fixture-server.py" \
|
||||
--directory "$FIXTURE" --port-file "$WORK/port" \
|
||||
> "$WORK/server.fifo" 2> "$WORK/server.log" &
|
||||
SERVER_PID=$!
|
||||
IFS= read -r SERVER_BANNER < "$WORK/server.fifo" ||
|
||||
fail "fixture server exited before publishing its port"
|
||||
PORT=$(tr -d '[:space:]' < "$WORK/port")
|
||||
case "$PORT" in
|
||||
'' | *[!0-9]*) fail "fixture server published no usable port ($SERVER_BANNER)" ;;
|
||||
esac
|
||||
|
||||
# Every tool setup.sh, install.sh and the stub may need, EXCEPT a hash tool.
|
||||
# The no-hash-tool variant runs with PATH restricted to this directory.
|
||||
mkdir -p "$WORK/tools"
|
||||
for tool in bash sh env curl wget mktemp uname sysctl grep wc tr awk tar \
|
||||
xattr codesign chmod cp mv rm mkdir cat sed head dirname tput; do
|
||||
if resolved=$(command -v "$tool" 2>/dev/null) && [ -n "$resolved" ]; then
|
||||
ln -s "$resolved" "$WORK/tools/$tool"
|
||||
fi
|
||||
done
|
||||
|
||||
# run_setup <label> <variant> <mode: file|pipe> <path> -> exit status in
|
||||
# RUN_RC, output in $WORK/<label>.log, scratch HOME in $WORK/home-<label>.
|
||||
run_setup() {
|
||||
local label="$1" variant="$2" mode="$3" path="$4"
|
||||
local home="$WORK/home-$label" log="$WORK/$label.log"
|
||||
local trace="$WORK/trace/$label"
|
||||
mkdir -p "$home"
|
||||
RUN_HOME="$home"
|
||||
RUN_LOG="$log"
|
||||
RUN_TRACE="$trace"
|
||||
RUN_RC=0
|
||||
# Run from a directory that is not a checkout: setup.sh activates git hooks
|
||||
# when it finds scripts/hooks in the current directory.
|
||||
if [ "$mode" = "pipe" ]; then
|
||||
cat "$SETUP_SH" | (cd "$WORK/cwd" && env \
|
||||
HOME="$home" USERPROFILE="$home" \
|
||||
XDG_CONFIG_HOME="$home/.config" CLAUDE_CONFIG_DIR="$home/.claude" \
|
||||
TMPDIR="$WORK/tmp" TEMP="$WORK/tmp" TMP="$WORK/tmp" \
|
||||
CBM_CACHE_DIR="$WORK/cache" CBM_RUNTIME_DIR="$WORK/runtime" \
|
||||
CBM_DOWNLOAD_URL="http://127.0.0.1:$PORT/$variant" \
|
||||
CBM_SETUP_STUB_TRACE="$trace" \
|
||||
http_proxy=http://127.0.0.1:1 https_proxy=http://127.0.0.1:1 \
|
||||
HTTP_PROXY=http://127.0.0.1:1 HTTPS_PROXY=http://127.0.0.1:1 \
|
||||
no_proxy= NO_PROXY= \
|
||||
PATH="$path" "$BASH") > "$log" 2>&1 || RUN_RC=$?
|
||||
else
|
||||
printf 'n\n' | (cd "$WORK/cwd" && env \
|
||||
HOME="$home" USERPROFILE="$home" \
|
||||
XDG_CONFIG_HOME="$home/.config" CLAUDE_CONFIG_DIR="$home/.claude" \
|
||||
TMPDIR="$WORK/tmp" TEMP="$WORK/tmp" TMP="$WORK/tmp" \
|
||||
CBM_CACHE_DIR="$WORK/cache" CBM_RUNTIME_DIR="$WORK/runtime" \
|
||||
CBM_DOWNLOAD_URL="http://127.0.0.1:$PORT/$variant" \
|
||||
CBM_SETUP_STUB_TRACE="$trace" \
|
||||
http_proxy=http://127.0.0.1:1 https_proxy=http://127.0.0.1:1 \
|
||||
HTTP_PROXY=http://127.0.0.1:1 HTTPS_PROXY=http://127.0.0.1:1 \
|
||||
no_proxy= NO_PROXY= \
|
||||
PATH="$path" "$BASH" "$SETUP_SH") > "$log" 2>&1 || RUN_RC=$?
|
||||
fi
|
||||
}
|
||||
|
||||
show_log() {
|
||||
echo "--- $RUN_LOG ---" >&2
|
||||
cat "$RUN_LOG" >&2
|
||||
echo "--- server log ---" >&2
|
||||
cat "$WORK/server.log" >&2
|
||||
}
|
||||
|
||||
assert_installed() {
|
||||
local label="$1" installed="$RUN_HOME/.local/bin/codebase-memory-mcp"
|
||||
if [ "$RUN_RC" -ne 0 ]; then
|
||||
show_log
|
||||
fail "$label: setup.sh exited $RUN_RC with a correct checksums.txt"
|
||||
fi
|
||||
[ -x "$installed" ] || { show_log; fail "$label: nothing installed at $installed"; }
|
||||
cmp -s "$STAGE/codebase-memory-mcp" "$installed" ||
|
||||
{ show_log; fail "$label: installed file is not the fixture stub"; }
|
||||
grep -q 'Checksum verified\.' "$RUN_LOG" ||
|
||||
{ show_log; fail "$label: the installer's checksum verification did not run"; }
|
||||
[ -s "$RUN_TRACE" ] || { show_log; fail "$label: the installed stub never ran"; }
|
||||
}
|
||||
|
||||
assert_refused() {
|
||||
local label="$1" message="$2"
|
||||
if [ "$RUN_RC" -eq 0 ]; then
|
||||
show_log
|
||||
fail "$label: setup.sh exited 0; it must refuse to install"
|
||||
fi
|
||||
grep -q "$message" "$RUN_LOG" ||
|
||||
{ show_log; fail "$label: expected the message '$message'"; }
|
||||
if [ -n "$(find "$RUN_HOME" -type f 2>/dev/null)" ]; then
|
||||
show_log
|
||||
fail "$label: setup.sh wrote into HOME although verification failed"
|
||||
fi
|
||||
[ ! -e "$RUN_TRACE" ] ||
|
||||
{ show_log; fail "$label: the fixture binary ran although verification failed"; }
|
||||
}
|
||||
|
||||
run_setup good-file good file "$PATH"
|
||||
assert_installed "good/file"
|
||||
grep -q '"GET /good/install.sh HTTP/1.1" 200' "$WORK/server.log" ||
|
||||
{ show_log; fail "good/file: setup.sh did not fetch install.sh from the download base"; }
|
||||
echo "OK: correct checksums.txt -> installed through install.sh (file mode)"
|
||||
|
||||
run_setup good-pipe good pipe "$PATH"
|
||||
assert_installed "good/pipe"
|
||||
echo "OK: correct checksums.txt -> installed through install.sh (curl | bash mode)"
|
||||
|
||||
run_setup bad bad pipe "$PATH"
|
||||
assert_refused "bad" "CHECKSUM MISMATCH"
|
||||
echo "OK: wrong digest -> refused, nothing installed, stub never ran"
|
||||
|
||||
run_setup none none pipe "$PATH"
|
||||
assert_refused "none" "could not download checksums.txt"
|
||||
echo "OK: missing checksums.txt -> refused, nothing installed, stub never ran"
|
||||
|
||||
run_setup no-hash-tool good pipe "$WORK/tools"
|
||||
assert_refused "no-hash-tool" "sha256sum or shasum is required"
|
||||
echo "OK: no hash tool on PATH -> refused, nothing installed, stub never ran"
|
||||
|
||||
echo "Setup-script contract passed"
|
||||
Reference in new issue
Block a user