Files
2026-08-11 18:01:23 +03:00

379 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# install.sh — One-line installer for codebase-memory-mcp.
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash
# curl -fsSL ... | bash -s -- --dir /path # Custom install directory
#
# Environment:
# CBM_DOWNLOAD_URL Override base URL for downloads (for testing)
# Wrap in main() to prevent partial execution from piped downloads.
# If curl|bash is interrupted mid-transfer, bash would execute the partial
# script. With this wrapper, the function is defined but main() is never
# called because the final line hasn't arrived yet.
main() {
REPO="DeusData/codebase-memory-mcp"
INSTALL_DIR="$HOME/.local/bin"
SKIP_CONFIG=false
CBM_DOWNLOAD_URL="${CBM_DOWNLOAD_URL:-https://github.com/${REPO}/releases/latest/download}"
# Security: every remote hop must remain HTTPS. Plain HTTP is accepted only
# for an exact loopback authority used by local smoke tests, with redirects
# disabled so a local fixture cannot bounce the installer to the network.
is_loopback_http_url() {
[[ "$1" =~ ^http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?\#].*)?$ ]]
}
if [[ "$CBM_DOWNLOAD_URL" == https://* ]]; then
CBM_DOWNLOAD_LOOPBACK=false
elif is_loopback_http_url "$CBM_DOWNLOAD_URL"; then
CBM_DOWNLOAD_LOOPBACK=true
else
echo "error: refusing non-HTTPS download URL: $CBM_DOWNLOAD_URL" >&2
exit 1
fi
download_file() {
local url="$1"
local destination="$2"
local progress="$3"
if [ "$CBM_DOWNLOAD_LOOPBACK" = true ]; then
is_loopback_http_url "$url" || {
echo "error: loopback download escaped its authority: $url" >&2
return 1
}
if command -v curl &>/dev/null; then
local curl_args=(-fS --noproxy '*' --proto '=http')
[ "$progress" = true ] && curl_args+=(--progress-bar) || curl_args+=(-s)
curl "${curl_args[@]}" -o "$destination" "$url"
elif command -v wget &>/dev/null; then
local wget_args=(--no-proxy --max-redirect=0)
[ "$progress" = true ] && wget_args+=(--show-progress) || wget_args+=(-q)
wget "${wget_args[@]}" -O "$destination" "$url"
else
echo "error: curl or wget required" >&2
return 1
fi
return
fi
[[ "$url" == https://* ]] || {
echo "error: HTTPS download downgraded: $url" >&2
return 1
}
if command -v curl &>/dev/null; then
local curl_args=(-fSL --max-redirs 5 --proto '=https' --proto-redir '=https')
[ "$progress" = true ] && curl_args+=(--progress-bar) || curl_args+=(-sS)
curl "${curl_args[@]}" -o "$destination" "$url"
elif command -v wget &>/dev/null; then
local wget_args=(--https-only --max-redirect=5)
[ "$progress" = true ] && wget_args+=(--show-progress) || wget_args+=(-q)
wget "${wget_args[@]}" -O "$destination" "$url"
else
echo "error: curl or wget required" >&2
return 1
fi
}
for arg in "$@"; do
case "$arg" in
--dir=*) INSTALL_DIR="${arg#--dir=}" ;;
--skip-config) SKIP_CONFIG=true ;;
--help|-h)
echo "Usage: install.sh [--dir=<path>] [--skip-config]"
echo " --dir PATH Install directory (default: ~/.local/bin)"
echo " --skip-config Skip automatic agent configuration"
exit 0
;;
esac
done
# Handle --dir <path> (space-separated)
prev=""
for arg in "$@"; do
if [ "$prev" = "--dir" ]; then
INSTALL_DIR="$arg"
fi
prev="$arg"
done
detect_os() {
case "$(uname -s)" in
Darwin) echo "darwin" ;;
Linux) echo "linux" ;;
MINGW*|MSYS*|CYGWIN*) echo "windows" ;;
*) echo "error: unsupported OS: $(uname -s)" >&2; exit 1 ;;
esac
}
detect_arch() {
local arch
arch="$(uname -m)"
case "$arch" in
arm64|aarch64) echo "arm64" ;;
x86_64|amd64)
# Rosetta detection: shell reports x86_64 but hardware is Apple Silicon
if [ "$(uname -s)" = "Darwin" ] && sysctl -n machdep.cpu.brand_string 2>/dev/null | grep -qi apple; then
echo "arm64"
else
echo "amd64"
fi
;;
*) echo "error: unsupported architecture: $arch" >&2; exit 1 ;;
esac
}
OS=$(detect_os)
ARCH=$(detect_arch)
echo "codebase-memory-mcp installer"
echo " os: $OS"
echo " arch: $ARCH"
echo " target: $INSTALL_DIR/codebase-memory-mcp"
echo ""
# Build download URL
if [ "$OS" = "windows" ]; then
EXT="zip"
else
EXT="tar.gz"
fi
# Linux ships a fully-static "-portable" build; the standard linux binary
# dynamically links glibc 2.38+ and fails on older distros (Debian 11, RHEL 8,
# Ubuntu 20.04). macOS/Windows have no such variant.
PORTABLE=""
[ "$OS" = "linux" ] && PORTABLE="-portable"
ARCHIVE="codebase-memory-mcp-${OS}-${ARCH}${PORTABLE}.${EXT}"
URL="${CBM_DOWNLOAD_URL}/${ARCHIVE}"
# Download
DLDIR=$(mktemp -d)
trap 'rm -rf "$DLDIR"' EXIT
echo "Downloading ${ARCHIVE}..."
download_file "$URL" "$DLDIR/$ARCHIVE" true
# Checksum verification is mandatory. Activation must never stop running CBM
# sessions for a candidate whose published digest was not positively verified.
CHECKSUM_URL="${CBM_DOWNLOAD_URL}/checksums.txt"
download_file "$CHECKSUM_URL" "$DLDIR/checksums.txt" false || {
echo "error: could not download checksums.txt" >&2
exit 1
}
CHECKSUM_BYTES=$(wc -c < "$DLDIR/checksums.txt" | tr -d '[:space:]')
case "$CHECKSUM_BYTES" in
''|*[!0-9]*)
echo "error: could not determine checksums.txt size" >&2
exit 1
;;
esac
if [ "$CHECKSUM_BYTES" -gt 1048576 ]; then
echo "error: checksums.txt exceeds the 1 MiB safety limit" >&2
exit 1
fi
awk -v archive="$ARCHIVE" \
'$2 == archive || $2 == "*" archive { print $1 }' \
"$DLDIR/checksums.txt" > "$DLDIR/matching-checksums.txt"
EXPECTED=""
while IFS= read -r digest; do
case "$digest" in
''|*[!0-9A-Fa-f]*)
echo "error: invalid SHA-256 digest for $ARCHIVE" >&2
exit 1
;;
esac
if [ "${#digest}" -ne 64 ]; then
echo "error: invalid SHA-256 digest length for $ARCHIVE" >&2
exit 1
fi
digest=$(printf '%s' "$digest" | tr 'A-F' 'a-f')
if [ -n "$EXPECTED" ] && [ "$EXPECTED" != "$digest" ]; then
echo "error: conflicting SHA-256 digests for $ARCHIVE" >&2
exit 1
fi
EXPECTED="$digest"
done < "$DLDIR/matching-checksums.txt"
if [ -z "$EXPECTED" ]; then
echo "error: no SHA-256 digest for $ARCHIVE in checksums.txt" >&2
exit 1
fi
if command -v sha256sum &>/dev/null; then
ACTUAL=$(sha256sum "$DLDIR/$ARCHIVE" | awk '{print $1}')
elif command -v shasum &>/dev/null; then
ACTUAL=$(shasum -a 256 "$DLDIR/$ARCHIVE" | awk '{print $1}')
else
echo "error: sha256sum or shasum is required to verify the download" >&2
exit 1
fi
ACTUAL=$(printf '%s' "$ACTUAL" | tr 'A-F' 'a-f')
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "error: CHECKSUM MISMATCH — download may be corrupted!" >&2
echo " expected: $EXPECTED" >&2
echo " actual: $ACTUAL" >&2
exit 1
fi
echo "Checksum verified."
# Validate the complete archive namespace before extraction. Current and legacy
# release assets use the same four-member root layout; anything outside that
# closed set is a release-integrity failure, not a sidecar to ignore.
if [ "$OS" = "windows" ]; then
ARCHIVE_BINARY="codebase-memory-mcp.exe"
ARCHIVE_INSTALLER="install.ps1"
else
ARCHIVE_BINARY="codebase-memory-mcp"
ARCHIVE_INSTALLER="install.sh"
fi
ARCHIVE_MEMBERS_FILE="$DLDIR/archive-members.txt"
if [ "$EXT" = "zip" ]; then
if ! unzip -Z1 "$DLDIR/$ARCHIVE" > "$ARCHIVE_MEMBERS_FILE"; then
echo "error: could not enumerate release archive" >&2
exit 1
fi
else
if ! tar -tzf "$DLDIR/$ARCHIVE" > "$ARCHIVE_MEMBERS_FILE"; then
echo "error: could not enumerate release archive" >&2
exit 1
fi
fi
BINARY_MEMBERS=0
LICENSE_MEMBERS=0
INSTALLER_MEMBERS=0
NOTICE_MEMBERS=0
ARCHIVE_MEMBER_COUNT=0
while IFS= read -r member || [ -n "$member" ]; do
ARCHIVE_MEMBER_COUNT=$((ARCHIVE_MEMBER_COUNT + 1))
case "$member" in
"$ARCHIVE_BINARY") BINARY_MEMBERS=$((BINARY_MEMBERS + 1)) ;;
LICENSE) LICENSE_MEMBERS=$((LICENSE_MEMBERS + 1)) ;;
"$ARCHIVE_INSTALLER") INSTALLER_MEMBERS=$((INSTALLER_MEMBERS + 1)) ;;
THIRD_PARTY_NOTICES.md) NOTICE_MEMBERS=$((NOTICE_MEMBERS + 1)) ;;
*)
echo "error: release archive contains unexpected member: $member" >&2
exit 1
;;
esac
done < "$ARCHIVE_MEMBERS_FILE"
if [ "$BINARY_MEMBERS" -ne 1 ] || [ "$LICENSE_MEMBERS" -ne 1 ] ||
[ "$INSTALLER_MEMBERS" -ne 1 ] || [ "$NOTICE_MEMBERS" -ne 1 ] ||
[ "$ARCHIVE_MEMBER_COUNT" -ne 4 ]; then
echo "error: release archive does not match the exact member set" >&2
exit 1
fi
# Extract
echo "Extracting..."
if [ "$EXT" = "zip" ]; then
unzip -q "$DLDIR/$ARCHIVE" -d "$DLDIR"
else
tar --no-same-owner -xzf "$DLDIR/$ARCHIVE" -C "$DLDIR"
fi
for extracted_member in "$ARCHIVE_BINARY" LICENSE "$ARCHIVE_INSTALLER" \
THIRD_PARTY_NOTICES.md; do
if [ ! -f "$DLDIR/$extracted_member" ] || [ -L "$DLDIR/$extracted_member" ]; then
echo "error: release member is not a regular file: $extracted_member" >&2
exit 1
fi
done
DLBIN="$DLDIR/$ARCHIVE_BINARY"
if [ ! -f "$DLBIN" ] || [ -L "$DLBIN" ]; then
echo "error: binary not found after extraction" >&2
exit 1
fi
# macOS: fix signing
if [ "$OS" = "darwin" ]; then
echo "Fixing macOS code signing..."
# A curl-downloaded archive usually carries no quarantine attribute at all,
# and xattr then prints "No such xattr: com.apple.quarantine" on stderr.
# That harmless line was read as the cause of an unrelated install failure
# and became a bug report's title (#1537) — silence it; nothing here is an
# error worth showing.
xattr -d com.apple.quarantine "$DLBIN" >/dev/null 2>&1 || true
codesign --sign - --force "$DLBIN" >/dev/null 2>&1 || true
fi
# Verify the candidate before it requests account-wide maintenance. The
# candidate itself owns process draining and the transactional target swap.
chmod 755 "$DLBIN"
if ! CANDIDATE_VERSION=$("$DLBIN" --version 2>&1); then
echo "error: downloaded binary failed to run" >&2
exit 1
fi
echo "Verified candidate: $CANDIDATE_VERSION"
DEST="$INSTALL_DIR/codebase-memory-mcp"
INSTALL_ARGS=(-y --force "--dir=$INSTALL_DIR")
if [ "$SKIP_CONFIG" = true ]; then
INSTALL_ARGS+=(--skip-config)
fi
"$DLBIN" install "${INSTALL_ARGS[@]}"
# Place the installer beside the binary so `update` can point at a local file
# instead of a URL, and so the next update uses THIS release's installer.
#
# Two things make this safe. The source is the copy from the archive we just
# checksum-verified -- never "$0", which does not exist under `curl | bash` and
# would pin us to the OLD installer forever. And it is published by atomic
# rename, never by writing over the live path: bash reads a script incrementally
# by byte offset, so overwriting the file it is executing continues reading the
# NEW bytes at the OLD offset. That fails silently and bizarrely, which is worse
# than failing loudly.
#
# Best effort by design: a user who cannot write to INSTALL_DIR still gets a
# working install, and `update` falls back to explaining where to find it.
DL_INSTALLER="$DLDIR/install.sh"
if [ -f "$DL_INSTALLER" ]; then
INSTALLER_TMP="$INSTALL_DIR/.install.sh.$$"
if cp "$DL_INSTALLER" "$INSTALLER_TMP" 2>/dev/null &&
chmod 755 "$INSTALLER_TMP" 2>/dev/null &&
mv -f "$INSTALLER_TMP" "$INSTALL_DIR/install.sh" 2>/dev/null; then
echo "Installed updater -> $INSTALL_DIR/install.sh"
else
rm -f "$INSTALLER_TMP" 2>/dev/null || true
echo "note: could not place install.sh in $INSTALL_DIR (update will explain where to find it)"
fi
fi
# Verify
VERSION=$("$DEST" --version 2>&1) || {
echo "error: installed binary failed to run" >&2
if [ "$OS" = "darwin" ]; then
echo " try: xattr -cr $DEST && codesign --force --sign - $DEST" >&2
fi
exit 1
}
echo "Installed: $VERSION"
# Agent configuration is part of the candidate-owned activation window.
if [ "$SKIP_CONFIG" = true ]; then
echo ""
echo "Skipping agent configuration (--skip-config)"
fi
# PATH check
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
echo ""
echo "NOTE: $INSTALL_DIR is not in your PATH."
echo "Add it to your shell config:"
echo ""
echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.zshrc"
fi
echo ""
echo "Done! Restart your coding agent to start using codebase-memory-mcp."
} # end main()
main "$@"