diff --git a/packages/cli/internal/ai/ai.go b/packages/cli/internal/ai/ai.go index 83852b3..6fc9b57 100644 --- a/packages/cli/internal/ai/ai.go +++ b/packages/cli/internal/ai/ai.go @@ -107,13 +107,14 @@ func tryRefresh(projectRoot string, force bool) (RefreshResult, error) { generated := make([]string, 0, len(providers)) for _, p := range providers { + filename := guideFilename(p) sections := collectSections(subprojects, p) - content := renderGuide(p, sections, projectRoot) - path := filepath.Join(projectRoot, guideFilename(p)) + content := renderGuide(p, sections) + path := filepath.Join(projectRoot, filename) if _, err := writeManagedGuide(path, content, force, false); err != nil { return RefreshResult{}, err } - generated = append(generated, path) + generated = append(generated, filename) } return RefreshResult{ @@ -227,7 +228,7 @@ func renderFallbackGuide(templateID string) string { }, "\n") } -func renderGuide(p Provider, sections []section, projectRoot string) string { +func renderGuide(p Provider, sections []section) string { filename := guideFilename(p) label := providerLabel(p) var b strings.Builder @@ -235,7 +236,7 @@ func renderGuide(p Provider, sections []section, projectRoot string) string { b.WriteString("# " + label + " 工作区 AI 指南\n\n") b.WriteString("本段内容由 One CLI 基于项目模板为 `" + filename + "` 自动生成。请优先修改模板 AI 片段,或通过 `one add` 刷新;不要直接手改这段受管内容。\n\n") b.WriteString("## 工作区\n\n") - b.WriteString("- 根目录:`" + projectRoot + "`\n") + b.WriteString("- 根目录:当前包含 `one.manifest.json` 的工作区目录\n") b.WriteString("- AI 提供方:`" + string(p) + "`\n") b.WriteString(fmt.Sprintf("- 模板分组数:%d\n", len(sections))) for _, s := range sections { diff --git a/packages/cli/internal/ai/ai_test.go b/packages/cli/internal/ai/ai_test.go new file mode 100644 index 0000000..1849273 --- /dev/null +++ b/packages/cli/internal/ai/ai_test.go @@ -0,0 +1,55 @@ +package ai + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/workspace" +) + +func TestRefreshDoesNotLeakWorkspaceAbsolutePath(t *testing.T) { + root := t.TempDir() + if err := workspace.WriteManifest(root, &workspace.Manifest{ + Version: workspace.ManifestVersion, + Projects: []workspace.ManifestProject{}, + }); err != nil { + t.Fatalf("WriteManifest: %v", err) + } + serviceDir := filepath.Join(root, "services", "api") + if err := os.MkdirAll(serviceDir, 0o755); err != nil { + t.Fatalf("mkdir service: %v", err) + } + if err := os.WriteFile(filepath.Join(serviceDir, "go.mod"), []byte("module example.com/api\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + + res := Refresh(root, false) + if res.Status != "completed" { + t.Fatalf("Refresh status = %q, error = %+v", res.Status, res.ErrorBody) + } + wantFiles := []string{"AGENTS.md", "CLAUDE.md"} + if !reflect.DeepEqual(res.GeneratedFiles, wantFiles) { + t.Fatalf("GeneratedFiles = %v, want %v", res.GeneratedFiles, wantFiles) + } + for _, generated := range res.GeneratedFiles { + if filepath.IsAbs(generated) { + t.Errorf("GeneratedFiles should be workspace-relative, got absolute path %q", generated) + } + if strings.Contains(generated, root) { + t.Errorf("GeneratedFiles leaked workspace root %q in %q", root, generated) + } + } + + for _, name := range wantFiles { + raw, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if strings.Contains(string(raw), root) { + t.Errorf("%s leaked workspace root %q:\n%s", name, root, raw) + } + } +} diff --git a/packages/cli/internal/cli/e2e_helpers_test.go b/packages/cli/internal/cli/e2e_helpers_test.go index 675a63f..148ad4b 100644 --- a/packages/cli/internal/cli/e2e_helpers_test.go +++ b/packages/cli/internal/cli/e2e_helpers_test.go @@ -152,35 +152,36 @@ func pretty(v any) string { // Add new entries when a new test surfaces a new volatile key — keep // the set tight: scrubbing too aggressively hides real regressions. var volatileKeys = map[string]bool{ - "created_path": true, // create: absolute path under tempdir - "installed_to": true, // setup/create: list of absolute skill paths under $HOME - "profile_path": true, // profile-add (legacy v3): absolute path to ~/.config/one/profiles.json - "config_path": true, // profile-add (v4): absolute path to ~/.config/one/config.json - "credentials_path": true, // profile-add (v4): absolute path to ~/.config/one/credentials.json - "display_path": true, // error envelopes: relative-or-absolute path, depends on cwd - "target_path": true, // create error: absolute target path - "workspace_path": true, - "workspace_root": true, - "root": true, // status: workspace.root is absolute - "updatedAt": true, // manifest: ISO timestamp - "updated_at": true, - "timestamp": true, // status envelopes - "detected_at": true, - "absolute_path": true, - "path": true, // status subproject entries - "node_version": true, // depends on local node install - "go_version": true, - "version_actual": true, - "git_commit": true, - "node_modules": true, - "home": true, - "message": true, // error envelopes' message embeds tempdir paths and is i18n-mutable; contract is `code`+`schema` - "generated_files": true, // add: list of absolute paths to generated AI guide files - "files": true, // add-spec: list of absolute paths to generated spec files - "written_to": true, // env init: absolute path to one.manifest.json - "url": true, // serve: per-run URL with random port + token - "port": true, // serve: kernel-assigned port when --port 0 - "token": true, // serve: 32-byte random per-run session token + "created_path": true, // create: absolute path under tempdir + "cwd": true, // error envelopes: absolute current directory + "installed_to": true, // setup/create: list of absolute skill paths under $HOME + "package_json_path": true, // error envelopes: absolute path to package.json + "profile_path": true, // profile-add (legacy v3): absolute path to ~/.config/one/profiles.json + "config_path": true, // profile-add (v4): absolute path to ~/.config/one/config.json + "credentials_path": true, // profile-add (v4): absolute path to ~/.config/one/credentials.json + "display_path": true, // error envelopes: relative-or-absolute path, depends on cwd + "target_path": true, // create error: absolute target path + "workspace_path": true, + "workspace_root": true, + "root": true, // status: workspace.root is absolute + "updatedAt": true, // manifest: ISO timestamp + "updated_at": true, + "timestamp": true, // status envelopes + "detected_at": true, + "absolute_path": true, + "path": true, // status subproject entries + "node_version": true, // depends on local node install + "go_version": true, + "version_actual": true, + "git_commit": true, + "node_modules": true, + "home": true, + "message": true, // error envelopes' message embeds tempdir paths and is i18n-mutable; contract is `code`+`schema` + "files": true, // add-spec: list of absolute paths to generated spec files + "written_to": true, // env init: absolute path to one.manifest.json + "url": true, // serve: per-run URL with random port + token + "port": true, // serve: kernel-assigned port when --port 0 + "token": true, // serve: 32-byte random per-run session token } // canonicalize returns a deep copy of m with all volatileKeys scrubbed diff --git a/packages/cli/internal/cli/snapshot_e2e_add_test.go b/packages/cli/internal/cli/snapshot_e2e_add_test.go index 077f25c..3908dcf 100644 --- a/packages/cli/internal/cli/snapshot_e2e_add_test.go +++ b/packages/cli/internal/cli/snapshot_e2e_add_test.go @@ -59,6 +59,48 @@ func TestSnapshot_E2E_Add_AutoEnablesTemplateDefaults(t *testing.T) { t.Errorf("expected template file missing: %s", full) } } + goModRaw, err := os.ReadFile(filepath.Join(svcDir, "go.mod")) + if err != nil { + t.Fatalf("read rendered go.mod: %v", err) + } + goMod := string(goModRaw) + for _, want := range []string{ + "github.com/swaggo/files v1.0.1", + "github.com/swaggo/gin-swagger v1.6.1", + } { + if !strings.Contains(goMod, want) { + t.Errorf("rendered go.mod missing Swagger dependency %q:\n%s", want, goMod) + } + } + routerRaw, err := os.ReadFile(filepath.Join(svcDir, "internal", "http", "router.go")) + if err != nil { + t.Fatalf("read rendered router.go: %v", err) + } + router := string(routerRaw) + for _, want := range []string{ + `swaggerFiles "github.com/swaggo/files"`, + `ginSwagger "github.com/swaggo/gin-swagger"`, + `engine.GET("/api/docs/*any", ginSwagger.WrapHandler(`, + `ginSwagger.URL("/api/openapi.yaml")`, + } { + if !strings.Contains(router, want) { + t.Errorf("rendered router.go missing Swagger wiring %q:\n%s", want, router) + } + } + appHandlerRaw, err := os.ReadFile(filepath.Join(svcDir, "internal", "http", "handlers", "app_handler.go")) + if err != nil { + t.Fatalf("read rendered app_handler.go: %v", err) + } + if !strings.Contains(string(appHandlerRaw), `c.Redirect(http.StatusTemporaryRedirect, "/api/docs/index.html")`) { + t.Errorf("rendered app handler should redirect /api/docs to Swagger UI:\n%s", appHandlerRaw) + } + openAPIRaw, err := os.ReadFile(filepath.Join(svcDir, "api", "openapi.yaml")) + if err != nil { + t.Fatalf("read rendered openapi.yaml: %v", err) + } + if !strings.HasPrefix(string(openAPIRaw), "openapi: 3.0.3\n") { + t.Errorf("rendered openapi.yaml must use Swagger UI-compatible OpenAPI 3.0.x, got:\n%s", openAPIRaw) + } // go-api's `defaults` auto-enables container/docker // per-subproject, so Dockerfile MUST exist. diff --git a/packages/cli/testdata/reference/add-go-api.json b/packages/cli/testdata/reference/add-go-api.json index f7fabc0..e8627eb 100644 --- a/packages/cli/testdata/reference/add-go-api.json +++ b/packages/cli/testdata/reference/add-go-api.json @@ -1,7 +1,10 @@ { "ai_guides": { "file_count": 2, - "generated_files": "\u003cvolatile\u003e", + "generated_files": [ + "AGENTS.md", + "CLAUDE.md" + ], "providers": [ "codex", "claude-code" diff --git a/packages/cli/testdata/reference/error-template-not-found.json b/packages/cli/testdata/reference/error-template-not-found.json index b365b57..fc94b2c 100644 --- a/packages/cli/testdata/reference/error-template-not-found.json +++ b/packages/cli/testdata/reference/error-template-not-found.json @@ -1 +1 @@ -{"schema":"one-cli/error/v1","error":{"code":"NOT_ONE_PROJECT","message":"未检测到 One CLI 项目,请在项目根目录执行(缺少 \"one\" 标记)。","context":{"cwd":"/Users/diaozhenyuan/Workspace/Projects/torchstellar-team/one-cli","package_json_path":"/Users/diaozhenyuan/Workspace/Projects/torchstellar-team/one-cli/package.json","missing":"one_marker"},"remediation":[{"action":"cd-to-workspace","hint":"切换到 One workspace 根目录后重试(package.json 中需有 \"one\" 字段)"},{"action":"specify-dir","hint":"通过 -d 参数显式指定工作区目录","command":"one -d /path/to/workspace"}]}} +{"schema":"one-cli/error/v1","error":{"code":"NOT_ONE_PROJECT","message":"未检测到 One CLI 项目,请在项目根目录执行(缺少 \"one\" 标记)。","context":{"cwd":"","package_json_path":"","missing":"one_marker"},"remediation":[{"action":"cd-to-workspace","hint":"切换到 One workspace 根目录后重试(package.json 中需有 \"one\" 字段)"},{"action":"specify-dir","hint":"通过 -d 参数显式指定工作区目录","command":"one -d /path/to/workspace"}]}} diff --git a/packages/cli/testdata/reference/error-template-required.json b/packages/cli/testdata/reference/error-template-required.json index b365b57..fc94b2c 100644 --- a/packages/cli/testdata/reference/error-template-required.json +++ b/packages/cli/testdata/reference/error-template-required.json @@ -1 +1 @@ -{"schema":"one-cli/error/v1","error":{"code":"NOT_ONE_PROJECT","message":"未检测到 One CLI 项目,请在项目根目录执行(缺少 \"one\" 标记)。","context":{"cwd":"/Users/diaozhenyuan/Workspace/Projects/torchstellar-team/one-cli","package_json_path":"/Users/diaozhenyuan/Workspace/Projects/torchstellar-team/one-cli/package.json","missing":"one_marker"},"remediation":[{"action":"cd-to-workspace","hint":"切换到 One workspace 根目录后重试(package.json 中需有 \"one\" 字段)"},{"action":"specify-dir","hint":"通过 -d 参数显式指定工作区目录","command":"one -d /path/to/workspace"}]}} +{"schema":"one-cli/error/v1","error":{"code":"NOT_ONE_PROJECT","message":"未检测到 One CLI 项目,请在项目根目录执行(缺少 \"one\" 标记)。","context":{"cwd":"","package_json_path":"","missing":"one_marker"},"remediation":[{"action":"cd-to-workspace","hint":"切换到 One workspace 根目录后重试(package.json 中需有 \"one\" 字段)"},{"action":"specify-dir","hint":"通过 -d 参数显式指定工作区目录","command":"one -d /path/to/workspace"}]}} diff --git a/packages/templates/go-api/CLAUDE.md b/packages/templates/go-api/CLAUDE.md index 16e4671..581db7e 100644 --- a/packages/templates/go-api/CLAUDE.md +++ b/packages/templates/go-api/CLAUDE.md @@ -44,6 +44,7 @@ Taskfile.yml # go-task tasks | Request ID | `middleware.RequestID` — already wired in `internal/app` | | Structured response | `internal/http/response` (success / error / list helpers) | | DB | Gorm via `repository/`; configure in `internal/app` | +| API docs | `api/openapi.yaml` feeds Swagger UI at `/api/docs`; keep it in sync with routes and response shapes. | ## Engineering discipline — mandatory @@ -83,9 +84,9 @@ If any fails, stop. Fix the root cause, don't paper over. 2. Add validation: `c.ShouldBindJSON(&req)` → returns 400 on failure. 3. Call the service: `svc.DoThing(c.Request.Context(), req)`. 4. Write response via `response.OK(c, data)` or `response.Error(c, err)`. -5. Register route in `internal/app/router.go`. +5. Register route in `internal/http/router.go`. 6. Add unit test for the handler (mock the service interface). -7. Add OpenAPI doc in `api/`. +7. Add OpenAPI doc in `api/openapi.yaml` and verify it renders in Swagger UI at `/api/docs`. **Add a new repository method** diff --git a/packages/templates/go-api/README.md.hbs b/packages/templates/go-api/README.md.hbs index 3f4d1d2..c8ad715 100644 --- a/packages/templates/go-api/README.md.hbs +++ b/packages/templates/go-api/README.md.hbs @@ -11,6 +11,13 @@ task check task build ``` +## API docs + +Swagger UI is available at `http://localhost:8080/api/docs` when the +service is running. The UI reads the OpenAPI source at +`/api/openapi.yaml`; update `api/openapi.yaml` whenever routes or +response shapes change. + ## Environment Viper reads `configs/config.yaml` and process environment variables. For local task commands, copy `.env.example` to `.env`; `go-task` loads it before running Go commands. diff --git a/packages/templates/go-api/api/openapi.yaml b/packages/templates/go-api/api/openapi.yaml index 2a184d5..8d55f42 100644 --- a/packages/templates/go-api/api/openapi.yaml +++ b/packages/templates/go-api/api/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.0.3 info: title: Go API Service version: 0.1.0 diff --git a/packages/templates/go-api/go.mod.hbs b/packages/templates/go-api/go.mod.hbs index 7b6d25f..5f10340 100644 --- a/packages/templates/go-api/go.mod.hbs +++ b/packages/templates/go-api/go.mod.hbs @@ -9,6 +9,8 @@ require ( github.com/google/uuid v1.6.0 github.com/prometheus/client_golang v1.23.2 github.com/spf13/viper v1.21.0 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 go.uber.org/zap v1.27.1 golang.org/x/crypto v0.50.0 gorm.io/driver/postgres v1.6.0 diff --git a/packages/templates/go-api/internal/http/handlers/app_handler.go.hbs b/packages/templates/go-api/internal/http/handlers/app_handler.go.hbs index 556f7fb..00863d4 100644 --- a/packages/templates/go-api/internal/http/handlers/app_handler.go.hbs +++ b/packages/templates/go-api/internal/http/handlers/app_handler.go.hbs @@ -25,5 +25,5 @@ func (h *AppHandler) Info(c *gin.Context) { } func (h *AppHandler) Docs(c *gin.Context) { - c.Redirect(http.StatusTemporaryRedirect, "/api/openapi.yaml") + c.Redirect(http.StatusTemporaryRedirect, "/api/docs/index.html") } diff --git a/packages/templates/go-api/internal/http/router.go.hbs b/packages/templates/go-api/internal/http/router.go.hbs index f9e5afd..3a3752c 100644 --- a/packages/templates/go-api/internal/http/router.go.hbs +++ b/packages/templates/go-api/internal/http/router.go.hbs @@ -3,6 +3,8 @@ package http import ( "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus/promhttp" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" "go.uber.org/zap" "github.com/example/{{projectNameKebabCase}}/internal/config" @@ -41,6 +43,10 @@ func NewRouter(deps Dependencies) *Router { engine.GET("/metrics", gin.WrapH(promhttp.Handler())) engine.GET("/api/docs", deps.Handler.App.Docs) engine.StaticFile("/api/openapi.yaml", "api/openapi.yaml") + engine.GET("/api/docs/*any", ginSwagger.WrapHandler( + swaggerFiles.Handler, + ginSwagger.URL("/api/openapi.yaml"), + )) engine.POST("/auth/login", deps.Handler.Auth.Login)