From 0d880ace5737e5a89b344a12dfc18a03719ad939 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Sun, 8 Mar 2026 21:57:55 -0700 Subject: [PATCH 1/9] plan: add curl|sh install script with shell-based test suite Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-08-install-script.md | 562 ++++++++++++++++++++++++ 1 file changed, 562 insertions(+) create mode 100644 docs/plans/2026-03-08-install-script.md diff --git a/docs/plans/2026-03-08-install-script.md b/docs/plans/2026-03-08-install-script.md new file mode 100644 index 0000000..f5a667e --- /dev/null +++ b/docs/plans/2026-03-08-install-script.md @@ -0,0 +1,562 @@ +# devproxy — Install Script Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. + +**Goal:** Add a `curl -fsSL ... | sh` install script that downloads pre-built devproxy binaries from GitHub Releases, plus a comprehensive shell-based test suite. + +**Architecture:** A single POSIX shell install script (`install.sh`) at the repo root that detects the user's OS/arch, constructs a GitHub Release download URL, downloads the appropriate binary, and installs it to a configurable location. A test script (`tests/test_install.sh`) exercises all code paths using uname overrides, a local HTTP mock server, and temp directories. + +**Key design decisions:** +- The install script is POSIX sh (not bash) for maximum portability across macOS and Linux. +- Binary naming convention: `devproxy-` where triples are `x86_64-apple-darwin`, `aarch64-apple-darwin`, `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`. +- `DEVPROXY_INSTALL_BASE_URL` env var overrides the default GitHub Releases base URL, enabling testing and mirror deployments. +- `DEVPROXY_INSTALL_DIR` env var overrides the default install directory (`/usr/local/bin`). +- The test suite uses Python's `http.server` as a lightweight mock HTTP server (available on all target platforms without extra dependencies). +- The install script prefers `curl` but falls back to `wget`. + +--- + +## Task 1: Create the install script + +**Files:** +- Create: `install.sh` + +**Step 1: Write the install script** + +Create `install.sh` at the repo root with the following content: + +```sh +#!/bin/sh +set -eu + +DEVPROXY_VERSION="${DEVPROXY_VERSION:-latest}" +DEVPROXY_INSTALL_DIR="${DEVPROXY_INSTALL_DIR:-/usr/local/bin}" +DEVPROXY_INSTALL_BASE_URL="${DEVPROXY_INSTALL_BASE_URL:-https://github.com/foundra-build/devproxy/releases}" + +main() { + detect_platform + construct_url + create_install_dir + download_binary + make_executable + verify_installation + echo "devproxy installed successfully to ${DEVPROXY_INSTALL_DIR}/devproxy" +} + +detect_platform() { + OS="$(uname -s)" + ARCH="$(uname -m)" + + case "$OS" in + Darwin) OS_TARGET="apple-darwin" ;; + Linux) OS_TARGET="unknown-linux-gnu" ;; + *) echo "Error: unsupported operating system: $OS" >&2; exit 1 ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH_TARGET="x86_64" ;; + aarch64|arm64) ARCH_TARGET="aarch64" ;; + *) echo "Error: unsupported architecture: $ARCH" >&2; exit 1 ;; + esac + + TARGET="${ARCH_TARGET}-${OS_TARGET}" +} + +construct_url() { + BINARY_NAME="devproxy-${TARGET}" + if [ "$DEVPROXY_VERSION" = "latest" ]; then + DOWNLOAD_URL="${DEVPROXY_INSTALL_BASE_URL}/latest/download/${BINARY_NAME}" + else + DOWNLOAD_URL="${DEVPROXY_INSTALL_BASE_URL}/download/${DEVPROXY_VERSION}/${BINARY_NAME}" + fi +} + +create_install_dir() { + if [ ! -d "$DEVPROXY_INSTALL_DIR" ]; then + mkdir -p "$DEVPROXY_INSTALL_DIR" + fi +} + +download_binary() { + TMPFILE="$(mktemp)" + trap 'rm -f "$TMPFILE"' EXIT + + if command -v curl >/dev/null 2>&1; then + HTTP_CODE=$(curl -fsSL -w '%{http_code}' -o "$TMPFILE" "$DOWNLOAD_URL" 2>/dev/null) || true + if [ ! -s "$TMPFILE" ]; then + echo "Error: failed to download devproxy from ${DOWNLOAD_URL}" >&2 + echo "HTTP status: ${HTTP_CODE:-unknown}" >&2 + exit 1 + fi + elif command -v wget >/dev/null 2>&1; then + if ! wget -q -O "$TMPFILE" "$DOWNLOAD_URL" 2>/dev/null; then + echo "Error: failed to download devproxy from ${DOWNLOAD_URL}" >&2 + exit 1 + fi + else + echo "Error: neither curl nor wget found. Please install one and try again." >&2 + exit 1 + fi + + mv "$TMPFILE" "${DEVPROXY_INSTALL_DIR}/devproxy" + trap - EXIT +} + +make_executable() { + chmod +x "${DEVPROXY_INSTALL_DIR}/devproxy" +} + +verify_installation() { + if [ ! -x "${DEVPROXY_INSTALL_DIR}/devproxy" ]; then + echo "Error: installation failed — binary not found at ${DEVPROXY_INSTALL_DIR}/devproxy" >&2 + exit 1 + fi +} + +main +``` + +**Step 2: Make it executable** + +```bash +chmod +x install.sh +``` + +**Step 3: Verify syntax** + +```bash +sh -n install.sh +``` + +Expected: No output (no syntax errors). + +**Step 4: Commit** + +```bash +git add install.sh +git commit -m "feat: add curl|sh install script for devproxy" +``` + +--- + +## Task 2: Create the test script + +**Files:** +- Create: `tests/test_install.sh` + +**Step 1: Write the test script** + +Create `tests/test_install.sh` with these test cases: + +1. **OS/arch detection** — For each of the 4 platform combos (Darwin/arm64, Darwin/x86_64, Linux/x86_64, Linux/aarch64), override `uname` with a wrapper script that returns the expected values, run the install script in a mode that exits after detection, and verify the correct target triple. + +2. **Unsupported platform error** — Override `uname` to return `FreeBSD`/`mips` and verify non-zero exit + error message on stderr. + +3. **URL construction** — For each platform, verify the constructed download URL matches the expected pattern including the base URL override. + +4. **Full install e2e** — Start a Python `http.server` serving a mock binary (a simple shell script that prints a version string), point `DEVPROXY_INSTALL_BASE_URL` at localhost, run the install script with `DEVPROXY_INSTALL_DIR` set to a temp dir, verify the binary exists, is executable, and produces expected output. Run a second time to verify idempotency. + +5. **Download failure (404)** — Point at the mock server but request a URL that doesn't exist, verify non-zero exit + error message. + +6. **Missing downloader** — Override `PATH` to exclude curl and wget, verify non-zero exit + error message about missing downloader. + +The test script structure: + +```sh +#!/bin/sh +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +INSTALL_SCRIPT="$REPO_ROOT/install.sh" + +PASS=0 +FAIL=0 +TOTAL=0 + +pass() { + PASS=$((PASS + 1)) + TOTAL=$((TOTAL + 1)) + echo " PASS: $1" +} + +fail() { + FAIL=$((FAIL + 1)) + TOTAL=$((TOTAL + 1)) + echo " FAIL: $1" + if [ -n "${2:-}" ]; then + echo " $2" + fi +} + +cleanup() { + if [ -n "${MOCK_SERVER_PID:-}" ]; then + kill "$MOCK_SERVER_PID" 2>/dev/null || true + wait "$MOCK_SERVER_PID" 2>/dev/null || true + fi + if [ -n "${TMPDIR_ROOT:-}" ]; then + rm -rf "$TMPDIR_ROOT" + fi +} +trap cleanup EXIT + +TMPDIR_ROOT="$(mktemp -d)" + +# --- Helper: create a uname wrapper that returns custom OS/ARCH --- +make_uname_wrapper() { + _os="$1" + _arch="$2" + _dir="$TMPDIR_ROOT/uname-wrapper-${_os}-${_arch}" + mkdir -p "$_dir" + cat > "$_dir/uname" < "$_harness" + cat >> "$_harness" <<'HARNESS' +detect_platform +construct_url +echo "TARGET=$TARGET" +echo "DOWNLOAD_URL=$DOWNLOAD_URL" +HARNESS + PATH="$_uname_dir:$PATH" \ + DEVPROXY_INSTALL_BASE_URL="$_base_url" \ + DEVPROXY_VERSION="$_version" \ + sh "$_harness" 2>/dev/null + rm -f "$_harness" +} + +# ============================================================ +# Test 1: OS/arch detection — all 4 platform combos +# ============================================================ +echo "=== Test 1: OS/arch detection ===" + +for combo in "Darwin:arm64:aarch64-apple-darwin" \ + "Darwin:x86_64:x86_64-apple-darwin" \ + "Linux:x86_64:x86_64-unknown-linux-gnu" \ + "Linux:aarch64:aarch64-unknown-linux-gnu"; do + os="$(echo "$combo" | cut -d: -f1)" + arch="$(echo "$combo" | cut -d: -f2)" + expected="$(echo "$combo" | cut -d: -f3)" + + wrapper_dir="$(make_uname_wrapper "$os" "$arch")" + result="$(run_detection "$wrapper_dir" | grep '^TARGET=' | cut -d= -f2)" + + if [ "$result" = "$expected" ]; then + pass "$os/$arch -> $expected" + else + fail "$os/$arch -> expected $expected, got $result" + fi +done + +# ============================================================ +# Test 2: Unsupported platform error +# ============================================================ +echo "=== Test 2: Unsupported platform error ===" + +# Unsupported OS +wrapper_dir="$(make_uname_wrapper "FreeBSD" "x86_64")" +if output="$(run_detection "$wrapper_dir" 2>&1)"; then + fail "FreeBSD should fail but exited 0" +else + if echo "$output" | grep -qi "unsupported"; then + pass "FreeBSD rejected with error message" + else + fail "FreeBSD rejected but no 'unsupported' in message" "$output" + fi +fi + +# Unsupported arch +wrapper_dir="$(make_uname_wrapper "Linux" "mips")" +if output="$(run_detection "$wrapper_dir" 2>&1)"; then + fail "mips should fail but exited 0" +else + if echo "$output" | grep -qi "unsupported"; then + pass "mips rejected with error message" + else + fail "mips rejected but no 'unsupported' in message" "$output" + fi +fi + +# ============================================================ +# Test 3: URL construction +# ============================================================ +echo "=== Test 3: URL construction ===" + +BASE="https://example.com/releases" + +# Latest version +wrapper_dir="$(make_uname_wrapper "Darwin" "arm64")" +url="$(run_detection "$wrapper_dir" "$BASE" "latest" | grep '^DOWNLOAD_URL=' | cut -d= -f2-)" +expected_url="https://example.com/releases/latest/download/devproxy-aarch64-apple-darwin" +if [ "$url" = "$expected_url" ]; then + pass "latest URL for Darwin/arm64" +else + fail "latest URL: expected $expected_url, got $url" +fi + +# Specific version +url="$(run_detection "$wrapper_dir" "$BASE" "v1.0.0" | grep '^DOWNLOAD_URL=' | cut -d= -f2-)" +expected_url="https://example.com/releases/download/v1.0.0/devproxy-aarch64-apple-darwin" +if [ "$url" = "$expected_url" ]; then + pass "versioned URL for Darwin/arm64" +else + fail "versioned URL: expected $expected_url, got $url" +fi + +# Linux x86_64 +wrapper_dir="$(make_uname_wrapper "Linux" "x86_64")" +url="$(run_detection "$wrapper_dir" "$BASE" "latest" | grep '^DOWNLOAD_URL=' | cut -d= -f2-)" +expected_url="https://example.com/releases/latest/download/devproxy-x86_64-unknown-linux-gnu" +if [ "$url" = "$expected_url" ]; then + pass "latest URL for Linux/x86_64" +else + fail "latest URL: expected $expected_url, got $url" +fi + +# ============================================================ +# Test 4: Full install e2e with mock server +# ============================================================ +echo "=== Test 4: Full install e2e ===" + +# Set up mock server directory structure +MOCK_DIR="$TMPDIR_ROOT/mock-server" +mkdir -p "$MOCK_DIR/latest/download" + +# Create a mock binary (shell script that echoes version) +MOCK_BINARY="$MOCK_DIR/latest/download/devproxy-$(uname -m | sed 's/arm64/aarch64/')-$(case "$(uname -s)" in Darwin) echo apple-darwin;; Linux) echo unknown-linux-gnu;; esac)" +cat > "$MOCK_BINARY" <<'MOCKBIN' +#!/bin/sh +echo "devproxy mock 0.0.1-test" +MOCKBIN +chmod +x "$MOCK_BINARY" + +# Start mock HTTP server +MOCK_PORT=0 +# Find a free port +MOCK_PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()") +cd "$MOCK_DIR" +python3 -m http.server "$MOCK_PORT" >/dev/null 2>&1 & +MOCK_SERVER_PID=$! +cd "$REPO_ROOT" +# Give the server a moment to start +sleep 1 + +INSTALL_DIR="$TMPDIR_ROOT/install-target" +mkdir -p "$INSTALL_DIR" + +# Run install +if DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}" \ + DEVPROXY_INSTALL_DIR="$INSTALL_DIR" \ + sh "$INSTALL_SCRIPT" >/dev/null 2>&1; then + # Check binary exists and is executable + if [ -x "$INSTALL_DIR/devproxy" ]; then + pass "binary installed and executable" + else + fail "binary not found or not executable at $INSTALL_DIR/devproxy" + fi + + # Check binary works + mock_output="$("$INSTALL_DIR/devproxy" 2>&1 || true)" + if echo "$mock_output" | grep -q "devproxy mock"; then + pass "installed binary produces expected output" + else + fail "binary output unexpected" "$mock_output" + fi + + # Idempotency: run again + if DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}" \ + DEVPROXY_INSTALL_DIR="$INSTALL_DIR" \ + sh "$INSTALL_SCRIPT" >/dev/null 2>&1; then + pass "idempotent reinstall succeeds" + else + fail "idempotent reinstall failed" + fi +else + fail "install script failed" +fi + +# ============================================================ +# Test 5: Download failure (404) +# ============================================================ +echo "=== Test 5: Download failure (404) ===" + +INSTALL_DIR_404="$TMPDIR_ROOT/install-404" +mkdir -p "$INSTALL_DIR_404" + +# Point at a path that doesn't exist on the mock server +wrapper_dir="$(make_uname_wrapper "Linux" "aarch64")" +if output="$(PATH="$wrapper_dir:$PATH" \ + DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}/nonexistent" \ + DEVPROXY_INSTALL_DIR="$INSTALL_DIR_404" \ + sh "$INSTALL_SCRIPT" 2>&1)"; then + fail "404 should cause non-zero exit" +else + if echo "$output" | grep -qi "error\|fail"; then + pass "404 produces error message" + else + fail "404 exited non-zero but no error in output" "$output" + fi +fi + +# ============================================================ +# Test 6: Missing downloader +# ============================================================ +echo "=== Test 6: Missing downloader ===" + +INSTALL_DIR_NODL="$TMPDIR_ROOT/install-nodl" +mkdir -p "$INSTALL_DIR_NODL" + +# Create a minimal PATH with only essential commands but no curl/wget +MINIMAL_BIN="$TMPDIR_ROOT/minimal-bin" +mkdir -p "$MINIMAL_BIN" +# Link only the essentials the script needs (sh, uname, mktemp, chmod, mkdir, etc.) +for cmd in sh uname mktemp chmod mkdir mv rm cat sed grep printf echo test tr cut; do + cmd_path="$(command -v "$cmd" 2>/dev/null || true)" + if [ -n "$cmd_path" ]; then + ln -sf "$cmd_path" "$MINIMAL_BIN/$cmd" 2>/dev/null || true + fi +done +# Also need [ for test +if [ -f /bin/[ ]; then + ln -sf /bin/[ "$MINIMAL_BIN/[" 2>/dev/null || true +fi +# Need env and python3 is not needed here +ln -sf "$(command -v env)" "$MINIMAL_BIN/env" 2>/dev/null || true + +if output="$(PATH="$MINIMAL_BIN" \ + DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}" \ + DEVPROXY_INSTALL_DIR="$INSTALL_DIR_NODL" \ + sh "$INSTALL_SCRIPT" 2>&1)"; then + fail "missing downloader should cause non-zero exit" +else + if echo "$output" | grep -qi "curl\|wget"; then + pass "missing downloader error mentions curl/wget" + else + fail "missing downloader exited non-zero but no curl/wget mention" "$output" + fi +fi + +# ============================================================ +# Summary +# ============================================================ +echo "" +echo "============================================================" +echo "Results: $PASS passed, $FAIL failed, $TOTAL total" +echo "============================================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +``` + +**Step 2: Make it executable** + +```bash +chmod +x tests/test_install.sh +``` + +**Step 3: Run the tests** + +```bash +sh tests/test_install.sh +``` + +Expected: All tests pass (the e2e test uses the real platform's uname for the mock binary name). + +**Step 4: Commit** + +```bash +git add tests/test_install.sh +git commit -m "test: add shell-based install script test suite" +``` + +--- + +## Task 3: Add just recipe and update README + +**Files:** +- Modify: `justfile` +- Modify: `README.md` + +**Step 1: Add `test-install` recipe to justfile** + +Add after the `e2e` recipe: + +```just +# Run install script tests +test-install: + sh tests/test_install.sh +``` + +**Step 2: Update README with install instructions** + +Add an "Install" section before "Quick Start" with: + +```markdown +## Install + +```bash +curl -fsSL https://raw.githubusercontent.com/foundra-build/devproxy/main/install.sh | sh +``` +``` + +**Step 3: Verify just recipe works** + +```bash +just test-install +``` + +Expected: All tests pass. + +**Step 4: Commit** + +```bash +git add justfile README.md +git commit -m "docs: add install command to README and test-install just recipe" +``` + +--- + +## Task 4: Final verification + +**Step 1: Run full test suite** + +```bash +just test-install +``` + +Expected: All 6 test categories pass, output shows pass/fail summary. + +**Step 2: Run existing project checks** + +```bash +just check +``` + +Expected: Existing clippy + tests still pass (no Rust code changed). + +**Step 3: Verify install script syntax on sh** + +```bash +sh -n install.sh +``` + +Expected: No errors. From 753ccab1e171c2a56d7626a13cfd3b66eb96d1e6 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Sun, 8 Mar 2026 22:00:01 -0700 Subject: [PATCH 2/9] plan: fix Test 2 stderr suppression bug in unsupported platform tests Use a dedicated run_detection_with_stderr helper that does not suppress stderr (no 2>/dev/null), so the "unsupported" error messages from detect_platform can be captured and asserted. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-08-install-script.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-03-08-install-script.md b/docs/plans/2026-03-08-install-script.md index f5a667e..8a329fc 100644 --- a/docs/plans/2026-03-08-install-script.md +++ b/docs/plans/2026-03-08-install-script.md @@ -271,9 +271,27 @@ done # ============================================================ echo "=== Test 2: Unsupported platform error ===" +# Helper for unsupported-platform tests: runs the harness directly +# without suppressing stderr, so error messages can be captured. +run_detection_with_stderr() { + _uname_dir="$1" + _harness="$TMPDIR_ROOT/harness-unsup-$$.sh" + sed 's/^main$//' "$INSTALL_SCRIPT" > "$_harness" + cat >> "$_harness" <<'HARNESS' +detect_platform +HARNESS + PATH="$_uname_dir:$PATH" \ + DEVPROXY_INSTALL_BASE_URL="https://example.com" \ + DEVPROXY_VERSION="latest" \ + sh "$_harness" 2>&1 + _rc=$? + rm -f "$_harness" + return $_rc +} + # Unsupported OS wrapper_dir="$(make_uname_wrapper "FreeBSD" "x86_64")" -if output="$(run_detection "$wrapper_dir" 2>&1)"; then +if output="$(run_detection_with_stderr "$wrapper_dir")"; then fail "FreeBSD should fail but exited 0" else if echo "$output" | grep -qi "unsupported"; then @@ -285,7 +303,7 @@ fi # Unsupported arch wrapper_dir="$(make_uname_wrapper "Linux" "mips")" -if output="$(run_detection "$wrapper_dir" 2>&1)"; then +if output="$(run_detection_with_stderr "$wrapper_dir")"; then fail "mips should fail but exited 0" else if echo "$output" | grep -qi "unsupported"; then From e0f873687c64f3340f72d7a0db7a5820d4684c10 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Sun, 8 Mar 2026 22:02:12 -0700 Subject: [PATCH 3/9] plan: fix mock binary filename syntax and simplify curl error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract case statement for mock binary OS detection into a separate variable to avoid POSIX sh syntax ambiguity with unparenthesized case patterns inside command substitution. - Replace curl -fsSL -w '%{http_code}' || true pattern with a simple if ! curl -fsSL; then error; fi — the old pattern discarded curl's exit code and relied on empty-file detection, which could miss servers returning HTML error bodies with 200 status. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-08-install-script.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-03-08-install-script.md b/docs/plans/2026-03-08-install-script.md index 8a329fc..ac78ff5 100644 --- a/docs/plans/2026-03-08-install-script.md +++ b/docs/plans/2026-03-08-install-script.md @@ -82,10 +82,8 @@ download_binary() { trap 'rm -f "$TMPFILE"' EXIT if command -v curl >/dev/null 2>&1; then - HTTP_CODE=$(curl -fsSL -w '%{http_code}' -o "$TMPFILE" "$DOWNLOAD_URL" 2>/dev/null) || true - if [ ! -s "$TMPFILE" ]; then + if ! curl -fsSL -o "$TMPFILE" "$DOWNLOAD_URL"; then echo "Error: failed to download devproxy from ${DOWNLOAD_URL}" >&2 - echo "HTTP status: ${HTTP_CODE:-unknown}" >&2 exit 1 fi elif command -v wget >/dev/null 2>&1; then @@ -359,7 +357,13 @@ MOCK_DIR="$TMPDIR_ROOT/mock-server" mkdir -p "$MOCK_DIR/latest/download" # Create a mock binary (shell script that echoes version) -MOCK_BINARY="$MOCK_DIR/latest/download/devproxy-$(uname -m | sed 's/arm64/aarch64/')-$(case "$(uname -s)" in Darwin) echo apple-darwin;; Linux) echo unknown-linux-gnu;; esac)" +# Determine the current platform's target triple for the mock binary filename +_mock_arch="$(uname -m | sed 's/arm64/aarch64/')" +case "$(uname -s)" in + Darwin) _mock_os="apple-darwin" ;; + Linux) _mock_os="unknown-linux-gnu" ;; +esac +MOCK_BINARY="$MOCK_DIR/latest/download/devproxy-${_mock_arch}-${_mock_os}" cat > "$MOCK_BINARY" <<'MOCKBIN' #!/bin/sh echo "devproxy mock 0.0.1-test" From bbfbf3f16a0a71ec4d5a282a891d8a5fdd5a9791 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Sun, 8 Mar 2026 22:04:58 -0700 Subject: [PATCH 4/9] test-plan: add install script test plan with 6 tests and harness specs Co-Authored-By: Claude Opus 4.6 --- .../2026-03-08-install-script-test-plan.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/plans/2026-03-08-install-script-test-plan.md diff --git a/docs/plans/2026-03-08-install-script-test-plan.md b/docs/plans/2026-03-08-install-script-test-plan.md new file mode 100644 index 0000000..c02ce90 --- /dev/null +++ b/docs/plans/2026-03-08-install-script-test-plan.md @@ -0,0 +1,164 @@ +# devproxy — Install Script Test Plan + +## Harness requirements + +### 1. uname wrapper harness + +- **What it does:** Creates a temporary directory containing a `uname` script that returns configurable OS and ARCH values, allowing platform detection to be tested on any host. +- **Exposes:** A function `make_uname_wrapper(os, arch)` that returns a directory path. Prepending this directory to `PATH` overrides the system `uname`. +- **Estimated complexity:** Low — ~15 lines of shell. +- **Tests that depend on it:** Tests 1, 2, 3, 5. + +### 2. Detection harness (run_detection) + +- **What it does:** Extracts `detect_platform` and `construct_url` functions from `install.sh` (by stripping the `main` call), appends print statements, and runs them with the overridden uname and env vars. This lets tests inspect intermediate values (`TARGET`, `DOWNLOAD_URL`) without running the full install flow. +- **Exposes:** A function `run_detection(uname_dir, base_url, version)` that prints `TARGET=...` and `DOWNLOAD_URL=...` lines to stdout. A variant `run_detection_with_stderr(uname_dir)` that captures stderr for error-path tests. +- **Estimated complexity:** Low — ~20 lines of shell using `sed` to strip the main call. +- **Tests that depend on it:** Tests 1, 2, 3. + +### 3. Mock HTTP server + +- **What it does:** Serves a directory tree via Python 3's `http.server` on a random free port. The directory contains mock binary files (shell scripts that echo a version string) at paths matching the GitHub Releases URL structure (`latest/download/devproxy-`). +- **Exposes:** `MOCK_PORT` variable and `MOCK_SERVER_PID` for cleanup. +- **Estimated complexity:** Low — Python 3 is available on all target platforms. ~10 lines to start, 1 line to stop. +- **Tests that depend on it:** Tests 4, 5. + +### 4. Minimal PATH harness + +- **What it does:** Creates a temporary `bin` directory with symlinks to only essential system commands (sh, uname, mktemp, chmod, mkdir, mv, rm, cat, etc.) but explicitly excludes `curl` and `wget`. Setting `PATH` to only this directory simulates a system without any HTTP download tool. +- **Exposes:** `MINIMAL_BIN` directory path. +- **Estimated complexity:** Low — ~10 lines of shell. +- **Tests that depend on it:** Test 6. + +--- + +## Test plan + +### Test 1: OS/arch detection produces correct target triple for all supported platforms + +- **Type:** scenario +- **Harness:** uname wrapper + detection harness +- **Preconditions:** `install.sh` exists at repo root. No network access needed. +- **Actions:** For each of the 4 supported platform combinations: + 1. `Darwin` + `arm64` (macOS Apple Silicon) + 2. `Darwin` + `x86_64` (macOS Intel) + 3. `Linux` + `x86_64` (Linux AMD64) + 4. `Linux` + `aarch64` (Linux ARM64) + + Create a uname wrapper returning the given OS/arch. Run the detection harness. Extract the `TARGET` value from stdout. +- **Expected outcome:** Each combo produces the correct target triple, per the binary naming convention defined in the implementation plan: + - `Darwin`/`arm64` -> `aarch64-apple-darwin` + - `Darwin`/`x86_64` -> `x86_64-apple-darwin` + - `Linux`/`x86_64` -> `x86_64-unknown-linux-gnu` + - `Linux`/`aarch64` -> `aarch64-unknown-linux-gnu` + + Source of truth: implementation plan key design decisions — binary naming convention `devproxy-`. +- **Interactions:** Exercises the `uname` wrapper harness; no real system calls to `uname`. + +### Test 2: Unsupported OS or architecture exits non-zero with error message + +- **Type:** boundary +- **Harness:** uname wrapper + detection harness (with stderr variant) +- **Preconditions:** `install.sh` exists at repo root. +- **Actions:** + 1. Create uname wrapper returning `FreeBSD` / `x86_64`. Run detection harness with stderr capture. + 2. Create uname wrapper returning `Linux` / `mips`. Run detection harness with stderr capture. +- **Expected outcome:** + - Both invocations exit with non-zero status. + - Stderr output contains the word "unsupported" (case-insensitive). + - The unsupported value (OS name or arch name) appears in the error message. + + Source of truth: implementation plan `detect_platform()` function — explicit `*) echo "Error: unsupported..."` cases. +- **Interactions:** None beyond the uname wrapper. + +### Test 3: URL construction matches GitHub Releases pattern for latest and versioned downloads + +- **Type:** integration +- **Harness:** uname wrapper + detection harness +- **Preconditions:** `install.sh` exists at repo root. +- **Actions:** + 1. With `Darwin`/`arm64`, `DEVPROXY_VERSION=latest`, `DEVPROXY_INSTALL_BASE_URL=https://example.com/releases`: run detection harness, extract `DOWNLOAD_URL`. + 2. Same platform with `DEVPROXY_VERSION=v1.0.0`: run detection harness, extract `DOWNLOAD_URL`. + 3. With `Linux`/`x86_64`, `DEVPROXY_VERSION=latest`: run detection harness, extract `DOWNLOAD_URL`. +- **Expected outcome:** + - Latest Darwin/arm64: `https://example.com/releases/latest/download/devproxy-aarch64-apple-darwin` + - Versioned Darwin/arm64: `https://example.com/releases/download/v1.0.0/devproxy-aarch64-apple-darwin` + - Latest Linux/x86_64: `https://example.com/releases/latest/download/devproxy-x86_64-unknown-linux-gnu` + + Source of truth: implementation plan `construct_url()` function — latest uses `{base}/latest/download/{binary}`, versioned uses `{base}/download/{version}/{binary}`. This matches standard GitHub Releases URL patterns. +- **Interactions:** Exercises `DEVPROXY_INSTALL_BASE_URL` env var override, confirming it is respected. + +### Test 4: Full e2e install downloads binary from mock server, installs it, and is idempotent + +- **Type:** scenario +- **Harness:** Mock HTTP server +- **Preconditions:** Python 3 available. Mock server running on localhost with a mock binary (shell script echoing `devproxy mock 0.0.1-test`) at the correct path for the current host platform. +- **Actions:** + 1. Start mock HTTP server serving directory with `latest/download/devproxy-` mock binary. + 2. Run `install.sh` with `DEVPROXY_INSTALL_BASE_URL=http://localhost:` and `DEVPROXY_INSTALL_DIR=`. + 3. Check that `/devproxy` exists and is executable (`-x` test). + 4. Execute `/devproxy` and capture output. + 5. Run `install.sh` again with the same env vars (idempotency check). +- **Expected outcome:** + - Step 2: install script exits 0. + - Step 3: binary exists at `/devproxy` and is executable. + - Step 4: output contains `devproxy mock`. + - Step 5: second install exits 0 (no error on overwrite). + + Source of truth: implementation plan Task 2 test case 4 — "verify the binary exists, is executable, and produces expected output. Run a second time to verify idempotency." Also implementation plan `create_install_dir()` handles existing dir, and `download_binary()` uses mv to overwrite. +- **Interactions:** Exercises the full install pipeline: platform detection (real host uname), URL construction, HTTP download (curl or wget against localhost), file placement, chmod. The mock server replaces GitHub Releases. + +### Test 5: Download failure (404) exits non-zero with error message + +- **Type:** boundary +- **Harness:** uname wrapper + mock HTTP server +- **Preconditions:** Mock server running. `DEVPROXY_INSTALL_BASE_URL` pointed at a nonexistent path on the mock server. +- **Actions:** + 1. Override uname to `Linux`/`aarch64` (a platform whose mock binary does NOT exist on the mock server at the nonexistent path). + 2. Run `install.sh` with `DEVPROXY_INSTALL_BASE_URL=http://localhost:/nonexistent` and `DEVPROXY_INSTALL_DIR=`. + 3. Capture exit code and combined stdout/stderr. +- **Expected outcome:** + - Exit code is non-zero. + - Output contains "error" or "fail" (case-insensitive). + + Source of truth: implementation plan `download_binary()` function — `curl -fsSL` returns non-zero on 404, script prints "Error: failed to download" and exits 1. +- **Interactions:** Exercises curl's `-f` flag (fail on HTTP errors) or wget's equivalent behavior against a real HTTP 404 response. + +### Test 6: Missing downloader (no curl or wget) exits non-zero with descriptive error + +- **Type:** boundary +- **Harness:** Minimal PATH harness +- **Preconditions:** A restricted `PATH` containing only essential commands, with `curl` and `wget` excluded. +- **Actions:** + 1. Set `PATH` to the minimal bin directory. + 2. Run `install.sh` with `DEVPROXY_INSTALL_BASE_URL` and `DEVPROXY_INSTALL_DIR` set. + 3. Capture exit code and combined stdout/stderr. +- **Expected outcome:** + - Exit code is non-zero. + - Output mentions "curl" or "wget" (case-insensitive), informing the user what to install. + + Source of truth: implementation plan `download_binary()` function — `else echo "Error: neither curl nor wget found..." >&2; exit 1`. +- **Interactions:** The script must reach the download phase (platform detection and URL construction succeed with the minimal PATH, since `uname` is included). Only the downloader check fails. + +--- + +## Coverage summary + +### Covered + +- **Platform detection:** All 4 supported OS/arch combinations tested (Test 1). +- **Unsupported platforms:** Both unsupported OS and unsupported arch rejected with clear error (Test 2). +- **URL construction:** Both latest and versioned URL patterns verified, plus base URL override (Test 3). +- **Happy-path install:** Full end-to-end download, placement, permissions, and execution verified (Test 4). +- **Idempotency:** Reinstall over existing binary succeeds (Test 4). +- **Download failure:** HTTP 404 handled gracefully (Test 5). +- **Missing downloader:** Absence of curl/wget detected with user-friendly error (Test 6). +- **DEVPROXY_INSTALL_BASE_URL:** Exercised in Tests 3, 4, 5 — confirms the override works for testing and mirrors. +- **DEVPROXY_INSTALL_DIR:** Exercised in Tests 4, 5, 6 — confirms custom install directory works. + +### Explicitly excluded (per agreed strategy) + +- **wget fallback path:** The e2e test uses whichever downloader is available on the host (likely curl on macOS). Testing wget specifically would require hiding curl, which adds complexity for low risk. Risk: wget codepath could have a subtle behavioral difference (e.g., different redirect handling). Mitigated by the script's simple wget invocation. +- **DEVPROXY_VERSION specific version download e2e:** URL construction is tested (Test 3) but no e2e download with a versioned path. Risk: low, since the URL pattern is the only difference. +- **Sudo/permissions:** Installing to `/usr/local/bin` (the default) may require sudo. Tests use `DEVPROXY_INSTALL_DIR` to avoid this. Risk: a user who runs without sudo to a protected directory gets a permission error. This is standard Unix behavior, not a script bug. +- **Network/TLS:** Real GitHub Releases downloads are not tested. Risk: mitigated by curl/wget being battle-tested HTTP clients. From 78a8a194313d5bbc26cd4e56e1116e94d7d1b3be Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Sun, 8 Mar 2026 22:07:34 -0700 Subject: [PATCH 5/9] feat: add curl|sh install script with test suite Add install.sh for downloading pre-built devproxy binaries from GitHub Releases, with OS/arch detection, curl/wget fallback, and configurable install directory. Include comprehensive shell-based test suite covering platform detection, URL construction, e2e install, error handling, and missing downloader scenarios. Co-Authored-By: Claude Opus 4.6 --- README.md | 6 + install.sh | 86 +++++++++++ justfile | 4 + tests/test_install.sh | 326 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 422 insertions(+) create mode 100755 install.sh create mode 100755 tests/test_install.sh diff --git a/README.md b/README.md index 5709e59..3ef7b0d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ devproxy up - **No external proxy** — single Rust binary, no Caddy/Traefik/nginx - **Human-readable slugs** — random adjective-animal subdomains +## Install + +```bash +curl -fsSL https://raw.githubusercontent.com/foundra-build/devproxy/main/install.sh | sh +``` + ## Quick Start ```bash diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..0619a0f --- /dev/null +++ b/install.sh @@ -0,0 +1,86 @@ +#!/bin/sh +set -eu + +DEVPROXY_VERSION="${DEVPROXY_VERSION:-latest}" +DEVPROXY_INSTALL_DIR="${DEVPROXY_INSTALL_DIR:-/usr/local/bin}" +DEVPROXY_INSTALL_BASE_URL="${DEVPROXY_INSTALL_BASE_URL:-https://github.com/foundra-build/devproxy/releases}" + +main() { + detect_platform + construct_url + create_install_dir + download_binary + make_executable + verify_installation + echo "devproxy installed successfully to ${DEVPROXY_INSTALL_DIR}/devproxy" +} + +detect_platform() { + OS="$(uname -s)" + ARCH="$(uname -m)" + + case "$OS" in + Darwin) OS_TARGET="apple-darwin" ;; + Linux) OS_TARGET="unknown-linux-gnu" ;; + *) echo "Error: unsupported operating system: $OS" >&2; exit 1 ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH_TARGET="x86_64" ;; + aarch64|arm64) ARCH_TARGET="aarch64" ;; + *) echo "Error: unsupported architecture: $ARCH" >&2; exit 1 ;; + esac + + TARGET="${ARCH_TARGET}-${OS_TARGET}" +} + +construct_url() { + BINARY_NAME="devproxy-${TARGET}" + if [ "$DEVPROXY_VERSION" = "latest" ]; then + DOWNLOAD_URL="${DEVPROXY_INSTALL_BASE_URL}/latest/download/${BINARY_NAME}" + else + DOWNLOAD_URL="${DEVPROXY_INSTALL_BASE_URL}/download/${DEVPROXY_VERSION}/${BINARY_NAME}" + fi +} + +create_install_dir() { + if [ ! -d "$DEVPROXY_INSTALL_DIR" ]; then + mkdir -p "$DEVPROXY_INSTALL_DIR" + fi +} + +download_binary() { + TMPFILE="$(mktemp)" + trap 'rm -f "$TMPFILE"' EXIT + + if command -v curl >/dev/null 2>&1; then + if ! curl -fsSL -o "$TMPFILE" "$DOWNLOAD_URL"; then + echo "Error: failed to download devproxy from ${DOWNLOAD_URL}" >&2 + exit 1 + fi + elif command -v wget >/dev/null 2>&1; then + if ! wget -q -O "$TMPFILE" "$DOWNLOAD_URL" 2>/dev/null; then + echo "Error: failed to download devproxy from ${DOWNLOAD_URL}" >&2 + exit 1 + fi + else + echo "Error: neither curl nor wget found. Please install one and try again." >&2 + exit 1 + fi + + mv "$TMPFILE" "${DEVPROXY_INSTALL_DIR}/devproxy" + trap - EXIT +} + +make_executable() { + chmod +x "${DEVPROXY_INSTALL_DIR}/devproxy" +} + +verify_installation() { + if [ ! -x "${DEVPROXY_INSTALL_DIR}/devproxy" ]; then + echo "Error: installation failed — binary not found at ${DEVPROXY_INSTALL_DIR}/devproxy" >&2 + exit 1 + fi +} + +main diff --git a/justfile b/justfile index 504546c..731297b 100644 --- a/justfile +++ b/justfile @@ -32,3 +32,7 @@ fmt-check: # Run e2e tests (requires Docker) e2e: cargo test --test e2e -- --include-ignored --nocapture + +# Run install script tests +test-install: + sh tests/test_install.sh diff --git a/tests/test_install.sh b/tests/test_install.sh new file mode 100755 index 0000000..9a90d7e --- /dev/null +++ b/tests/test_install.sh @@ -0,0 +1,326 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +INSTALL_SCRIPT="$REPO_ROOT/install.sh" + +PASS=0 +FAIL=0 +TOTAL=0 + +pass() { + PASS=$((PASS + 1)) + TOTAL=$((TOTAL + 1)) + echo " PASS: $1" +} + +fail() { + FAIL=$((FAIL + 1)) + TOTAL=$((TOTAL + 1)) + echo " FAIL: $1" + if [ -n "${2:-}" ]; then + echo " $2" + fi +} + +cleanup() { + if [ -n "${MOCK_SERVER_PID:-}" ]; then + kill "$MOCK_SERVER_PID" 2>/dev/null || true + wait "$MOCK_SERVER_PID" 2>/dev/null || true + fi + if [ -n "${TMPDIR_ROOT:-}" ]; then + rm -rf "$TMPDIR_ROOT" + fi +} +trap cleanup EXIT + +TMPDIR_ROOT="$(mktemp -d)" + +# --- Helper: create a uname wrapper that returns custom OS/ARCH --- +make_uname_wrapper() { + _os="$1" + _arch="$2" + _dir="$TMPDIR_ROOT/uname-wrapper-${_os}-${_arch}" + mkdir -p "$_dir" + cat > "$_dir/uname" < "$_harness" + cat >> "$_harness" <<'HARNESS' +detect_platform +construct_url +echo "TARGET=$TARGET" +echo "DOWNLOAD_URL=$DOWNLOAD_URL" +HARNESS + PATH="$_uname_dir:$PATH" \ + DEVPROXY_INSTALL_BASE_URL="$_base_url" \ + DEVPROXY_VERSION="$_version" \ + sh "$_harness" 2>/dev/null + rm -f "$_harness" +} + +# ============================================================ +# Test 1: OS/arch detection — all 4 platform combos +# ============================================================ +echo "=== Test 1: OS/arch detection ===" + +for combo in "Darwin:arm64:aarch64-apple-darwin" \ + "Darwin:x86_64:x86_64-apple-darwin" \ + "Linux:x86_64:x86_64-unknown-linux-gnu" \ + "Linux:aarch64:aarch64-unknown-linux-gnu"; do + os="$(echo "$combo" | cut -d: -f1)" + arch="$(echo "$combo" | cut -d: -f2)" + expected="$(echo "$combo" | cut -d: -f3)" + + wrapper_dir="$(make_uname_wrapper "$os" "$arch")" + result="$(run_detection "$wrapper_dir" | grep '^TARGET=' | cut -d= -f2)" + + if [ "$result" = "$expected" ]; then + pass "$os/$arch -> $expected" + else + fail "$os/$arch -> expected $expected, got $result" + fi +done + +# ============================================================ +# Test 2: Unsupported platform error +# ============================================================ +echo "=== Test 2: Unsupported platform error ===" + +# Helper for unsupported-platform tests: runs the harness directly +# without suppressing stderr, so error messages can be captured. +run_detection_with_stderr() { + _uname_dir="$1" + _harness="$TMPDIR_ROOT/harness-unsup-$$.sh" + sed 's/^main$//' "$INSTALL_SCRIPT" > "$_harness" + cat >> "$_harness" <<'HARNESS' +detect_platform +HARNESS + PATH="$_uname_dir:$PATH" \ + DEVPROXY_INSTALL_BASE_URL="https://example.com" \ + DEVPROXY_VERSION="latest" \ + sh "$_harness" 2>&1 + _rc=$? + rm -f "$_harness" + return $_rc +} + +# Unsupported OS +wrapper_dir="$(make_uname_wrapper "FreeBSD" "x86_64")" +if output="$(run_detection_with_stderr "$wrapper_dir")"; then + fail "FreeBSD should fail but exited 0" +else + if echo "$output" | grep -qi "unsupported"; then + pass "FreeBSD rejected with error message" + else + fail "FreeBSD rejected but no 'unsupported' in message" "$output" + fi +fi + +# Unsupported arch +wrapper_dir="$(make_uname_wrapper "Linux" "mips")" +if output="$(run_detection_with_stderr "$wrapper_dir")"; then + fail "mips should fail but exited 0" +else + if echo "$output" | grep -qi "unsupported"; then + pass "mips rejected with error message" + else + fail "mips rejected but no 'unsupported' in message" "$output" + fi +fi + +# ============================================================ +# Test 3: URL construction +# ============================================================ +echo "=== Test 3: URL construction ===" + +BASE="https://example.com/releases" + +# Latest version +wrapper_dir="$(make_uname_wrapper "Darwin" "arm64")" +url="$(run_detection "$wrapper_dir" "$BASE" "latest" | grep '^DOWNLOAD_URL=' | cut -d= -f2-)" +expected_url="https://example.com/releases/latest/download/devproxy-aarch64-apple-darwin" +if [ "$url" = "$expected_url" ]; then + pass "latest URL for Darwin/arm64" +else + fail "latest URL: expected $expected_url, got $url" +fi + +# Specific version +url="$(run_detection "$wrapper_dir" "$BASE" "v1.0.0" | grep '^DOWNLOAD_URL=' | cut -d= -f2-)" +expected_url="https://example.com/releases/download/v1.0.0/devproxy-aarch64-apple-darwin" +if [ "$url" = "$expected_url" ]; then + pass "versioned URL for Darwin/arm64" +else + fail "versioned URL: expected $expected_url, got $url" +fi + +# Linux x86_64 +wrapper_dir="$(make_uname_wrapper "Linux" "x86_64")" +url="$(run_detection "$wrapper_dir" "$BASE" "latest" | grep '^DOWNLOAD_URL=' | cut -d= -f2-)" +expected_url="https://example.com/releases/latest/download/devproxy-x86_64-unknown-linux-gnu" +if [ "$url" = "$expected_url" ]; then + pass "latest URL for Linux/x86_64" +else + fail "latest URL: expected $expected_url, got $url" +fi + +# ============================================================ +# Test 4: Full install e2e with mock server +# ============================================================ +echo "=== Test 4: Full install e2e ===" + +# Set up mock server directory structure +MOCK_DIR="$TMPDIR_ROOT/mock-server" +mkdir -p "$MOCK_DIR/latest/download" + +# Create a mock binary (shell script that echoes version) +# Determine the current platform's target triple for the mock binary filename +_mock_arch="$(uname -m | sed 's/arm64/aarch64/')" +case "$(uname -s)" in + Darwin) _mock_os="apple-darwin" ;; + Linux) _mock_os="unknown-linux-gnu" ;; +esac +MOCK_BINARY="$MOCK_DIR/latest/download/devproxy-${_mock_arch}-${_mock_os}" +cat > "$MOCK_BINARY" <<'MOCKBIN' +#!/bin/sh +echo "devproxy mock 0.0.1-test" +MOCKBIN +chmod +x "$MOCK_BINARY" + +# Start mock HTTP server +MOCK_PORT=0 +# Find a free port +MOCK_PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()") +cd "$MOCK_DIR" +python3 -m http.server "$MOCK_PORT" >/dev/null 2>&1 & +MOCK_SERVER_PID=$! +cd "$REPO_ROOT" +# Give the server a moment to start +sleep 1 + +INSTALL_DIR="$TMPDIR_ROOT/install-target" +mkdir -p "$INSTALL_DIR" + +# Run install +if DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}" \ + DEVPROXY_INSTALL_DIR="$INSTALL_DIR" \ + sh "$INSTALL_SCRIPT" >/dev/null 2>&1; then + # Check binary exists and is executable + if [ -x "$INSTALL_DIR/devproxy" ]; then + pass "binary installed and executable" + else + fail "binary not found or not executable at $INSTALL_DIR/devproxy" + fi + + # Check binary works + mock_output="$("$INSTALL_DIR/devproxy" 2>&1 || true)" + if echo "$mock_output" | grep -q "devproxy mock"; then + pass "installed binary produces expected output" + else + fail "binary output unexpected" "$mock_output" + fi + + # Idempotency: run again + if DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}" \ + DEVPROXY_INSTALL_DIR="$INSTALL_DIR" \ + sh "$INSTALL_SCRIPT" >/dev/null 2>&1; then + pass "idempotent reinstall succeeds" + else + fail "idempotent reinstall failed" + fi +else + fail "install script failed" +fi + +# ============================================================ +# Test 5: Download failure (404) +# ============================================================ +echo "=== Test 5: Download failure (404) ===" + +INSTALL_DIR_404="$TMPDIR_ROOT/install-404" +mkdir -p "$INSTALL_DIR_404" + +# Point at a path that doesn't exist on the mock server +wrapper_dir="$(make_uname_wrapper "Linux" "aarch64")" +if output="$(PATH="$wrapper_dir:$PATH" \ + DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}/nonexistent" \ + DEVPROXY_INSTALL_DIR="$INSTALL_DIR_404" \ + sh "$INSTALL_SCRIPT" 2>&1)"; then + fail "404 should cause non-zero exit" +else + if echo "$output" | grep -qi "error\|fail"; then + pass "404 produces error message" + else + fail "404 exited non-zero but no error in output" "$output" + fi +fi + +# ============================================================ +# Test 6: Missing downloader +# ============================================================ +echo "=== Test 6: Missing downloader ===" + +INSTALL_DIR_NODL="$TMPDIR_ROOT/install-nodl" +mkdir -p "$INSTALL_DIR_NODL" + +# Create a minimal PATH with only essential commands but no curl/wget +MINIMAL_BIN="$TMPDIR_ROOT/minimal-bin" +mkdir -p "$MINIMAL_BIN" +# Link only the essentials the script needs (sh, uname, mktemp, chmod, mkdir, mv, rm, cat, sed, grep, printf, echo, test, tr, cut) +for cmd in sh uname mktemp chmod mkdir mv rm cat sed grep printf echo test tr cut; do + cmd_path="$(command -v "$cmd" 2>/dev/null || true)" + if [ -n "$cmd_path" ]; then + ln -sf "$cmd_path" "$MINIMAL_BIN/$cmd" 2>/dev/null || true + fi +done +# Also need [ for test +if [ -f /bin/[ ]; then + ln -sf /bin/[ "$MINIMAL_BIN/[" 2>/dev/null || true +fi +# Need env +ln -sf "$(command -v env)" "$MINIMAL_BIN/env" 2>/dev/null || true + +if output="$(PATH="$MINIMAL_BIN" \ + DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}" \ + DEVPROXY_INSTALL_DIR="$INSTALL_DIR_NODL" \ + sh "$INSTALL_SCRIPT" 2>&1)"; then + fail "missing downloader should cause non-zero exit" +else + if echo "$output" | grep -qi "curl\|wget"; then + pass "missing downloader error mentions curl/wget" + else + fail "missing downloader exited non-zero but no curl/wget mention" "$output" + fi +fi + +# ============================================================ +# Summary +# ============================================================ +echo "" +echo "============================================================" +echo "Results: $PASS passed, $FAIL failed, $TOTAL total" +echo "============================================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi From 8b8622a668080c12c58914d32615ee4defdbab44 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Sun, 8 Mar 2026 22:10:25 -0700 Subject: [PATCH 6/9] fix: address review findings in install script and test suite - Fix run_detection_with_stderr exit code handling: use `|| _rc=$?` pattern instead of bare `$?` after command, which was unreachable under set -eu (findings 1, 5) - Replace fragile `sed 's/^main$//'` with sentinel marker `# __DEVPROXY_INSTALL_MAIN__` and guard assertion (finding 2) - Replace flaky `sleep 1` for mock server startup with a retry loop that polls the server with curl (finding 3) - Add user-friendly error message to create_install_dir when mkdir fails, suggesting sudo or DEVPROXY_INSTALL_DIR (finding 4) Co-Authored-By: Claude Opus 4.6 --- install.sh | 7 ++++++- tests/test_install.sh | 42 +++++++++++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/install.sh b/install.sh index 0619a0f..b7545e1 100755 --- a/install.sh +++ b/install.sh @@ -45,7 +45,11 @@ construct_url() { create_install_dir() { if [ ! -d "$DEVPROXY_INSTALL_DIR" ]; then - mkdir -p "$DEVPROXY_INSTALL_DIR" + if ! mkdir -p "$DEVPROXY_INSTALL_DIR" 2>/dev/null; then + echo "Error: failed to create install directory ${DEVPROXY_INSTALL_DIR}" >&2 + echo "Try running with sudo or set DEVPROXY_INSTALL_DIR to a writable location." >&2 + exit 1 + fi fi } @@ -83,4 +87,5 @@ verify_installation() { fi } +# __DEVPROXY_INSTALL_MAIN__ main diff --git a/tests/test_install.sh b/tests/test_install.sh index 9a90d7e..f12fff6 100755 --- a/tests/test_install.sh +++ b/tests/test_install.sh @@ -55,16 +55,26 @@ WRAPPER echo "$_dir" } +# --- Helper: build a harness script from install.sh --- +# Strips everything from the sentinel marker line onward, then appends custom code. +# This is more robust than matching a bare "main" line. +make_harness() { + _harness_file="$1" + # Guard: verify the sentinel marker exists in install.sh + if ! grep -q '^# __DEVPROXY_INSTALL_MAIN__$' "$INSTALL_SCRIPT"; then + echo "FATAL: install.sh is missing the # __DEVPROXY_INSTALL_MAIN__ sentinel marker" >&2 + exit 2 + fi + sed '/^# __DEVPROXY_INSTALL_MAIN__$/,$d' "$INSTALL_SCRIPT" > "$_harness_file" +} + # --- Helper: extract detect_platform + construct_url and print TARGET/URL --- -# We source a modified version of install.sh that calls detect_platform + construct_url then prints run_detection() { _uname_dir="$1" _base_url="${2:-https://github.com/foundra-build/devproxy/releases}" _version="${3:-latest}" - # Create a test harness that sources the functions and calls them _harness="$TMPDIR_ROOT/harness-$$.sh" - # Extract function definitions from install.sh, replace main call - sed 's/^main$//' "$INSTALL_SCRIPT" > "$_harness" + make_harness "$_harness" cat >> "$_harness" <<'HARNESS' detect_platform construct_url @@ -106,20 +116,22 @@ done # ============================================================ echo "=== Test 2: Unsupported platform error ===" -# Helper for unsupported-platform tests: runs the harness directly -# without suppressing stderr, so error messages can be captured. +# Helper for unsupported-platform tests: runs the harness in a subshell +# with set +e so the non-zero exit is captured rather than aborting. run_detection_with_stderr() { _uname_dir="$1" _harness="$TMPDIR_ROOT/harness-unsup-$$.sh" - sed 's/^main$//' "$INSTALL_SCRIPT" > "$_harness" + make_harness "$_harness" cat >> "$_harness" <<'HARNESS' detect_platform HARNESS + # Run in a subshell with set +e to capture the exit code properly + # and ensure cleanup of the harness file on both success and failure. + _rc=0 PATH="$_uname_dir:$PATH" \ DEVPROXY_INSTALL_BASE_URL="https://example.com" \ DEVPROXY_VERSION="latest" \ - sh "$_harness" 2>&1 - _rc=$? + sh "$_harness" 2>&1 || _rc=$? rm -f "$_harness" return $_rc } @@ -215,8 +227,16 @@ cd "$MOCK_DIR" python3 -m http.server "$MOCK_PORT" >/dev/null 2>&1 & MOCK_SERVER_PID=$! cd "$REPO_ROOT" -# Give the server a moment to start -sleep 1 +# Wait for mock server to be ready (retry up to 5 seconds) +_retries=0 +while ! curl -s -o /dev/null "http://localhost:${MOCK_PORT}/" 2>/dev/null; do + _retries=$((_retries + 1)) + if [ "$_retries" -ge 50 ]; then + echo "FATAL: mock HTTP server failed to start on port $MOCK_PORT" >&2 + exit 2 + fi + sleep 0.1 +done INSTALL_DIR="$TMPDIR_ROOT/install-target" mkdir -p "$INSTALL_DIR" From bac1e646405b66d67437004f534146b67266e52a Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Sun, 8 Mar 2026 22:12:46 -0700 Subject: [PATCH 7/9] fix: harden install script binary placement and error handling - Use `install -m 755` instead of mv + chmod to atomically place the binary with correct permissions, eliminating the window where an interrupt could leave a non-executable binary (finding 1) - Add writability check for existing install directories so users get a clear error instead of a cryptic mv failure (finding 2) - Remove `2>/dev/null` from wget so TLS/DNS errors are visible to users; `-q` already suppresses the progress bar (finding 3) Co-Authored-By: Claude Opus 4.6 --- install.sh | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index b7545e1..3f4c50a 100755 --- a/install.sh +++ b/install.sh @@ -10,7 +10,6 @@ main() { construct_url create_install_dir download_binary - make_executable verify_installation echo "devproxy installed successfully to ${DEVPROXY_INSTALL_DIR}/devproxy" } @@ -50,6 +49,10 @@ create_install_dir() { echo "Try running with sudo or set DEVPROXY_INSTALL_DIR to a writable location." >&2 exit 1 fi + elif [ ! -w "$DEVPROXY_INSTALL_DIR" ]; then + echo "Error: install directory ${DEVPROXY_INSTALL_DIR} is not writable" >&2 + echo "Try running with sudo or set DEVPROXY_INSTALL_DIR to a writable location." >&2 + exit 1 fi } @@ -63,7 +66,7 @@ download_binary() { exit 1 fi elif command -v wget >/dev/null 2>&1; then - if ! wget -q -O "$TMPFILE" "$DOWNLOAD_URL" 2>/dev/null; then + if ! wget -q -O "$TMPFILE" "$DOWNLOAD_URL"; then echo "Error: failed to download devproxy from ${DOWNLOAD_URL}" >&2 exit 1 fi @@ -72,14 +75,13 @@ download_binary() { exit 1 fi - mv "$TMPFILE" "${DEVPROXY_INSTALL_DIR}/devproxy" + # Use install -m to atomically place the binary with correct permissions, + # avoiding a window where the binary exists but is not yet executable. + install -m 755 "$TMPFILE" "${DEVPROXY_INSTALL_DIR}/devproxy" + rm -f "$TMPFILE" trap - EXIT } -make_executable() { - chmod +x "${DEVPROXY_INSTALL_DIR}/devproxy" -} - verify_installation() { if [ ! -x "${DEVPROXY_INSTALL_DIR}/devproxy" ]; then echo "Error: installation failed — binary not found at ${DEVPROXY_INSTALL_DIR}/devproxy" >&2 From f193921025d0bc634e178ce4b7cb6f6e0385d576 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Sun, 8 Mar 2026 22:14:55 -0700 Subject: [PATCH 8/9] fix: use POSIX cp+chmod, add arch alias tests, handle unsupported host - Replace `install -m 755` with `cp` + `chmod 755` for portability on minimal Linux systems like Alpine (finding 1) - Add explicit error handling for cp and chmod failures with clear messages instead of falling through to verify_installation (finding 2) - Add test cases for amd64 and arm64 architecture aliases on Linux, covering the alternate uname -m values (finding 3) - Add fallback case for _mock_os on unsupported host platforms so e2e tests skip gracefully instead of using an unset variable (finding 4) Co-Authored-By: Claude Opus 4.6 --- install.sh | 14 +++++++++++--- tests/test_install.sh | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index 3f4c50a..7d7ae01 100755 --- a/install.sh +++ b/install.sh @@ -75,9 +75,17 @@ download_binary() { exit 1 fi - # Use install -m to atomically place the binary with correct permissions, - # avoiding a window where the binary exists but is not yet executable. - install -m 755 "$TMPFILE" "${DEVPROXY_INSTALL_DIR}/devproxy" + # Copy and set permissions using only POSIX-guaranteed commands. + # chmod before cp would not help since cp creates a new inode; + # instead we cp then chmod, keeping the window minimal. + if ! cp "$TMPFILE" "${DEVPROXY_INSTALL_DIR}/devproxy"; then + echo "Error: failed to copy binary to ${DEVPROXY_INSTALL_DIR}/devproxy" >&2 + exit 1 + fi + if ! chmod 755 "${DEVPROXY_INSTALL_DIR}/devproxy"; then + echo "Error: failed to set executable permissions on ${DEVPROXY_INSTALL_DIR}/devproxy" >&2 + exit 1 + fi rm -f "$TMPFILE" trap - EXIT } diff --git a/tests/test_install.sh b/tests/test_install.sh index f12fff6..de7e454 100755 --- a/tests/test_install.sh +++ b/tests/test_install.sh @@ -96,7 +96,9 @@ echo "=== Test 1: OS/arch detection ===" for combo in "Darwin:arm64:aarch64-apple-darwin" \ "Darwin:x86_64:x86_64-apple-darwin" \ "Linux:x86_64:x86_64-unknown-linux-gnu" \ - "Linux:aarch64:aarch64-unknown-linux-gnu"; do + "Linux:aarch64:aarch64-unknown-linux-gnu" \ + "Linux:amd64:x86_64-unknown-linux-gnu" \ + "Linux:arm64:aarch64-unknown-linux-gnu"; do os="$(echo "$combo" | cut -d: -f1)" arch="$(echo "$combo" | cut -d: -f2)" expected="$(echo "$combo" | cut -d: -f3)" @@ -208,10 +210,19 @@ mkdir -p "$MOCK_DIR/latest/download" # Create a mock binary (shell script that echoes version) # Determine the current platform's target triple for the mock binary filename _mock_arch="$(uname -m | sed 's/arm64/aarch64/')" +_mock_os="" case "$(uname -s)" in Darwin) _mock_os="apple-darwin" ;; Linux) _mock_os="unknown-linux-gnu" ;; + *) echo " SKIP: e2e tests not supported on $(uname -s)" ;; esac + +if [ -z "$_mock_os" ]; then + # Skip e2e tests (4 and 5) on unsupported host platforms + echo "=== Test 5: Download failure (404) ===" + echo " SKIP: e2e tests not supported on $(uname -s)" +else + MOCK_BINARY="$MOCK_DIR/latest/download/devproxy-${_mock_arch}-${_mock_os}" cat > "$MOCK_BINARY" <<'MOCKBIN' #!/bin/sh @@ -295,6 +306,8 @@ else fi fi +fi # end of _mock_os check for e2e tests (Tests 4 and 5) + # ============================================================ # Test 6: Missing downloader # ============================================================ From f8642b3cd7bcb4e0a923a9e0fa9cbbdf50bf256b Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Sun, 8 Mar 2026 22:16:19 -0700 Subject: [PATCH 9/9] chore: remove planning artifacts Co-Authored-By: Claude Opus 4.6 --- .../2026-03-08-install-script-test-plan.md | 164 ----- docs/plans/2026-03-08-install-script.md | 584 ------------------ 2 files changed, 748 deletions(-) delete mode 100644 docs/plans/2026-03-08-install-script-test-plan.md delete mode 100644 docs/plans/2026-03-08-install-script.md diff --git a/docs/plans/2026-03-08-install-script-test-plan.md b/docs/plans/2026-03-08-install-script-test-plan.md deleted file mode 100644 index c02ce90..0000000 --- a/docs/plans/2026-03-08-install-script-test-plan.md +++ /dev/null @@ -1,164 +0,0 @@ -# devproxy — Install Script Test Plan - -## Harness requirements - -### 1. uname wrapper harness - -- **What it does:** Creates a temporary directory containing a `uname` script that returns configurable OS and ARCH values, allowing platform detection to be tested on any host. -- **Exposes:** A function `make_uname_wrapper(os, arch)` that returns a directory path. Prepending this directory to `PATH` overrides the system `uname`. -- **Estimated complexity:** Low — ~15 lines of shell. -- **Tests that depend on it:** Tests 1, 2, 3, 5. - -### 2. Detection harness (run_detection) - -- **What it does:** Extracts `detect_platform` and `construct_url` functions from `install.sh` (by stripping the `main` call), appends print statements, and runs them with the overridden uname and env vars. This lets tests inspect intermediate values (`TARGET`, `DOWNLOAD_URL`) without running the full install flow. -- **Exposes:** A function `run_detection(uname_dir, base_url, version)` that prints `TARGET=...` and `DOWNLOAD_URL=...` lines to stdout. A variant `run_detection_with_stderr(uname_dir)` that captures stderr for error-path tests. -- **Estimated complexity:** Low — ~20 lines of shell using `sed` to strip the main call. -- **Tests that depend on it:** Tests 1, 2, 3. - -### 3. Mock HTTP server - -- **What it does:** Serves a directory tree via Python 3's `http.server` on a random free port. The directory contains mock binary files (shell scripts that echo a version string) at paths matching the GitHub Releases URL structure (`latest/download/devproxy-`). -- **Exposes:** `MOCK_PORT` variable and `MOCK_SERVER_PID` for cleanup. -- **Estimated complexity:** Low — Python 3 is available on all target platforms. ~10 lines to start, 1 line to stop. -- **Tests that depend on it:** Tests 4, 5. - -### 4. Minimal PATH harness - -- **What it does:** Creates a temporary `bin` directory with symlinks to only essential system commands (sh, uname, mktemp, chmod, mkdir, mv, rm, cat, etc.) but explicitly excludes `curl` and `wget`. Setting `PATH` to only this directory simulates a system without any HTTP download tool. -- **Exposes:** `MINIMAL_BIN` directory path. -- **Estimated complexity:** Low — ~10 lines of shell. -- **Tests that depend on it:** Test 6. - ---- - -## Test plan - -### Test 1: OS/arch detection produces correct target triple for all supported platforms - -- **Type:** scenario -- **Harness:** uname wrapper + detection harness -- **Preconditions:** `install.sh` exists at repo root. No network access needed. -- **Actions:** For each of the 4 supported platform combinations: - 1. `Darwin` + `arm64` (macOS Apple Silicon) - 2. `Darwin` + `x86_64` (macOS Intel) - 3. `Linux` + `x86_64` (Linux AMD64) - 4. `Linux` + `aarch64` (Linux ARM64) - - Create a uname wrapper returning the given OS/arch. Run the detection harness. Extract the `TARGET` value from stdout. -- **Expected outcome:** Each combo produces the correct target triple, per the binary naming convention defined in the implementation plan: - - `Darwin`/`arm64` -> `aarch64-apple-darwin` - - `Darwin`/`x86_64` -> `x86_64-apple-darwin` - - `Linux`/`x86_64` -> `x86_64-unknown-linux-gnu` - - `Linux`/`aarch64` -> `aarch64-unknown-linux-gnu` - - Source of truth: implementation plan key design decisions — binary naming convention `devproxy-`. -- **Interactions:** Exercises the `uname` wrapper harness; no real system calls to `uname`. - -### Test 2: Unsupported OS or architecture exits non-zero with error message - -- **Type:** boundary -- **Harness:** uname wrapper + detection harness (with stderr variant) -- **Preconditions:** `install.sh` exists at repo root. -- **Actions:** - 1. Create uname wrapper returning `FreeBSD` / `x86_64`. Run detection harness with stderr capture. - 2. Create uname wrapper returning `Linux` / `mips`. Run detection harness with stderr capture. -- **Expected outcome:** - - Both invocations exit with non-zero status. - - Stderr output contains the word "unsupported" (case-insensitive). - - The unsupported value (OS name or arch name) appears in the error message. - - Source of truth: implementation plan `detect_platform()` function — explicit `*) echo "Error: unsupported..."` cases. -- **Interactions:** None beyond the uname wrapper. - -### Test 3: URL construction matches GitHub Releases pattern for latest and versioned downloads - -- **Type:** integration -- **Harness:** uname wrapper + detection harness -- **Preconditions:** `install.sh` exists at repo root. -- **Actions:** - 1. With `Darwin`/`arm64`, `DEVPROXY_VERSION=latest`, `DEVPROXY_INSTALL_BASE_URL=https://example.com/releases`: run detection harness, extract `DOWNLOAD_URL`. - 2. Same platform with `DEVPROXY_VERSION=v1.0.0`: run detection harness, extract `DOWNLOAD_URL`. - 3. With `Linux`/`x86_64`, `DEVPROXY_VERSION=latest`: run detection harness, extract `DOWNLOAD_URL`. -- **Expected outcome:** - - Latest Darwin/arm64: `https://example.com/releases/latest/download/devproxy-aarch64-apple-darwin` - - Versioned Darwin/arm64: `https://example.com/releases/download/v1.0.0/devproxy-aarch64-apple-darwin` - - Latest Linux/x86_64: `https://example.com/releases/latest/download/devproxy-x86_64-unknown-linux-gnu` - - Source of truth: implementation plan `construct_url()` function — latest uses `{base}/latest/download/{binary}`, versioned uses `{base}/download/{version}/{binary}`. This matches standard GitHub Releases URL patterns. -- **Interactions:** Exercises `DEVPROXY_INSTALL_BASE_URL` env var override, confirming it is respected. - -### Test 4: Full e2e install downloads binary from mock server, installs it, and is idempotent - -- **Type:** scenario -- **Harness:** Mock HTTP server -- **Preconditions:** Python 3 available. Mock server running on localhost with a mock binary (shell script echoing `devproxy mock 0.0.1-test`) at the correct path for the current host platform. -- **Actions:** - 1. Start mock HTTP server serving directory with `latest/download/devproxy-` mock binary. - 2. Run `install.sh` with `DEVPROXY_INSTALL_BASE_URL=http://localhost:` and `DEVPROXY_INSTALL_DIR=`. - 3. Check that `/devproxy` exists and is executable (`-x` test). - 4. Execute `/devproxy` and capture output. - 5. Run `install.sh` again with the same env vars (idempotency check). -- **Expected outcome:** - - Step 2: install script exits 0. - - Step 3: binary exists at `/devproxy` and is executable. - - Step 4: output contains `devproxy mock`. - - Step 5: second install exits 0 (no error on overwrite). - - Source of truth: implementation plan Task 2 test case 4 — "verify the binary exists, is executable, and produces expected output. Run a second time to verify idempotency." Also implementation plan `create_install_dir()` handles existing dir, and `download_binary()` uses mv to overwrite. -- **Interactions:** Exercises the full install pipeline: platform detection (real host uname), URL construction, HTTP download (curl or wget against localhost), file placement, chmod. The mock server replaces GitHub Releases. - -### Test 5: Download failure (404) exits non-zero with error message - -- **Type:** boundary -- **Harness:** uname wrapper + mock HTTP server -- **Preconditions:** Mock server running. `DEVPROXY_INSTALL_BASE_URL` pointed at a nonexistent path on the mock server. -- **Actions:** - 1. Override uname to `Linux`/`aarch64` (a platform whose mock binary does NOT exist on the mock server at the nonexistent path). - 2. Run `install.sh` with `DEVPROXY_INSTALL_BASE_URL=http://localhost:/nonexistent` and `DEVPROXY_INSTALL_DIR=`. - 3. Capture exit code and combined stdout/stderr. -- **Expected outcome:** - - Exit code is non-zero. - - Output contains "error" or "fail" (case-insensitive). - - Source of truth: implementation plan `download_binary()` function — `curl -fsSL` returns non-zero on 404, script prints "Error: failed to download" and exits 1. -- **Interactions:** Exercises curl's `-f` flag (fail on HTTP errors) or wget's equivalent behavior against a real HTTP 404 response. - -### Test 6: Missing downloader (no curl or wget) exits non-zero with descriptive error - -- **Type:** boundary -- **Harness:** Minimal PATH harness -- **Preconditions:** A restricted `PATH` containing only essential commands, with `curl` and `wget` excluded. -- **Actions:** - 1. Set `PATH` to the minimal bin directory. - 2. Run `install.sh` with `DEVPROXY_INSTALL_BASE_URL` and `DEVPROXY_INSTALL_DIR` set. - 3. Capture exit code and combined stdout/stderr. -- **Expected outcome:** - - Exit code is non-zero. - - Output mentions "curl" or "wget" (case-insensitive), informing the user what to install. - - Source of truth: implementation plan `download_binary()` function — `else echo "Error: neither curl nor wget found..." >&2; exit 1`. -- **Interactions:** The script must reach the download phase (platform detection and URL construction succeed with the minimal PATH, since `uname` is included). Only the downloader check fails. - ---- - -## Coverage summary - -### Covered - -- **Platform detection:** All 4 supported OS/arch combinations tested (Test 1). -- **Unsupported platforms:** Both unsupported OS and unsupported arch rejected with clear error (Test 2). -- **URL construction:** Both latest and versioned URL patterns verified, plus base URL override (Test 3). -- **Happy-path install:** Full end-to-end download, placement, permissions, and execution verified (Test 4). -- **Idempotency:** Reinstall over existing binary succeeds (Test 4). -- **Download failure:** HTTP 404 handled gracefully (Test 5). -- **Missing downloader:** Absence of curl/wget detected with user-friendly error (Test 6). -- **DEVPROXY_INSTALL_BASE_URL:** Exercised in Tests 3, 4, 5 — confirms the override works for testing and mirrors. -- **DEVPROXY_INSTALL_DIR:** Exercised in Tests 4, 5, 6 — confirms custom install directory works. - -### Explicitly excluded (per agreed strategy) - -- **wget fallback path:** The e2e test uses whichever downloader is available on the host (likely curl on macOS). Testing wget specifically would require hiding curl, which adds complexity for low risk. Risk: wget codepath could have a subtle behavioral difference (e.g., different redirect handling). Mitigated by the script's simple wget invocation. -- **DEVPROXY_VERSION specific version download e2e:** URL construction is tested (Test 3) but no e2e download with a versioned path. Risk: low, since the URL pattern is the only difference. -- **Sudo/permissions:** Installing to `/usr/local/bin` (the default) may require sudo. Tests use `DEVPROXY_INSTALL_DIR` to avoid this. Risk: a user who runs without sudo to a protected directory gets a permission error. This is standard Unix behavior, not a script bug. -- **Network/TLS:** Real GitHub Releases downloads are not tested. Risk: mitigated by curl/wget being battle-tested HTTP clients. diff --git a/docs/plans/2026-03-08-install-script.md b/docs/plans/2026-03-08-install-script.md deleted file mode 100644 index ac78ff5..0000000 --- a/docs/plans/2026-03-08-install-script.md +++ /dev/null @@ -1,584 +0,0 @@ -# devproxy — Install Script Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. - -**Goal:** Add a `curl -fsSL ... | sh` install script that downloads pre-built devproxy binaries from GitHub Releases, plus a comprehensive shell-based test suite. - -**Architecture:** A single POSIX shell install script (`install.sh`) at the repo root that detects the user's OS/arch, constructs a GitHub Release download URL, downloads the appropriate binary, and installs it to a configurable location. A test script (`tests/test_install.sh`) exercises all code paths using uname overrides, a local HTTP mock server, and temp directories. - -**Key design decisions:** -- The install script is POSIX sh (not bash) for maximum portability across macOS and Linux. -- Binary naming convention: `devproxy-` where triples are `x86_64-apple-darwin`, `aarch64-apple-darwin`, `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`. -- `DEVPROXY_INSTALL_BASE_URL` env var overrides the default GitHub Releases base URL, enabling testing and mirror deployments. -- `DEVPROXY_INSTALL_DIR` env var overrides the default install directory (`/usr/local/bin`). -- The test suite uses Python's `http.server` as a lightweight mock HTTP server (available on all target platforms without extra dependencies). -- The install script prefers `curl` but falls back to `wget`. - ---- - -## Task 1: Create the install script - -**Files:** -- Create: `install.sh` - -**Step 1: Write the install script** - -Create `install.sh` at the repo root with the following content: - -```sh -#!/bin/sh -set -eu - -DEVPROXY_VERSION="${DEVPROXY_VERSION:-latest}" -DEVPROXY_INSTALL_DIR="${DEVPROXY_INSTALL_DIR:-/usr/local/bin}" -DEVPROXY_INSTALL_BASE_URL="${DEVPROXY_INSTALL_BASE_URL:-https://github.com/foundra-build/devproxy/releases}" - -main() { - detect_platform - construct_url - create_install_dir - download_binary - make_executable - verify_installation - echo "devproxy installed successfully to ${DEVPROXY_INSTALL_DIR}/devproxy" -} - -detect_platform() { - OS="$(uname -s)" - ARCH="$(uname -m)" - - case "$OS" in - Darwin) OS_TARGET="apple-darwin" ;; - Linux) OS_TARGET="unknown-linux-gnu" ;; - *) echo "Error: unsupported operating system: $OS" >&2; exit 1 ;; - esac - - case "$ARCH" in - x86_64|amd64) ARCH_TARGET="x86_64" ;; - aarch64|arm64) ARCH_TARGET="aarch64" ;; - *) echo "Error: unsupported architecture: $ARCH" >&2; exit 1 ;; - esac - - TARGET="${ARCH_TARGET}-${OS_TARGET}" -} - -construct_url() { - BINARY_NAME="devproxy-${TARGET}" - if [ "$DEVPROXY_VERSION" = "latest" ]; then - DOWNLOAD_URL="${DEVPROXY_INSTALL_BASE_URL}/latest/download/${BINARY_NAME}" - else - DOWNLOAD_URL="${DEVPROXY_INSTALL_BASE_URL}/download/${DEVPROXY_VERSION}/${BINARY_NAME}" - fi -} - -create_install_dir() { - if [ ! -d "$DEVPROXY_INSTALL_DIR" ]; then - mkdir -p "$DEVPROXY_INSTALL_DIR" - fi -} - -download_binary() { - TMPFILE="$(mktemp)" - trap 'rm -f "$TMPFILE"' EXIT - - if command -v curl >/dev/null 2>&1; then - if ! curl -fsSL -o "$TMPFILE" "$DOWNLOAD_URL"; then - echo "Error: failed to download devproxy from ${DOWNLOAD_URL}" >&2 - exit 1 - fi - elif command -v wget >/dev/null 2>&1; then - if ! wget -q -O "$TMPFILE" "$DOWNLOAD_URL" 2>/dev/null; then - echo "Error: failed to download devproxy from ${DOWNLOAD_URL}" >&2 - exit 1 - fi - else - echo "Error: neither curl nor wget found. Please install one and try again." >&2 - exit 1 - fi - - mv "$TMPFILE" "${DEVPROXY_INSTALL_DIR}/devproxy" - trap - EXIT -} - -make_executable() { - chmod +x "${DEVPROXY_INSTALL_DIR}/devproxy" -} - -verify_installation() { - if [ ! -x "${DEVPROXY_INSTALL_DIR}/devproxy" ]; then - echo "Error: installation failed — binary not found at ${DEVPROXY_INSTALL_DIR}/devproxy" >&2 - exit 1 - fi -} - -main -``` - -**Step 2: Make it executable** - -```bash -chmod +x install.sh -``` - -**Step 3: Verify syntax** - -```bash -sh -n install.sh -``` - -Expected: No output (no syntax errors). - -**Step 4: Commit** - -```bash -git add install.sh -git commit -m "feat: add curl|sh install script for devproxy" -``` - ---- - -## Task 2: Create the test script - -**Files:** -- Create: `tests/test_install.sh` - -**Step 1: Write the test script** - -Create `tests/test_install.sh` with these test cases: - -1. **OS/arch detection** — For each of the 4 platform combos (Darwin/arm64, Darwin/x86_64, Linux/x86_64, Linux/aarch64), override `uname` with a wrapper script that returns the expected values, run the install script in a mode that exits after detection, and verify the correct target triple. - -2. **Unsupported platform error** — Override `uname` to return `FreeBSD`/`mips` and verify non-zero exit + error message on stderr. - -3. **URL construction** — For each platform, verify the constructed download URL matches the expected pattern including the base URL override. - -4. **Full install e2e** — Start a Python `http.server` serving a mock binary (a simple shell script that prints a version string), point `DEVPROXY_INSTALL_BASE_URL` at localhost, run the install script with `DEVPROXY_INSTALL_DIR` set to a temp dir, verify the binary exists, is executable, and produces expected output. Run a second time to verify idempotency. - -5. **Download failure (404)** — Point at the mock server but request a URL that doesn't exist, verify non-zero exit + error message. - -6. **Missing downloader** — Override `PATH` to exclude curl and wget, verify non-zero exit + error message about missing downloader. - -The test script structure: - -```sh -#!/bin/sh -set -eu - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -INSTALL_SCRIPT="$REPO_ROOT/install.sh" - -PASS=0 -FAIL=0 -TOTAL=0 - -pass() { - PASS=$((PASS + 1)) - TOTAL=$((TOTAL + 1)) - echo " PASS: $1" -} - -fail() { - FAIL=$((FAIL + 1)) - TOTAL=$((TOTAL + 1)) - echo " FAIL: $1" - if [ -n "${2:-}" ]; then - echo " $2" - fi -} - -cleanup() { - if [ -n "${MOCK_SERVER_PID:-}" ]; then - kill "$MOCK_SERVER_PID" 2>/dev/null || true - wait "$MOCK_SERVER_PID" 2>/dev/null || true - fi - if [ -n "${TMPDIR_ROOT:-}" ]; then - rm -rf "$TMPDIR_ROOT" - fi -} -trap cleanup EXIT - -TMPDIR_ROOT="$(mktemp -d)" - -# --- Helper: create a uname wrapper that returns custom OS/ARCH --- -make_uname_wrapper() { - _os="$1" - _arch="$2" - _dir="$TMPDIR_ROOT/uname-wrapper-${_os}-${_arch}" - mkdir -p "$_dir" - cat > "$_dir/uname" < "$_harness" - cat >> "$_harness" <<'HARNESS' -detect_platform -construct_url -echo "TARGET=$TARGET" -echo "DOWNLOAD_URL=$DOWNLOAD_URL" -HARNESS - PATH="$_uname_dir:$PATH" \ - DEVPROXY_INSTALL_BASE_URL="$_base_url" \ - DEVPROXY_VERSION="$_version" \ - sh "$_harness" 2>/dev/null - rm -f "$_harness" -} - -# ============================================================ -# Test 1: OS/arch detection — all 4 platform combos -# ============================================================ -echo "=== Test 1: OS/arch detection ===" - -for combo in "Darwin:arm64:aarch64-apple-darwin" \ - "Darwin:x86_64:x86_64-apple-darwin" \ - "Linux:x86_64:x86_64-unknown-linux-gnu" \ - "Linux:aarch64:aarch64-unknown-linux-gnu"; do - os="$(echo "$combo" | cut -d: -f1)" - arch="$(echo "$combo" | cut -d: -f2)" - expected="$(echo "$combo" | cut -d: -f3)" - - wrapper_dir="$(make_uname_wrapper "$os" "$arch")" - result="$(run_detection "$wrapper_dir" | grep '^TARGET=' | cut -d= -f2)" - - if [ "$result" = "$expected" ]; then - pass "$os/$arch -> $expected" - else - fail "$os/$arch -> expected $expected, got $result" - fi -done - -# ============================================================ -# Test 2: Unsupported platform error -# ============================================================ -echo "=== Test 2: Unsupported platform error ===" - -# Helper for unsupported-platform tests: runs the harness directly -# without suppressing stderr, so error messages can be captured. -run_detection_with_stderr() { - _uname_dir="$1" - _harness="$TMPDIR_ROOT/harness-unsup-$$.sh" - sed 's/^main$//' "$INSTALL_SCRIPT" > "$_harness" - cat >> "$_harness" <<'HARNESS' -detect_platform -HARNESS - PATH="$_uname_dir:$PATH" \ - DEVPROXY_INSTALL_BASE_URL="https://example.com" \ - DEVPROXY_VERSION="latest" \ - sh "$_harness" 2>&1 - _rc=$? - rm -f "$_harness" - return $_rc -} - -# Unsupported OS -wrapper_dir="$(make_uname_wrapper "FreeBSD" "x86_64")" -if output="$(run_detection_with_stderr "$wrapper_dir")"; then - fail "FreeBSD should fail but exited 0" -else - if echo "$output" | grep -qi "unsupported"; then - pass "FreeBSD rejected with error message" - else - fail "FreeBSD rejected but no 'unsupported' in message" "$output" - fi -fi - -# Unsupported arch -wrapper_dir="$(make_uname_wrapper "Linux" "mips")" -if output="$(run_detection_with_stderr "$wrapper_dir")"; then - fail "mips should fail but exited 0" -else - if echo "$output" | grep -qi "unsupported"; then - pass "mips rejected with error message" - else - fail "mips rejected but no 'unsupported' in message" "$output" - fi -fi - -# ============================================================ -# Test 3: URL construction -# ============================================================ -echo "=== Test 3: URL construction ===" - -BASE="https://example.com/releases" - -# Latest version -wrapper_dir="$(make_uname_wrapper "Darwin" "arm64")" -url="$(run_detection "$wrapper_dir" "$BASE" "latest" | grep '^DOWNLOAD_URL=' | cut -d= -f2-)" -expected_url="https://example.com/releases/latest/download/devproxy-aarch64-apple-darwin" -if [ "$url" = "$expected_url" ]; then - pass "latest URL for Darwin/arm64" -else - fail "latest URL: expected $expected_url, got $url" -fi - -# Specific version -url="$(run_detection "$wrapper_dir" "$BASE" "v1.0.0" | grep '^DOWNLOAD_URL=' | cut -d= -f2-)" -expected_url="https://example.com/releases/download/v1.0.0/devproxy-aarch64-apple-darwin" -if [ "$url" = "$expected_url" ]; then - pass "versioned URL for Darwin/arm64" -else - fail "versioned URL: expected $expected_url, got $url" -fi - -# Linux x86_64 -wrapper_dir="$(make_uname_wrapper "Linux" "x86_64")" -url="$(run_detection "$wrapper_dir" "$BASE" "latest" | grep '^DOWNLOAD_URL=' | cut -d= -f2-)" -expected_url="https://example.com/releases/latest/download/devproxy-x86_64-unknown-linux-gnu" -if [ "$url" = "$expected_url" ]; then - pass "latest URL for Linux/x86_64" -else - fail "latest URL: expected $expected_url, got $url" -fi - -# ============================================================ -# Test 4: Full install e2e with mock server -# ============================================================ -echo "=== Test 4: Full install e2e ===" - -# Set up mock server directory structure -MOCK_DIR="$TMPDIR_ROOT/mock-server" -mkdir -p "$MOCK_DIR/latest/download" - -# Create a mock binary (shell script that echoes version) -# Determine the current platform's target triple for the mock binary filename -_mock_arch="$(uname -m | sed 's/arm64/aarch64/')" -case "$(uname -s)" in - Darwin) _mock_os="apple-darwin" ;; - Linux) _mock_os="unknown-linux-gnu" ;; -esac -MOCK_BINARY="$MOCK_DIR/latest/download/devproxy-${_mock_arch}-${_mock_os}" -cat > "$MOCK_BINARY" <<'MOCKBIN' -#!/bin/sh -echo "devproxy mock 0.0.1-test" -MOCKBIN -chmod +x "$MOCK_BINARY" - -# Start mock HTTP server -MOCK_PORT=0 -# Find a free port -MOCK_PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()") -cd "$MOCK_DIR" -python3 -m http.server "$MOCK_PORT" >/dev/null 2>&1 & -MOCK_SERVER_PID=$! -cd "$REPO_ROOT" -# Give the server a moment to start -sleep 1 - -INSTALL_DIR="$TMPDIR_ROOT/install-target" -mkdir -p "$INSTALL_DIR" - -# Run install -if DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}" \ - DEVPROXY_INSTALL_DIR="$INSTALL_DIR" \ - sh "$INSTALL_SCRIPT" >/dev/null 2>&1; then - # Check binary exists and is executable - if [ -x "$INSTALL_DIR/devproxy" ]; then - pass "binary installed and executable" - else - fail "binary not found or not executable at $INSTALL_DIR/devproxy" - fi - - # Check binary works - mock_output="$("$INSTALL_DIR/devproxy" 2>&1 || true)" - if echo "$mock_output" | grep -q "devproxy mock"; then - pass "installed binary produces expected output" - else - fail "binary output unexpected" "$mock_output" - fi - - # Idempotency: run again - if DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}" \ - DEVPROXY_INSTALL_DIR="$INSTALL_DIR" \ - sh "$INSTALL_SCRIPT" >/dev/null 2>&1; then - pass "idempotent reinstall succeeds" - else - fail "idempotent reinstall failed" - fi -else - fail "install script failed" -fi - -# ============================================================ -# Test 5: Download failure (404) -# ============================================================ -echo "=== Test 5: Download failure (404) ===" - -INSTALL_DIR_404="$TMPDIR_ROOT/install-404" -mkdir -p "$INSTALL_DIR_404" - -# Point at a path that doesn't exist on the mock server -wrapper_dir="$(make_uname_wrapper "Linux" "aarch64")" -if output="$(PATH="$wrapper_dir:$PATH" \ - DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}/nonexistent" \ - DEVPROXY_INSTALL_DIR="$INSTALL_DIR_404" \ - sh "$INSTALL_SCRIPT" 2>&1)"; then - fail "404 should cause non-zero exit" -else - if echo "$output" | grep -qi "error\|fail"; then - pass "404 produces error message" - else - fail "404 exited non-zero but no error in output" "$output" - fi -fi - -# ============================================================ -# Test 6: Missing downloader -# ============================================================ -echo "=== Test 6: Missing downloader ===" - -INSTALL_DIR_NODL="$TMPDIR_ROOT/install-nodl" -mkdir -p "$INSTALL_DIR_NODL" - -# Create a minimal PATH with only essential commands but no curl/wget -MINIMAL_BIN="$TMPDIR_ROOT/minimal-bin" -mkdir -p "$MINIMAL_BIN" -# Link only the essentials the script needs (sh, uname, mktemp, chmod, mkdir, etc.) -for cmd in sh uname mktemp chmod mkdir mv rm cat sed grep printf echo test tr cut; do - cmd_path="$(command -v "$cmd" 2>/dev/null || true)" - if [ -n "$cmd_path" ]; then - ln -sf "$cmd_path" "$MINIMAL_BIN/$cmd" 2>/dev/null || true - fi -done -# Also need [ for test -if [ -f /bin/[ ]; then - ln -sf /bin/[ "$MINIMAL_BIN/[" 2>/dev/null || true -fi -# Need env and python3 is not needed here -ln -sf "$(command -v env)" "$MINIMAL_BIN/env" 2>/dev/null || true - -if output="$(PATH="$MINIMAL_BIN" \ - DEVPROXY_INSTALL_BASE_URL="http://localhost:${MOCK_PORT}" \ - DEVPROXY_INSTALL_DIR="$INSTALL_DIR_NODL" \ - sh "$INSTALL_SCRIPT" 2>&1)"; then - fail "missing downloader should cause non-zero exit" -else - if echo "$output" | grep -qi "curl\|wget"; then - pass "missing downloader error mentions curl/wget" - else - fail "missing downloader exited non-zero but no curl/wget mention" "$output" - fi -fi - -# ============================================================ -# Summary -# ============================================================ -echo "" -echo "============================================================" -echo "Results: $PASS passed, $FAIL failed, $TOTAL total" -echo "============================================================" - -if [ "$FAIL" -gt 0 ]; then - exit 1 -fi -``` - -**Step 2: Make it executable** - -```bash -chmod +x tests/test_install.sh -``` - -**Step 3: Run the tests** - -```bash -sh tests/test_install.sh -``` - -Expected: All tests pass (the e2e test uses the real platform's uname for the mock binary name). - -**Step 4: Commit** - -```bash -git add tests/test_install.sh -git commit -m "test: add shell-based install script test suite" -``` - ---- - -## Task 3: Add just recipe and update README - -**Files:** -- Modify: `justfile` -- Modify: `README.md` - -**Step 1: Add `test-install` recipe to justfile** - -Add after the `e2e` recipe: - -```just -# Run install script tests -test-install: - sh tests/test_install.sh -``` - -**Step 2: Update README with install instructions** - -Add an "Install" section before "Quick Start" with: - -```markdown -## Install - -```bash -curl -fsSL https://raw.githubusercontent.com/foundra-build/devproxy/main/install.sh | sh -``` -``` - -**Step 3: Verify just recipe works** - -```bash -just test-install -``` - -Expected: All tests pass. - -**Step 4: Commit** - -```bash -git add justfile README.md -git commit -m "docs: add install command to README and test-install just recipe" -``` - ---- - -## Task 4: Final verification - -**Step 1: Run full test suite** - -```bash -just test-install -``` - -Expected: All 6 test categories pass, output shows pass/fail summary. - -**Step 2: Run existing project checks** - -```bash -just check -``` - -Expected: Existing clippy + tests still pass (no Rust code changed). - -**Step 3: Verify install script syntax on sh** - -```bash -sh -n install.sh -``` - -Expected: No errors.