Files
deusdata--codebase-memory-mcp/install.sh
Martin Vogel afd67c51b0 refactor(cli): install scripts own updating; the binary carries no URL
Completes the move started on Windows. `update` prints the install script's
command and exits 0 on every platform; the download/extract/chmod/exec sequence
is compiled out of release builds entirely, and the MCP background thread that
ran cbm_popen("curl ... api.github.com ...") on first tool call is gone.

The installers now place themselves beside the binary, which closes the last
gap: `update` referenced a raw.githubusercontent URL purely because an
install.sh-installed user had no local copy to point at. Two details make that
safe.

The source is the install script from the archive we JUST checksum-verified,
never "$0" -- which does not exist under `curl | bash`, and would pin the user
to the OLD installer forever. Since the script ships inside the verified
archive, it inherits that verification instead of needing its own.

And it is published by atomic rename, never written over the live path. Bash
reads a script incrementally by byte offset, so overwriting the file it is
executing continues reading NEW bytes at the OLD offset: silent, bizarre
corruption. cp to a temp name then mv -f swaps the directory entry while the
running shell keeps its original inode. Windows follows the same rule via
Copy-Item + Move-Item even though PowerShell parses up front.

Deliberately NOT done: spawning the new installer to delete and replace its
predecessor. Fetch remote code -> drop to disk -> spawn -> self-delete is a
textbook stager, and self-deletion is among the most heavily weighted
heuristics there is. It also gains nothing -- `"$DLBIN" install` already runs
the NEW release's install logic, because the binary owns the install.

Net: zero install or download URLs remain in the shipped binary, and the
allow-list entry is retired with it. Both scripts scan CLEAN (0/61) with the
self-placement code in them.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-29 17:04:50 +02:00

328 lines
11 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 -- --ui # Install the UI variant
# 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"
VARIANT="standard"
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
--ui) VARIANT="ui" ;;
--standard) VARIANT="standard" ;;
--dir=*) INSTALL_DIR="${arg#--dir=}" ;;
--skip-config) SKIP_CONFIG=true ;;
--help|-h)
echo "Usage: install.sh [--ui] [--dir=<path>] [--skip-config]"
echo " --ui Install the UI variant (with graph visualization)"
echo " --standard Install the standard variant (default)"
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 " variant: $VARIANT"
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"
if [ "$VARIANT" = "ui" ]; then
ARCHIVE="codebase-memory-mcp-ui-${OS}-${ARCH}${PORTABLE}.${EXT}"
else
ARCHIVE="codebase-memory-mcp-${OS}-${ARCH}${PORTABLE}.${EXT}"
fi
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."
# Extract
echo "Extracting..."
if [ "$EXT" = "zip" ]; then
unzip -q "$DLDIR/$ARCHIVE" -d "$DLDIR"
else
tar -xzf "$DLDIR/$ARCHIVE" -C "$DLDIR"
fi
DLBIN="$DLDIR/codebase-memory-mcp"
if [ ! -f "$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..."
xattr -d com.apple.quarantine "$DLBIN" 2>/dev/null || true
codesign --sign - --force "$DLBIN" 2>/dev/null || 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 "$@"