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:
Martin Vogel committed 2026-10-02 06:50:04 +02:00
1 parent 96c3f41cf3
commit 6c00f96311
4 files changed
+483 -91

No files matched your search

+90 -26
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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"
+329
View File
@@ -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"