diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..49f0694 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.DS_Store +.superpowers +tmp +*.log +*.bak.* +web/node_modules +web/dist +dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c15d6d0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-alpine AS web-build +WORKDIR /src/web +RUN corepack enable && corepack prepare pnpm@10.30.3 --activate +COPY web/package.json web/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY web/ ./ +RUN pnpm build + +FROM golang:1.24-alpine AS go-build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ +ARG TARGETOS=linux +ARG TARGETARCH +RUN if [ -n "$TARGETARCH" ]; then export GOARCH=$TARGETARCH; fi; \ + CGO_ENABLED=0 GOOS=$TARGETOS go build -o /out/codex-agent-manager ./cmd/codex-agent-manager + +FROM alpine:3.22 +WORKDIR /app +COPY --from=go-build /out/codex-agent-manager /usr/local/bin/codex-agent-manager +COPY --from=web-build /src/web/dist ./web/dist +COPY README.md agent.md task_plan.md findings.md progress.md ./ +COPY docs/ ./docs/ + +ENV CODEX_HOME=/codex-home +ENV HTTP_ADDR=0.0.0.0:18083 +ENV STATIC_DIR=/app/web/dist +ENV WORKSPACE_ROOT=/app + +EXPOSE 18083 +ENTRYPOINT ["codex-agent-manager"] diff --git a/README.md b/README.md index de1eeaf..ac9892d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,55 @@ pnpm dev 打开 `http://127.0.0.1:13083/` 查看工作台。 +## Docker 启动 + +构建镜像: + +```bash +docker build -t codex-agent-manager:local . +``` + +启动容器,并把本机 Codex 配置和当前项目目录挂载进去: + +```bash +docker run --rm \ + --name codex-agent-manager \ + -p 0.0.0.0:18083:18083 \ + -e CODEX_HOME=/codex-home \ + -e WORKSPACE_ROOT=/Users/yoilun/Code/codex-agent-manager \ + -v /Users/yoilun/.codex:/codex-home \ + -v /Users/yoilun/Code/codex-agent-manager:/Users/yoilun/Code/codex-agent-manager:ro \ + codex-agent-manager:local +``` + +然后打开 `http://127.0.0.1:18083/`。Docker 镜像内由 Go 后端同时提供 API 和前端静态页面,不需要再单独启动 Vite。 + +如果要让局域网内其他设备访问,保持上面的 `0.0.0.0:18083:18083` 端口映射,然后在同一局域网的设备上打开: + +```text +http://你的电脑局域网IP:18083/ +``` + +在 macOS 上可用下面命令查看当前 Wi-Fi/LAN IP: + +```bash +ipconfig getifaddr en0 +``` + +也可以使用 Compose: + +```bash +docker compose up --build +``` + +如需改挂载路径,可在启动前设置本机环境变量: + +```bash +CODEX_HOME=/path/to/.codex \ +CODEX_AGENT_MANAGER_PROJECT=/path/to/codex-agent-manager \ +docker compose up --build +``` + ## 验证命令 后端和整体检查: diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..60049a3 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,14 @@ +services: + codex-agent-manager: + build: . + image: codex-agent-manager:local + ports: + - "0.0.0.0:18083:18083" + environment: + CODEX_HOME: /codex-home + HTTP_ADDR: 0.0.0.0:18083 + STATIC_DIR: /app/web/dist + WORKSPACE_ROOT: /Users/yoilun/Code/codex-agent-manager + volumes: + - ${CODEX_HOME:-/Users/yoilun/.codex}:/codex-home + - ${CODEX_AGENT_MANAGER_PROJECT:-/Users/yoilun/Code/codex-agent-manager}:/Users/yoilun/Code/codex-agent-manager:ro diff --git a/internal/app/config.go b/internal/app/config.go index 2144b69..afc6536 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -9,14 +9,19 @@ type Config struct { CodexHome string HTTPAddr string WorkspaceRoot string + StaticDir string } func DefaultConfig() Config { + httpAddr := envOrDefault("HTTP_ADDR", "127.0.0.1:18083") + staticDir := os.Getenv("STATIC_DIR") + workspaceRoot := envOrDefault("WORKSPACE_ROOT", ".") if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" { return Config{ CodexHome: codexHome, - HTTPAddr: "127.0.0.1:18083", - WorkspaceRoot: ".", + HTTPAddr: httpAddr, + WorkspaceRoot: workspaceRoot, + StaticDir: staticDir, } } home, err := os.UserHomeDir() @@ -25,7 +30,15 @@ func DefaultConfig() Config { } return Config{ CodexHome: filepath.Join(home, ".codex"), - HTTPAddr: "127.0.0.1:18083", - WorkspaceRoot: ".", + HTTPAddr: httpAddr, + WorkspaceRoot: workspaceRoot, + StaticDir: staticDir, } } + +func envOrDefault(name string, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/internal/app/config_test.go b/internal/app/config_test.go index 5021c9c..58dedfd 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -17,6 +17,24 @@ func TestDefaultConfigUsesCODEXHomeOverride(t *testing.T) { } } +func TestDefaultConfigUsesDockerRuntimeOverrides(t *testing.T) { + t.Setenv("HTTP_ADDR", "0.0.0.0:18083") + t.Setenv("STATIC_DIR", "/app/web/dist") + t.Setenv("WORKSPACE_ROOT", "/app") + + cfg := DefaultConfig() + + if cfg.HTTPAddr != "0.0.0.0:18083" { + t.Fatalf("HTTPAddr = %q, want %q", cfg.HTTPAddr, "0.0.0.0:18083") + } + if cfg.StaticDir != "/app/web/dist" { + t.Fatalf("StaticDir = %q, want %q", cfg.StaticDir, "/app/web/dist") + } + if cfg.WorkspaceRoot != "/app" { + t.Fatalf("WorkspaceRoot = %q, want %q", cfg.WorkspaceRoot, "/app") + } +} + func TestDefaultConfigFallsBackToUserCodexHome(t *testing.T) { t.Setenv("CODEX_HOME", "") home, err := os.UserHomeDir() diff --git a/internal/server/server.go b/internal/server/server.go index 599b5f5..ce548c2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,6 +6,8 @@ import ( "io" "net/http" "os" + "path" + "path/filepath" "strings" "codex-agent-manager/internal/agents" @@ -110,6 +112,10 @@ func New(cfg app.Config) http.Handler { "source": view.Source, }) }) + mux.HandleFunc("/api/", http.NotFound) + if cfg.StaticDir != "" { + mux.HandleFunc("/", staticFrontendHandler(cfg.StaticDir)) + } return mux } @@ -211,3 +217,25 @@ func writeJSON(w http.ResponseWriter, status int, body any) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } + +func staticFrontendHandler(staticDir string) http.HandlerFunc { + fileServer := http.FileServer(http.Dir(staticDir)) + indexPath := filepath.Join(staticDir, "index.html") + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) + return + } + cleanPath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") + if cleanPath == "" { + http.ServeFile(w, r, indexPath) + return + } + target := filepath.Join(staticDir, cleanPath) + if info, err := os.Stat(target); err == nil && !info.IsDir() { + fileServer.ServeHTTP(w, r) + return + } + http.ServeFile(w, r, indexPath) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 5489fec..5d4aa8f 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -202,6 +202,60 @@ func TestReadOnlyEndpointsRejectUnsupportedMethods(t *testing.T) { } } +func TestStaticFrontendServesIndexAndAssets(t *testing.T) { + root := t.TempDir() + staticDir := filepath.Join(root, "dist") + if err := os.MkdirAll(filepath.Join(staticDir, "assets"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte(`管理台`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(staticDir, "assets", "app.js"), []byte(`console.log("ok")`), 0o644); err != nil { + t.Fatal(err) + } + handler := New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0", StaticDir: staticDir}) + + for _, path := range []string{"/", "/workflow"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("%s status = %d, body = %s", path, rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "管理台") { + t.Fatalf("%s did not serve index.html: %s", path, rec.Body.String()) + } + } + + req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `console.log("ok")`) { + t.Fatalf("asset status = %d, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestStaticFrontendDoesNotMaskMissingAPIRoutes(t *testing.T) { + root := t.TempDir() + staticDir := filepath.Join(root, "dist") + if err := os.MkdirAll(staticDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte(`index`), 0o644); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/missing", nil) + rec := httptest.NewRecorder() + New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0", StaticDir: staticDir}).ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d, body = %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } +} + func TestAgentValidateEndpointReturnsDiffAndRejectsUnsupportedMethods(t *testing.T) { root := t.TempDir() agentsDir := filepath.Join(root, "agents") diff --git a/web/package.json b/web/package.json index 0ae3655..b24b924 100644 --- a/web/package.json +++ b/web/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "type": "module", + "packageManager": "pnpm@10.30.3", "scripts": { "dev": "vite --host 127.0.0.1 --port 13083", "build": "vite build",