diff --git a/Formula/codetect.rb b/Formula/codetect.rb new file mode 100644 index 0000000..3bb02ef --- /dev/null +++ b/Formula/codetect.rb @@ -0,0 +1,53 @@ +# Homebrew formula for codetect +# +# This file is the reference formula. The canonical, published version lives in +# the homebrew tap repo: https://github.com/brian-lai/homebrew-tap +# +# To install: +# brew tap brian-lai/tap +# brew install codetect +# +# To update this formula after a new release, update `url`, `sha256`, and `version`. +# The release workflow (`.github/workflows/release.yml`) should automate this. + +class Codetect < Formula + desc "Fast, token-efficient codebase search MCP server for Claude Code and any LLM" + homepage "https://github.com/brian-lai/codetect" + url "https://github.com/brian-lai/codetect/archive/refs/tags/v3.7.5.tar.gz" + sha256 "PLACEHOLDER_SHA256_UPDATE_ON_RELEASE" # TODO: update url, sha256, and version on each release + license "MIT" + version "3.7.5" + + depends_on "go" => :build + depends_on "ripgrep" + + def install + system "make", "build" + bin.install "dist/codetect" => "codetect-mcp" + bin.install "dist/codetect-index" + bin.install "dist/codetect-daemon" + bin.install "dist/codetect-eval" + bin.install "scripts/codetect-wrapper.sh" => "codetect" + (share/"codetect/templates").install Dir["templates/*"] + # Write VERSION file for non-git version reporting + (share/"codetect/VERSION").write(version.to_s + "\n") + # Copy the binary installer for `codetect update` + (share/"codetect").install "scripts/install-binary.sh" + end + + def post_install + # ENV["HOME"] may not be the installing user's home in all sandbox contexts, + # but writing the marker is best-effort — never fail the install over it. + begin + config_dir = Pathname.new(ENV.fetch("HOME", Dir.home)) / ".config/codetect" + config_dir.mkpath + (config_dir / "install_method").write("brew\n") + rescue StandardError + # Non-fatal: codetect update will still work via brew upgrade + end + end + + test do + assert_match version.to_s, shell_output("#{bin}/codetect version") + end +end diff --git a/Makefile b/Makefile index 920067a..65357a8 100644 --- a/Makefile +++ b/Makefile @@ -164,6 +164,11 @@ install: build @chmod +x $(BIN_DIR)/codetect $(BIN_DIR)/codetect-mcp $(BIN_DIR)/codetect-index $(BIN_DIR)/codetect-daemon $(BIN_DIR)/codetect-eval $(BIN_DIR)/migrate-to-postgres @codesign --sign - --force $(BIN_DIR)/codetect-mcp $(BIN_DIR)/codetect-index $(BIN_DIR)/codetect-daemon $(BIN_DIR)/codetect-eval $(BIN_DIR)/migrate-to-postgres 2>/dev/null || true @cp templates/mcp.json $(SHARE_DIR)/templates/ + @# Write VERSION file so non-git installs can report their version. + @# Use --abbrev=0 to get the nearest tag without the dirty commit suffix. + @git describe --tags --exact-match 2>/dev/null > $(SHARE_DIR)/VERSION || \ + git describe --tags --abbrev=0 2>/dev/null > $(SHARE_DIR)/VERSION || \ + echo "dev" > $(SHARE_DIR)/VERSION @echo "" @echo "✓ Installed to $(PREFIX)" @echo "" diff --git a/README.md b/README.md index c4056e5..970d207 100644 --- a/README.md +++ b/README.md @@ -29,18 +29,27 @@ See [CHANGELOG.md](CHANGELOG.md) for version history and [Migration Guide](docs/ ## Quick Start +**macOS (Homebrew):** ```bash -# Clone and run interactive installer -git clone https://github.com/brian-lai/codetect.git -cd codetect -./install.sh +brew tap brian-lai/tap +brew install codetect ``` -The installer will: -- ✓ Check for required dependencies (Go, ripgrep) -- ✓ Guide you through Ollama setup for semantic search (with prominent warnings if missing) -- ✓ Build and install globally to `~/.local/bin` -- ✓ Configure your shell PATH automatically +**Linux / macOS (curl):** +```bash +curl -fsSL https://raw.githubusercontent.com/brian-lai/codetect/main/scripts/install-binary.sh | bash +``` + +**Debian / Ubuntu (.deb):** +```bash +curl -LO https://github.com/brian-lai/codetect/releases/latest/download/codetect_amd64.deb +sudo dpkg -i codetect_amd64.deb +``` + +**From source:** +```bash +git clone https://github.com/brian-lai/codetect.git && cd codetect && ./install.sh +``` Then in any project: @@ -59,9 +68,9 @@ See [Installation Guide](docs/installation.md) for detailed setup instructions. | Dependency | Required | Purpose | |------------|----------|---------| -| Go 1.21+ | Yes | Building from source | -| [ripgrep](https://github.com/BurntSushi/ripgrep) | Yes | Keyword search | +| [ripgrep](https://github.com/BurntSushi/ripgrep) | Yes | Keyword search (auto-installed by Homebrew) | | [Ollama](https://ollama.ai) | No | Semantic search (local embeddings) | +| Go 1.21+ | Build from source only | Compiling binaries | **Note:** v3 uses ast-grep for symbol extraction. No external ctags dependency required. diff --git a/install.sh b/install.sh index 30c3498..764703f 100755 --- a/install.sh +++ b/install.sh @@ -1199,6 +1199,8 @@ if [[ $INSTALL_GLOBAL =~ ^[Yy] ]]; then CONFIG_DIR="$HOME/.config/codetect" mkdir -p "$CONFIG_DIR" CONFIG_FILE="$CONFIG_DIR/config.env" + # Record install method for codetect update + echo "git" > "$CONFIG_DIR/install_method" INSTALLED_GLOBALLY=true else warn "Skipping global installation" diff --git a/packaging/deb/DEBIAN/control b/packaging/deb/DEBIAN/control new file mode 100644 index 0000000..1fe985b --- /dev/null +++ b/packaging/deb/DEBIAN/control @@ -0,0 +1,14 @@ +Package: codetect +Version: VERSION_PLACEHOLDER +Architecture: ARCH_PLACEHOLDER +Maintainer: Brian Lai +Depends: ripgrep +Recommends: ollama +Section: devel +Priority: optional +Homepage: https://github.com/brian-lai/codetect +Description: Fast, token-efficient codebase search MCP server + codetect is a local MCP server that brings Cursor-like codebase intelligence + to Claude Code and any LLM tool. It provides fast keyword search (ripgrep), + symbol indexing (AST-based), and semantic search (Ollama embeddings) through + four focused MCP tools. diff --git a/packaging/deb/DEBIAN/postinst b/packaging/deb/DEBIAN/postinst new file mode 100755 index 0000000..2032122 --- /dev/null +++ b/packaging/deb/DEBIAN/postinst @@ -0,0 +1,19 @@ +#!/bin/bash +# Post-installation script for codetect .deb package +set -e + +# Resolve the actual installing user's home directory. +# When run via "sudo dpkg -i", $HOME is /root but SUDO_USER is the real user. +if [[ -n "$SUDO_USER" ]]; then + USER_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6) +else + USER_HOME="$HOME" +fi + +# Record install method for codetect update. +# Do not use $XDG_CONFIG_HOME here — under sudo it belongs to root, not $SUDO_USER. +CONFIG_DIR="$USER_HOME/.config/codetect" +mkdir -p "$CONFIG_DIR" +echo "binary" > "$CONFIG_DIR/install_method" + +exit 0 diff --git a/packaging/deb/build-deb.sh b/packaging/deb/build-deb.sh new file mode 100755 index 0000000..d4f85ff --- /dev/null +++ b/packaging/deb/build-deb.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# +# Build a .deb package for codetect. +# +# Usage (from repo root): +# bash packaging/deb/build-deb.sh [version] [arch] +# +# Examples: +# bash packaging/deb/build-deb.sh v3.7.5 amd64 +# bash packaging/deb/build-deb.sh v3.7.5 arm64 +# +# Outputs: dist/codetect__.deb +# +# Prerequisites: dpkg-deb, built binaries in dist/ + +set -e + +VERSION="${1:-$(git describe --tags --exact-match 2>/dev/null || git describe --tags --abbrev=0 2>/dev/null || echo dev)}" +VERSION_NUM="${VERSION#v}" +ARCH="${2:-amd64}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +PKG_DIR="$REPO_ROOT/dist/deb/codetect_${VERSION_NUM}_${ARCH}" +DEB_OUT="$REPO_ROOT/dist/codetect_${VERSION_NUM}_${ARCH}.deb" + +echo "Building codetect $VERSION_NUM ($ARCH) .deb..." + +# Clean and create package tree +rm -rf "$PKG_DIR" +mkdir -p \ + "$PKG_DIR/DEBIAN" \ + "$PKG_DIR/usr/local/bin" \ + "$PKG_DIR/usr/local/share/codetect/templates" + +# Fill DEBIAN control files +sed -e "s/VERSION_PLACEHOLDER/$VERSION_NUM/" \ + -e "s/ARCH_PLACEHOLDER/$ARCH/" \ + "$SCRIPT_DIR/DEBIAN/control" > "$PKG_DIR/DEBIAN/control" + +install -m 755 "$SCRIPT_DIR/DEBIAN/postinst" "$PKG_DIR/DEBIAN/postinst" + +# Install binaries (must be pre-built) +for bin in codetect-mcp codetect-index codetect-daemon codetect-eval migrate-to-postgres; do + src="$REPO_ROOT/dist/$bin" + if [[ ! -f "$src" ]]; then + echo "Error: $src not found. Run 'make build' first." >&2 + exit 1 + fi + install -m 755 "$src" "$PKG_DIR/usr/local/bin/$bin" +done + +# Wrapper script (main user-facing entry point) +install -m 755 "$REPO_ROOT/scripts/codetect-wrapper.sh" "$PKG_DIR/usr/local/bin/codetect" + +# Templates +cp -r "$REPO_ROOT/templates/." "$PKG_DIR/usr/local/share/codetect/templates/" + +# VERSION file +echo "$VERSION_NUM" > "$PKG_DIR/usr/local/share/codetect/VERSION" + +# Copy binary installer for `codetect update` +install -m 755 "$REPO_ROOT/scripts/install-binary.sh" \ + "$PKG_DIR/usr/local/share/codetect/install-binary.sh" + +# Build the .deb +dpkg-deb --build "$PKG_DIR" "$DEB_OUT" + +echo "" +echo "Built: $DEB_OUT" +echo "" +echo "Install with:" +echo " sudo dpkg -i $DEB_OUT" diff --git a/scripts/codetect-wrapper.sh b/scripts/codetect-wrapper.sh index f64cb2b..5962abe 100755 --- a/scripts/codetect-wrapper.sh +++ b/scripts/codetect-wrapper.sh @@ -1244,6 +1244,12 @@ cmd_version() { fi fi + # For brew/binary installs: read VERSION file written at install time + if [[ -f "$SHARE_DIR/VERSION" ]]; then + echo "codetect $(cat "$SHARE_DIR/VERSION")" + return 0 + fi + # Fallback: ask codetect-index for its version if [[ -x "$BIN_DIR/codetect-index" ]]; then "$BIN_DIR/codetect-index" version @@ -1254,16 +1260,56 @@ cmd_version() { } cmd_update() { - local source_dir="${CODETECT_SOURCE:-$HOME/dev/codetect}" - - if [[ ! -f "$source_dir/scripts/update.sh" ]]; then - error "Update script not found" - info "Set CODETECT_SOURCE to the location of your codetect clone" - info "Default: $source_dir" - return 1 + # Detect how codetect was installed + local method + method=$(cat "$CONFIG_DIR/install_method" 2>/dev/null) || method="" + + # Detect go install if marker absent: binary lives under GOPATH/bin + if [[ -z "$method" ]]; then + local codetect_bin + codetect_bin=$(which codetect 2>/dev/null) || codetect_bin="" + local gopath + gopath=$(go env GOPATH 2>/dev/null) || gopath="" + if [[ -n "$gopath" && "$codetect_bin" == "$gopath/bin/"* ]]; then + method="go" + fi fi - exec "$source_dir/scripts/update.sh" "$@" + case "$method" in + brew) + info "Installed via Homebrew. Running: brew upgrade codetect" + exec brew upgrade codetect + ;; + go) + info "Installed via go install (updates codetect-mcp only)." + info "For a full update including the wrapper and indexer, use brew or the curl installer." + info "Running: go install github.com/brian-lai/codetect/cmd/codetect@latest" + exec go install github.com/brian-lai/codetect/cmd/codetect@latest + ;; + binary) + local installer="$SHARE_DIR/install-binary.sh" + if [[ -x "$installer" ]]; then + exec "$installer" "$@" + else + info "Re-run the installer to update:" + info " curl -fsSL https://raw.githubusercontent.com/brian-lai/codetect/main/scripts/install-binary.sh | bash" + return 0 + fi + ;; + git|*) + # Legacy / git-clone path — delegate to scripts/update.sh + local source_dir="${CODETECT_SOURCE:-$HOME/dev/codetect}" + if [[ ! -f "$source_dir/scripts/update.sh" ]]; then + error "Update script not found and install method unknown." + info "Please reinstall codetect using one of:" + info " brew install brian-lai/tap/codetect" + info " curl -fsSL https://raw.githubusercontent.com/brian-lai/codetect/main/scripts/install-binary.sh | bash" + info " https://github.com/brian-lai/codetect#installation" + return 1 + fi + exec "$source_dir/scripts/update.sh" "$@" + ;; + esac } cmd_help() { @@ -1321,6 +1367,35 @@ cmd_help() { echo " claude # Start Claude Code" } +# +# Version staleness check (throttled to once per 24 hours) +# +check_for_updates() { + local stamp_file="$CONFIG_DIR/last_update_check" + local now + now=$(date +%s 2>/dev/null) || return 0 + local last + last=$(cat "$stamp_file" 2>/dev/null) || last=0 + # Use >= to avoid (( expr == 0 )) returning exit code 1 under set -e + [[ $(( now - last )) -ge 86400 ]] || return 0 + # Only write timestamp after a successful API response so a network failure + # doesn't silence the check for 24 hours + local latest + latest=$(curl -sf --max-time 3 \ + "https://api.github.com/repos/brian-lai/codetect/releases/latest" \ + 2>/dev/null | grep '"tag_name"' | cut -d'"' -f4) || return 0 + [[ -z "$latest" ]] && return 0 + echo "$now" > "$stamp_file" 2>/dev/null || true + # Get current version tag (last word of cmd_version output, e.g. "v3.7.5") + local current + current=$(cmd_version 2>/dev/null | awk '{print $NF}') || return 0 + # Skip nag for dev builds: their version looks like "(abc1234)" not "vX.Y.Z" + [[ "$current" == "("* ]] && return 0 + if [[ -n "$current" && "$latest" != "$current" ]]; then + echo -e " ${CYAN}ℹ${NC} codetect $latest is available. Run: codetect update" >&2 + fi +} + # # Main # @@ -1328,6 +1403,11 @@ main() { local cmd="${1:-help}" shift || true + # Check for updates on every command except mcp (long-running server) + if [[ "$cmd" != "mcp" ]]; then + check_for_updates + fi + case "$cmd" in mcp) cmd_mcp "$@" diff --git a/scripts/install-binary.sh b/scripts/install-binary.sh new file mode 100755 index 0000000..25652df --- /dev/null +++ b/scripts/install-binary.sh @@ -0,0 +1,218 @@ +#!/bin/bash +# +# codetect binary installer +# +# Downloads the latest pre-built codetect release from GitHub and installs it +# to ~/.local/bin. No Go toolchain required. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/brian-lai/codetect/main/scripts/install-binary.sh | bash +# bash install-binary.sh [--version v3.7.5] [--prefix /usr/local] +# + +set -e + +REPO="brian-lai/codetect" +INSTALL_PREFIX="${CODETECT_PREFIX:-$HOME/.local}" +BIN_DIR="$INSTALL_PREFIX/bin" +SHARE_DIR="$INSTALL_PREFIX/share/codetect" +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/codetect" +SPECIFIC_VERSION="" + +# Parse flags +while [[ $# -gt 0 ]]; do + case "$1" in + --version|-v) + SPECIFIC_VERSION="$2" + shift 2 + ;; + --prefix) + INSTALL_PREFIX="$2" + BIN_DIR="$INSTALL_PREFIX/bin" + SHARE_DIR="$INSTALL_PREFIX/share/codetect" + shift 2 + ;; + *) + shift + ;; + esac +done + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +success() { echo -e "${GREEN}✓${NC} $1"; } +warn() { echo -e "${YELLOW}!${NC} $1"; } +error() { echo -e "${RED}✗${NC} $1"; } +info() { echo -e " $1"; } + +echo -e "${CYAN}Installing codetect...${NC}" +echo "" + +# Detect OS and architecture +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) + error "Unsupported architecture: $ARCH" + info "Please build from source: https://github.com/$REPO#installation" + exit 1 + ;; +esac + +if [[ "$OS" != "linux" && "$OS" != "darwin" ]]; then + error "Unsupported OS: $OS" + info "Please build from source: https://github.com/$REPO#installation" + exit 1 +fi + +info "Platform: $OS/$ARCH" +echo "" + +# Resolve version to install +if [[ -z "$SPECIFIC_VERSION" ]]; then + info "Fetching latest release..." + SPECIFIC_VERSION=$(curl -sf --max-time 10 \ + "https://api.github.com/repos/$REPO/releases/latest" \ + | grep '"tag_name"' | cut -d'"' -f4) + if [[ -z "$SPECIFIC_VERSION" ]]; then + error "Could not determine latest version. Check your network connection." + exit 1 + fi +fi + +# Strip leading 'v' for filename construction, keep full tag for URL +VERSION_TAG="$SPECIFIC_VERSION" +VERSION_NUM="${VERSION_TAG#v}" + +TARBALL="codetect_${OS}_${ARCH}.tar.gz" +DOWNLOAD_URL="https://github.com/$REPO/releases/download/${VERSION_TAG}/${TARBALL}" +CHECKSUMS_URL="https://github.com/$REPO/releases/download/${VERSION_TAG}/checksums.txt" + +info "Version: $VERSION_TAG" +info "Downloading: $TARBALL" +echo "" + +# Create temp directory +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT + +# Download tarball and checksums +curl -fL --max-time 120 --progress-bar -o "$TMP_DIR/$TARBALL" "$DOWNLOAD_URL" || { + error "Download failed: $DOWNLOAD_URL" + info "Make sure release $VERSION_TAG has pre-built binaries attached." + info "Check: https://github.com/$REPO/releases/$VERSION_TAG" + exit 1 +} + +# Verify checksum if available +if curl -sf --max-time 10 -o "$TMP_DIR/checksums.txt" "$CHECKSUMS_URL" 2>/dev/null; then + info "Verifying checksum..." + expected=$(grep "$TARBALL" "$TMP_DIR/checksums.txt" | awk '{print $1}') + if [[ -n "$expected" ]]; then + if command -v sha256sum &>/dev/null; then + actual=$(sha256sum "$TMP_DIR/$TARBALL" | awk '{print $1}') + elif command -v shasum &>/dev/null; then + actual=$(shasum -a 256 "$TMP_DIR/$TARBALL" | awk '{print $1}') + else + warn "sha256sum/shasum not found; skipping checksum verification" + actual="$expected" + fi + if [[ "$actual" != "$expected" ]]; then + error "Checksum mismatch!" + info "Expected: $expected" + info "Got: $actual" + exit 1 + fi + success "Checksum verified" + fi +fi + +# Extract +info "Extracting..." +tar -xzf "$TMP_DIR/$TARBALL" -C "$TMP_DIR" + +# Install +info "Installing to $BIN_DIR..." +mkdir -p "$BIN_DIR" "$SHARE_DIR/templates" "$CONFIG_DIR" + +for bin in codetect-mcp codetect-index codetect-daemon codetect-eval migrate-to-postgres; do + if [[ -f "$TMP_DIR/$bin" ]]; then + cp "$TMP_DIR/$bin" "$BIN_DIR/$bin" + chmod +x "$BIN_DIR/$bin" + fi +done + +# The wrapper script is the main user-facing entry point +if [[ -f "$TMP_DIR/codetect" ]]; then + cp "$TMP_DIR/codetect" "$BIN_DIR/codetect" + chmod +x "$BIN_DIR/codetect" +fi + +# Templates +if [[ -d "$TMP_DIR/templates" ]]; then + cp -r "$TMP_DIR/templates/." "$SHARE_DIR/templates/" +fi + +# Store VERSION and a copy of this installer for `codetect update`. +# When run via "curl | bash", $0 is /dev/stdin so we re-download instead. +echo "$VERSION_NUM" > "$SHARE_DIR/VERSION" +if [[ -f "$0" && "$0" != "/dev/stdin" && "$0" != "bash" ]]; then + cp "$0" "$SHARE_DIR/install-binary.sh" 2>/dev/null || true +fi +if [[ ! -s "$SHARE_DIR/install-binary.sh" ]]; then + curl -fsSL --max-time 10 \ + "https://raw.githubusercontent.com/$REPO/main/scripts/install-binary.sh" \ + -o "$SHARE_DIR/install-binary.sh" 2>/dev/null || true +fi +chmod +x "$SHARE_DIR/install-binary.sh" 2>/dev/null || true + +# Record install method +echo "binary" > "$CONFIG_DIR/install_method" + +success "Installed codetect $VERSION_NUM" +echo "" + +# PATH check +if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then + warn "$BIN_DIR is not in your PATH" + echo "" + info "Add this to your shell profile (~/.zshrc or ~/.bashrc):" + echo "" + echo -e " ${YELLOW}export PATH=\"$BIN_DIR:\$PATH\"${NC}" + echo "" + if [[ $SHELL == *"zsh"* ]]; then + SHELL_RC="$HOME/.zshrc" + else + SHELL_RC="$HOME/.bashrc" + fi + # Only prompt if stdin is a terminal — non-interactive (curl | bash) defaults + # to adding PATH automatically, consistent with rustup/Homebrew behavior. + if [[ -t 0 ]]; then + read -r -p " Add to $SHELL_RC now? [Y/n] " ADD_PATH + fi + ADD_PATH=${ADD_PATH:-Y} + if [[ $ADD_PATH =~ ^[Yy] ]]; then + echo "" >> "$SHELL_RC" + echo "# Added by codetect installer" >> "$SHELL_RC" + echo "export PATH=\"$BIN_DIR:\$PATH\"" >> "$SHELL_RC" + success "Added to $SHELL_RC" + info "Run: source $SHELL_RC" + fi +fi + +echo "" +echo -e "${GREEN}Done!${NC} Get started:" +echo "" +echo " cd /path/to/your/project" +echo " codetect init # Create .mcp.json" +echo " codetect index # Index symbols + embeddings" +echo " claude # Start Claude Code" +echo "" +echo "See https://github.com/$REPO for full documentation." diff --git a/scripts/test-docker-install.sh b/scripts/test-docker-install.sh new file mode 100755 index 0000000..2f5fb95 --- /dev/null +++ b/scripts/test-docker-install.sh @@ -0,0 +1,261 @@ +#!/bin/bash +# +# Integration test: verify install-binary.sh behavior in a Docker container. +# +# Strategy: We're testing installer *shell logic*, not the Go binaries. +# The "binaries" in the mock tarball are shell stubs that print version info. +# This lets us test all installer behaviors without needing a cross-compiled build. +# +# Usage (from repo root): +# bash scripts/test-docker-install.sh +# +# Requirements: Docker +# + +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPTS="$REPO_ROOT/scripts" + +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +NC='\033[0m' + +pass() { echo -e "${GREEN}✓ PASS${NC} $1"; } +fail() { echo -e "${RED}✗ FAIL${NC} $1"; FAILURES=$((FAILURES+1)); } +info() { echo -e "${CYAN}→${NC} $1"; } + +FAILURES=0 + +# ── Prerequisites ───────────────────────────────────────────────────────────── + +if ! command -v docker &>/dev/null; then + echo "Docker not found" >&2; exit 1 +fi + +# Build the test image if not already present +if ! docker image inspect codetect-test-env &>/dev/null; then + info "Building test image (Alpine + bash + curl + ripgrep)..." + TMPCTX=$(mktemp -d) + printf 'FROM alpine:3.19\nRUN apk add --no-cache bash curl ripgrep coreutils\n' > "$TMPCTX/Dockerfile" + docker build -q -t codetect-test-env "$TMPCTX" >/dev/null + rm -rf "$TMPCTX" +fi + +# ── Step 1: Build mock release tarball with stub binaries ───────────────────── +# +# Stubs are shell scripts that act as codetect binaries. +# This avoids needing to cross-compile Go for linux/arm64 or linux/amd64. + +info "Building mock release tarball (stub binaries)..." + +VERSION_TAG="v99.0.0-test" +VERSION_NUM="${VERSION_TAG#v}" + +TMP_STAGE=$(mktemp -d) +cleanup() { rm -rf "$TMP_STAGE" "${SERVE_DIR:-}" "${PATCHED:-}"; } +trap cleanup EXIT + +# codetect-mcp stub (MCP server — just needs to exist and be executable) +cat > "$TMP_STAGE/codetect-mcp" <<'EOF' +#!/bin/bash +echo "codetect-mcp stub" +EOF + +# codetect-index stub (reports version) +cat > "$TMP_STAGE/codetect-index" <<'EOF' +#!/bin/bash +if [[ "$1" == "version" ]]; then echo "codetect-index v99.0.0-test"; fi +EOF + +# Other binary stubs +for bin in codetect-daemon codetect-eval migrate-to-postgres; do + echo '#!/bin/bash' > "$TMP_STAGE/$bin" +done + +chmod +x "$TMP_STAGE"/* + +# The wrapper script is the real one from the branch +cp "$SCRIPTS/codetect-wrapper.sh" "$TMP_STAGE/codetect" +chmod +x "$TMP_STAGE/codetect" + +# Templates dir (empty is fine) +mkdir -p "$TMP_STAGE/templates" + +# Detect host arch to match Docker's default platform (Docker uses host arch on Apple Silicon) +case "$(uname -m)" in + arm64|aarch64) TARBALL_ARCH="arm64" ;; + *) TARBALL_ARCH="amd64" ;; +esac +TARBALL_NAME="codetect_linux_${TARBALL_ARCH}.tar.gz" + +SERVE_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_STAGE" "$SERVE_DIR" "${PATCHED:-}"' EXIT + +tar -czf "$SERVE_DIR/$TARBALL_NAME" -C "$TMP_STAGE" . +(cd "$SERVE_DIR" && sha256sum "$TARBALL_NAME" > checksums.txt) + +# Fake GitHub API latest-release endpoint +mkdir -p "$SERVE_DIR/repos/brian-lai/codetect/releases" +printf '{"tag_name":"%s"}\n' "$VERSION_TAG" \ + > "$SERVE_DIR/repos/brian-lai/codetect/releases/latest" + +# Serve tarball + checksums at the expected releases/download path +DOWNLOAD_DIR="$SERVE_DIR/brian-lai/codetect/releases/download/$VERSION_TAG" +mkdir -p "$DOWNLOAD_DIR" +cp "$SERVE_DIR/$TARBALL_NAME" "$DOWNLOAD_DIR/" +cp "$SERVE_DIR/checksums.txt" "$DOWNLOAD_DIR/" + +# Also place under both arch names so the installer finds it regardless +cp "$DOWNLOAD_DIR/$TARBALL_NAME" "$DOWNLOAD_DIR/codetect_linux_amd64.tar.gz" 2>/dev/null || true +cp "$DOWNLOAD_DIR/$TARBALL_NAME" "$DOWNLOAD_DIR/codetect_linux_arm64.tar.gz" 2>/dev/null || true +(cd "$DOWNLOAD_DIR" && sha256sum codetect_linux_*.tar.gz > checksums.txt) + +cp "$SCRIPTS/install-binary.sh" "$SERVE_DIR/install-binary.sh" + +echo " Tarball arch: linux/$TARBALL_ARCH ($(du -sh "$SERVE_DIR/$TARBALL_NAME" | cut -f1))" + +# ── Step 2: Start local HTTP server ────────────────────────────────────────── + +HTTP_PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()") +info "Starting local HTTP server on port $HTTP_PORT..." + +python3 -m http.server "$HTTP_PORT" --directory "$SERVE_DIR" >/tmp/codetect-test-http.log 2>&1 & +HTTP_PID=$! +trap 'kill $HTTP_PID 2>/dev/null; rm -rf "$TMP_STAGE" "$SERVE_DIR" "${PATCHED:-}"' EXIT +sleep 1 + +curl -sf "http://localhost:$HTTP_PORT/$TARBALL_NAME" -o /dev/null \ + || { echo "HTTP server failed to start:" >&2; cat /tmp/codetect-test-http.log >&2; exit 1; } +echo " Serving on http://localhost:$HTTP_PORT" + +# ── Step 3: Patch installer URLs to use local server ───────────────────────── + +HOST="host.docker.internal" +PATCHED=$(mktemp /tmp/install-binary-test-XXXX.sh) +trap 'kill $HTTP_PID 2>/dev/null; rm -rf "$TMP_STAGE" "$SERVE_DIR" "$PATCHED"' EXIT + +sed \ + -e "s|https://api.github.com/repos/\$REPO/releases/latest|http://$HOST:$HTTP_PORT/repos/brian-lai/codetect/releases/latest|g" \ + -e "s|https://github.com/\$REPO/releases/download/\${VERSION_TAG}/\${TARBALL}|http://$HOST:$HTTP_PORT/brian-lai/codetect/releases/download/\${VERSION_TAG}/\${TARBALL}|g" \ + -e "s|https://github.com/\$REPO/releases/download/\${VERSION_TAG}/checksums.txt|http://$HOST:$HTTP_PORT/brian-lai/codetect/releases/download/\${VERSION_TAG}/checksums.txt|g" \ + -e "s|https://raw.githubusercontent.com/\$REPO/main/scripts/install-binary.sh|http://$HOST:$HTTP_PORT/install-binary.sh|g" \ + "$SCRIPTS/install-binary.sh" > "$PATCHED" +chmod +x "$PATCHED" + +# ── Step 4: Run tests ───────────────────────────────────────────────────────── + +echo "" +info "Running tests in Ubuntu 22.04 container..." +echo "" + +# Helper: run a command in a fresh container (codetect-test-env = Alpine + bash + curl + ripgrep) +docker_run() { + docker run --rm \ + --add-host=host.docker.internal:host-gateway \ + -v "$PATCHED:/install-binary.sh:ro" \ + -e HOME=/root \ + codetect-test-env \ + bash -c "$1" 2>&1 +} + +# Test 1: Install completes +info "Test 1: install completes without error" +OUT=$(docker_run "bash /install-binary.sh") +if echo "$OUT" | grep -q "Installed codetect"; then + pass "install completes, success message present" +else + fail "install did not complete cleanly" + echo "$OUT" | tail -15 +fi + +# Test 2: install_method marker = "binary" +info "Test 2: install_method marker = 'binary'" +OUT=$(docker_run "bash /install-binary.sh 2>/dev/null; cat \$HOME/.config/codetect/install_method") +if echo "$OUT" | grep -q "^binary$"; then + pass "install_method = 'binary'" +else + fail "install_method wrong (got: $(echo "$OUT" | tail -1))" +fi + +# Test 3: VERSION file written correctly +info "Test 3: VERSION file written with correct version" +OUT=$(docker_run "bash /install-binary.sh 2>/dev/null; cat \$HOME/.local/share/codetect/VERSION") +if echo "$OUT" | grep -q "^${VERSION_NUM}$"; then + pass "VERSION = $VERSION_NUM" +else + fail "VERSION file wrong (got: $(echo "$OUT" | tail -1))" +fi + +# Test 4: codetect binary is in place and executable +info "Test 4: codetect wrapper is installed and executable" +OUT=$(docker_run "bash /install-binary.sh 2>/dev/null; test -x \$HOME/.local/bin/codetect && echo EXECUTABLE || echo NOT_FOUND") +if echo "$OUT" | grep -q "^EXECUTABLE$"; then + pass "codetect wrapper is executable at ~/.local/bin/codetect" +else + fail "codetect wrapper not found or not executable" +fi + +# Test 5: Checksum verification ran +info "Test 5: checksum verification runs" +OUT=$(docker_run "bash /install-binary.sh 2>&1") +if echo "$OUT" | grep -qi "checksum verified\|verifying checksum"; then + pass "checksum verification ran" +else + fail "no evidence of checksum verification" + echo "$OUT" | grep -i check || true +fi + +# Test 6: Update check suppressed by fresh stamp file +info "Test 6: update nag suppressed when stamp file is fresh" +OUT=$(docker_run " + bash /install-binary.sh 2>/dev/null + export PATH=\$HOME/.local/bin:\$PATH + date +%s > \$HOME/.config/codetect/last_update_check + codetect version 2>&1 +") +if echo "$OUT" | grep -q "is available"; then + fail "update nag shown despite fresh stamp" +else + pass "update nag suppressed by fresh stamp file" +fi + +# Test 7: codetect update delegates to install-binary.sh +info "Test 7: 'codetect update' delegates to install-binary.sh" +OUT=$(docker_run " + bash /install-binary.sh 2>/dev/null + export PATH=\$HOME/.local/bin:\$PATH + printf '#!/bin/bash\necho UPDATER_CALLED\n' > \$HOME/.local/share/codetect/install-binary.sh + chmod +x \$HOME/.local/share/codetect/install-binary.sh + codetect update 2>&1 +") +if echo "$OUT" | grep -q "UPDATER_CALLED"; then + pass "codetect update delegates to install-binary.sh" +else + fail "codetect update did not call install-binary.sh" + echo "$OUT" | tail -5 +fi + +# Test 8: Non-interactive PATH written to .bashrc +info "Test 8: PATH written to .bashrc in non-interactive mode" +OUT=$(docker_run " + bash /install-binary.sh 2>/dev/null + grep -c 'local/bin' \$HOME/.bashrc 2>/dev/null || echo 0 +") +MATCH=$(echo "$OUT" | grep -E '^[0-9]+$' | tail -1) +if [[ "${MATCH:-0}" -ge 1 ]]; then + pass "PATH line written to .bashrc in non-interactive mode" +else + fail "PATH not written to .bashrc" +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── + +echo "" +if [[ $FAILURES -eq 0 ]]; then + echo -e "${GREEN}All 8 tests passed.${NC}" +else + echo -e "${RED}$FAILURES / 8 test(s) failed.${NC}" + exit 1 +fi diff --git a/scripts/update.sh b/scripts/update.sh index b62d940..941f02f 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -74,9 +74,13 @@ echo "" # Check if source directory exists if [[ ! -d "$SOURCE_DIR" ]]; then error "Source directory not found: $SOURCE_DIR" - info "Set CODETECT_SOURCE to the location of your codetect clone" - info "Or clone it:" + echo "" + info "This updater requires a git clone of codetect." + info "If you installed via Homebrew, curl, or apt, run 'codetect update' instead." + echo "" + info "To use this script, clone the repo first:" info " git clone https://github.com/brian-lai/codetect.git $SOURCE_DIR" + info "Or set CODETECT_SOURCE to your existing clone location." exit 1 fi