diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-06-03 22:56:24 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-06-03 22:56:24 +0000 |
| commit | b28cfc6ff288d083f6c8e9c055b69bfcadbceccc (patch) | |
| tree | fb5e16fa588494bb400126a3ea774ae7bd34be1a | |
| parent | 476a3136a9f55e151ae689cd098795ec865e7850 (diff) | |
feat: add --base-path flag so UI routes API calls to correct prefix
Allows running multiple instances (e.g. /claudomator and /claudomator-oss)
behind a reverse proxy. The server rewrites the base-path meta tag in
index.html at request time rather than baking it into the embed.
Also adds --branch support to deploy script for isolated branch builds
via git worktrees.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | internal/api/server.go | 36 | ||||
| -rw-r--r-- | internal/cli/serve.go | 7 | ||||
| -rwxr-xr-x | scripts/deploy | 124 |
3 files changed, 126 insertions, 41 deletions
diff --git a/internal/api/server.go b/internal/api/server.go index 2dcbf77..afb80ad 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,9 +1,11 @@ package api import ( + "bytes" "context" "encoding/json" "fmt" + "io/fs" "log/slog" "net/http" "os" @@ -60,6 +62,7 @@ type Server struct { pushStore pushSubscriptionStore dropsDir string llm *llm.Client + basePath string // URL prefix the UI is mounted at, e.g. "/claudomator" } // SetAPIToken configures a bearer token that must be supplied to access the API. @@ -89,6 +92,12 @@ func (s *Server) SetWorkspaceRoot(path string) { s.workspaceRoot = path } +// SetBasePath sets the URL prefix the UI is served under (e.g. "/claudomator-oss"). +// The server rewrites the base-path meta tag in index.html at request time. +func (s *Server) SetBasePath(p string) { + s.basePath = p +} + // Pool returns the executor pool, for graceful shutdown by the caller. func (s *Server) Pool() *executor.Pool { return s.pool } @@ -180,7 +189,7 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/drops", s.handleListDrops) s.mux.HandleFunc("GET /api/drops/{filename}", s.handleGetDrop) s.mux.HandleFunc("POST /api/drops", s.handlePostDrop) - s.mux.Handle("GET /", http.FileServerFS(webui.Files)) + s.mux.HandleFunc("GET /", s.handleStaticFiles) } // forwardResults listens on the executor pool's result and started channels and broadcasts via WebSocket. @@ -777,6 +786,31 @@ func (s *Server) handleGetExecution(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, exec) } +// handleStaticFiles serves embedded web UI files. For index.html it rewrites the +// base-path meta tag so the UI knows which API prefix to use. +func (s *Server) handleStaticFiles(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + raw, err := fs.ReadFile(webui.Files, "index.html") + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + basePath := s.basePath + if basePath == "" { + basePath = "/claudomator" + } + rewritten := bytes.ReplaceAll(raw, + []byte(`content="/claudomator"`), + []byte(`content="`+basePath+`"`), + ) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(rewritten) + return + } + http.FileServerFS(webui.Files).ServeHTTP(w, r) +} + func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index dae66d6..ae42780 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -21,6 +21,7 @@ import ( func newServeCmd() *cobra.Command { var addr string var workspaceRoot string + var basePath string cmd := &cobra.Command{ Use: "serve", @@ -29,11 +30,12 @@ func newServeCmd() *cobra.Command { if cmd.Flags().Changed("workspace-root") { cfg.WorkspaceRoot = workspaceRoot } - return serve(addr) + return serve(addr, basePath) }, } cmd.Flags().StringVar(&addr, "addr", ":8484", "listen address") + cmd.Flags().StringVar(&basePath, "base-path", "/claudomator", "URL prefix the UI is served under (e.g. /claudomator-oss)") cmd.Flags().StringVar(&workspaceRoot, "workspace-root", "/workspace", "root directory for listing workspaces") cmd.Flags().StringVar(&cfg.ClaudeImage, "claude-image", cfg.ClaudeImage, "docker image for claude agents") cmd.Flags().StringVar(&cfg.GeminiImage, "gemini-image", cfg.GeminiImage, "docker image for gemini agents") @@ -41,7 +43,7 @@ func newServeCmd() *cobra.Command { return cmd } -func serve(addr string) error { +func serve(addr, basePath string) error { if err := cfg.EnsureDirs(); err != nil { return fmt.Errorf("creating dirs: %w", err) } @@ -153,6 +155,7 @@ func serve(addr string) error { pool.RecoverStaleBlocked() srv := api.NewServer(store, pool, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath) + srv.SetBasePath(basePath) // Configure notifiers: combine webhook (if set) with web push. notifiers := []notify.Notifier{} diff --git a/scripts/deploy b/scripts/deploy index 2161535..3ee0bae 100755 --- a/scripts/deploy +++ b/scripts/deploy @@ -1,66 +1,114 @@ #!/bin/bash # deploy — Build and deploy claudomator to /site/doot.terst.org -# Usage: ./scripts/deploy [--dirty] +# Usage: ./scripts/deploy [--dirty] [--branch <name>] +# --branch <name> Deploy a named local branch as claudomator-<name> (e.g. oss) +# --dirty Skip stash check (main deploy only) # Example: sudo ./scripts/deploy +# sudo ./scripts/deploy --branch oss set -euo pipefail DIRTY=false -for arg in "$@"; do - if [ "$arg" == "--dirty" ]; then - DIRTY=true - fi +BRANCH="" +while [[ $# -gt 0 ]]; do + case "$1" in + --dirty) DIRTY=true; shift ;; + --branch) BRANCH="$2"; shift 2 ;; + --branch=*) BRANCH="${1#--branch=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac done FQDN="doot.terst.org" SITE_DIR="/site/${FQDN}" BIN_DIR="${SITE_DIR}/bin" -SERVICE="claudomator@${FQDN}.service" REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" -cd "${REPO_DIR}" +export GOCACHE="${SITE_DIR}/cache/go-build" +export GOPATH="${SITE_DIR}/cache/gopath" +mkdir -p "${GOCACHE}" "${GOPATH}" -echo "==> Pulling latest from bare repo..." -git pull --ff-only local main +if [ -n "$BRANCH" ]; then + # ── Branch deploy ────────────────────────────────────────────────────────── + # Builds a named branch in an isolated git worktree so the main checkout is + # undisturbed. The resulting binary is deployed as claudomator-<branch> and + # managed by the claudomator-<branch>@<fqdn> service. + BINARY_NAME="claudomator-${BRANCH}" + SERVICE="claudomator-${BRANCH}@${FQDN}.service" + WORKTREE="/tmp/claudomator-deploy-${BRANCH}" -STASHED=false -if [ "$DIRTY" = false ] && [ -n "$(git status --porcelain)" ]; then - echo "==> Stashing uncommitted changes..." - git stash push -u -m "Auto-stash before deploy" - STASHED=true - trap 'if [ "$STASHED" = true ]; then echo "==> Popping stash..."; git stash pop; fi' EXIT -fi + cleanup() { git -C "${REPO_DIR}" worktree remove "${WORKTREE}" --force 2>/dev/null || true; } + trap cleanup EXIT -echo "==> Verifying (build + tests)..." -"${REPO_DIR}/scripts/verify" + cd "${REPO_DIR}" -echo "==> Building claudomator..." -export GOCACHE="${SITE_DIR}/cache/go-build" -export GOPATH="${SITE_DIR}/cache/gopath" -mkdir -p "${GOCACHE}" "${GOPATH}" -go build -o "${BIN_DIR}/claudomator" ./cmd/claudomator/ + echo "==> Fetching all remotes..." + git fetch --all 2>/dev/null || git fetch local 2>/dev/null || true + + echo "==> Setting up worktree for ${BRANCH}..." + git worktree remove "${WORKTREE}" --force 2>/dev/null || true + git worktree add "${WORKTREE}" "${BRANCH}" + + echo "==> Fast-forwarding ${BRANCH} to tracking remote..." + git -C "${WORKTREE}" merge --ff-only "@{u}" 2>/dev/null || echo " (no upstream or already current)" + + echo "==> Verifying ${BRANCH} (build + tests)..." + (cd "${WORKTREE}" && go build ./... && go test -race ./...) + echo "==> All checks passed." -echo "==> Copying scripts..." -mkdir -p "${SITE_DIR}/scripts" -find "${REPO_DIR}/scripts" -maxdepth 1 -type f -exec cp -p {} "${SITE_DIR}/scripts/" \; + echo "==> Building ${BINARY_NAME}..." + (cd "${WORKTREE}" && go build -o "${BIN_DIR}/${BINARY_NAME}" ./cmd/claudomator/) -echo "==> Installing to /usr/local/bin..." -install -m 755 "${BIN_DIR}/claudomator" /usr/local/bin/claudomator +else + # ── Main deploy ──────────────────────────────────────────────────────────── + BINARY_NAME="claudomator" + SERVICE="claudomator@${FQDN}.service" -echo "==> Verifying system CLI version..." -/usr/local/bin/claudomator version + cd "${REPO_DIR}" -echo "==> Fixing permissions..." -"${REPO_DIR}/scripts/fix-permissions" + echo "==> Pulling latest from bare repo..." + git pull --ff-only local main -echo "==> Syncing credentials..." -"${REPO_DIR}/scripts/sync-credentials" + STASHED=false + if [ "$DIRTY" = false ] && [ -n "$(git status --porcelain)" ]; then + echo "==> Stashing uncommitted changes..." + git stash push -u -m "Auto-stash before deploy" + STASHED=true + trap 'if [ "$STASHED" = true ]; then echo "==> Popping stash..."; git stash pop; fi' EXIT + fi + + echo "==> Verifying (build + tests)..." + "${REPO_DIR}/scripts/verify" + + echo "==> Building claudomator..." + go build -o "${BIN_DIR}/claudomator" ./cmd/claudomator/ + + echo "==> Copying scripts..." + mkdir -p "${SITE_DIR}/scripts" + find "${REPO_DIR}/scripts" -maxdepth 1 -type f -exec cp -p {} "${SITE_DIR}/scripts/" \; + + echo "==> Installing to /usr/local/bin..." + install -m 755 "${BIN_DIR}/claudomator" /usr/local/bin/claudomator + + echo "==> Verifying system CLI version..." + /usr/local/bin/claudomator version + + echo "==> Fixing permissions..." + "${REPO_DIR}/scripts/fix-permissions" + + echo "==> Syncing credentials..." + "${REPO_DIR}/scripts/sync-credentials" + + echo "==> Ensuring binary and scripts are executable..." + chmod +x "${BIN_DIR}/claudomator" /usr/local/bin/claudomator + find "${SITE_DIR}/scripts" -maxdepth 1 -type f -exec chmod +x {} + +fi -echo "==> Ensuring binary and scripts are executable..." -chmod +x "${BIN_DIR}/claudomator" /usr/local/bin/claudomator -find "${SITE_DIR}/scripts" -maxdepth 1 -type f -exec chmod +x {} + +echo "==> Ensuring ${BINARY_NAME} is executable..." +chmod +x "${BIN_DIR}/${BINARY_NAME}" +chown www-data:www-data "${BIN_DIR}/${BINARY_NAME}" -echo "==> Restarting service..." +echo "==> Restarting ${SERVICE}..." sudo systemctl restart "${SERVICE}" sudo systemctl status "${SERVICE}" --no-pager -l |
