#!/usr/bin/env bash # Smoke-test a freshly-built opencode-devbox image. # # Verifies: # - Core binaries are on PATH and runnable # - opencode itself starts and prints a version # - Entrypoint runs cleanly as non-root after UID adjustment # - Generated opencode.json has the expected shape # - MCP wrapper works (when mempalace is installed) # # Usage: ./scripts/smoke-test.sh [--variant base|omos] # # Exit codes: # 0 all checks passed # 1 one or more checks failed set -euo pipefail IMAGE="${1:-}" VARIANT="base" if [ "${2:-}" = "--variant" ]; then VARIANT="${3:-base}" fi if [ -z "$IMAGE" ]; then echo "usage: $0 [--variant base|omos]" >&2 exit 2 fi FAILED=0 pass() { echo " ✓ $1"; } fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); } run() { # Run a command inside the image and capture its output. # First arg is a label, rest is the shell command. local label="$1"; shift local out if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$*" 2>&1); then pass "$label ($(echo "$out" | head -1))" else fail "$label: $out" fi } echo "=== Smoke test: $IMAGE (variant: $VARIANT) ===" echo echo "-- Resolved component versions --" # Prints the actual version of every floating component so CI logs # always record what got baked into this image, even when Dockerfile # ARGs default to "latest". docker run --rm --entrypoint="" "$IMAGE" sh -c ' printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)" printf " %-15s %s\n" "node" "$(node --version)" printf " %-15s %s\n" "npm" "$(npm --version)" printf " %-15s %s\n" "nvim" "$(nvim --version | head -1)" printf " %-15s %s\n" "bat" "$(bat --version)" printf " %-15s %s\n" "eza" "$(eza --version | head -2 | tail -1)" printf " %-15s %s\n" "zoxide" "$(zoxide --version)" printf " %-15s %s\n" "uv" "$(uv --version)" printf " %-15s %s\n" "fzf" "$(fzf --version)" printf " %-15s %s\n" "fd" "$(fd --version)" printf " %-15s %s\n" "rg" "$(rg --version | head -1)" printf " %-15s %s\n" "gosu" "$(gosu --version)" printf " %-15s %s\n" "git-lfs" "$(git-lfs --version)" printf " %-15s %s\n" "gitea-mcp" "$(gitea-mcp --version 2>&1 | head -1)" printf " %-15s %s\n" "aws" "$(aws --version 2>&1)" if command -v bun >/dev/null 2>&1; then printf " %-15s %s\n" "bun" "$(bun --version)" fi if command -v mempalace >/dev/null 2>&1; then printf " %-15s %s\n" "mempalace" "$(mempalace --version 2>&1 | head -1 || echo installed)" fi ' echo echo "-- Core binaries --" run "opencode" "opencode --version" run "node" "node --version" run "npm" "npm --version" run "git" "git --version" run "nvim" "nvim --version | head -1" run "bat" "bat --version" run "eza" "eza --version | head -1" run "zoxide" "zoxide --version" run "uv" "uv --version" run "uvx" "uvx --version" run "rustup-init" "rustup-init --version" run "fzf" "fzf --version" run "fd" "fd --version" run "rg" "rg --version | head -1" run "jq" "jq --version" run "aws" "aws --version" run "gitea-mcp" "gitea-mcp --version" run "gosu" "gosu --version" run "tmux" "tmux -V" echo echo "-- Optional / variant-gated --" # mempalace: present unless built with INSTALL_MEMPALACE=false if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then run "mempalace" "mempalace --help | head -1" run "mempalace-mcp" "test -x /usr/local/bin/mempalace-mcp && readlink /usr/local/bin/mempalace-mcp" else echo " - mempalace not installed (INSTALL_MEMPALACE=false)" fi # bun: only in the omos variant if [ "$VARIANT" = "omos" ]; then run "bun (omos)" "bun --version" run "bunx symlink (omos)" "test -L /usr/local/bin/bunx && readlink /usr/local/bin/bunx" # oh-my-opencode-slim is npm-installed globally (not a bun install); # verify it shows up in the global module list. run "oh-my-opencode-slim" "npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim" else if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v bun" >/dev/null 2>&1; then fail "bun should NOT be in base image but was found" else pass "bun correctly absent from base image" fi fi echo echo "-- Entrypoint behaviour --" # Generate-config script exists and has valid syntax. run "generate-config.py exists" \ "test -x /usr/local/lib/opencode-devbox/generate-config.py && python3 -m py_compile /usr/local/lib/opencode-devbox/generate-config.py && echo ok" # Entrypoint drops to developer user and runs a trivial command. # Writes the result to a file inside the container so we don't have to # disentangle entrypoint log output from command stdout on the host. label="entrypoint drops to developer" tmpout=$(mktemp) if docker run --rm -e OPENCODE_PROVIDER= "$IMAGE" \ sh -c 'whoami > /tmp/who && cat /tmp/who' > "$tmpout" 2>/dev/null; then # The last line of stdout is the whoami output. Entrypoint log lines # (MemPalace init, "Adjusted developer UID", etc.) go to stderr or # get printed before our sh command runs. actual=$(tail -1 "$tmpout" | tr -d '[:space:]') if [ "$actual" = "developer" ]; then pass "$label" else fail "$label: expected 'developer', got '$actual' (full output: $(cat "$tmpout"))" fi else fail "$label: container failed" fi rm -f "$tmpout" # Config generation with anthropic provider writes valid JSON with the # expected shape. The script's log message goes to stderr (line 1 of # generate-config.py uses file=sys.stderr) so capturing only stdout # gives us clean JSON. label="generate-config produces valid opencode.json" tmp=$(mktemp -d) if docker run --rm \ -e OPENCODE_PROVIDER=anthropic \ -e HOME=/tmp/home \ --entrypoint="" \ "$IMAGE" sh -c ' mkdir -p /tmp/home python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null cat /tmp/home/.config/opencode/opencode.json ' > "$tmp/out.json" 2>/dev/null; then if python3 -c " import json, sys c = json.load(open('$tmp/out.json')) assert c['model'].startswith('anthropic/'), c assert c['autoupdate'] is False assert c['share'] == 'disabled' " 2>&1; then pass "$label" else fail "$label: output doesn't match expected shape: $(cat "$tmp/out.json")" fi else fail "$label: container failed: $(cat "$tmp/out.json")" fi # Config generation is idempotent — running twice must not overwrite. label="generate-config never overwrites existing config" if docker run --rm \ -e OPENCODE_PROVIDER=anthropic \ -e HOME=/tmp/home \ --entrypoint="" \ "$IMAGE" sh -c ' mkdir -p /tmp/home/.config/opencode echo "{\"sentinel\": \"user-config\"}" > /tmp/home/.config/opencode/opencode.json python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null cat /tmp/home/.config/opencode/opencode.json ' 2>/dev/null | grep -q '"sentinel": "user-config"'; then pass "$label" else fail "$label: existing config was modified!" fi rm -rf "$tmp" echo echo "-- Image size --" SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE") SIZE_MB=$((SIZE_BYTES / 1024 / 1024)) echo " Uncompressed size: ${SIZE_MB} MB" # Thresholds (uncompressed): base 2500 MB, omos 3000 MB. Adjust as image content evolves. THRESHOLD=2500 [ "$VARIANT" = "omos" ] && THRESHOLD=3000 if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT" else pass "image size ${SIZE_MB} MB within threshold ${THRESHOLD} MB" fi echo if [ "$FAILED" -gt 0 ]; then echo "=== FAILED: $FAILED check(s) ===" >&2 exit 1 fi echo "=== PASSED ==="