Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions packages/cli/internal/ai/ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -227,15 +228,15 @@ 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
b.WriteString(generatedStart + "\n")
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 {
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/internal/ai/ai_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
59 changes: 30 additions & 29 deletions packages/cli/internal/cli/e2e_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/internal/cli/snapshot_e2e_add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/testdata/reference/add-go-api.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
{
"ai_guides": {
"file_count": 2,
"generated_files": "\u003cvolatile\u003e",
"generated_files": [
"AGENTS.md",
"CLAUDE.md"
],
"providers": [
"codex",
"claude-code"
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <command> -d /path/to/workspace"}]}}
{"schema":"one-cli/error/v1","error":{"code":"NOT_ONE_PROJECT","message":"未检测到 One CLI 项目,请在项目根目录执行(缺少 \"one\" 标记)。","context":{"cwd":"<volatile>","package_json_path":"<volatile>","missing":"one_marker"},"remediation":[{"action":"cd-to-workspace","hint":"切换到 One workspace 根目录后重试(package.json 中需有 \"one\" 字段)"},{"action":"specify-dir","hint":"通过 -d 参数显式指定工作区目录","command":"one <command> -d /path/to/workspace"}]}}
Original file line number Diff line number Diff line change
@@ -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 <command> -d /path/to/workspace"}]}}
{"schema":"one-cli/error/v1","error":{"code":"NOT_ONE_PROJECT","message":"未检测到 One CLI 项目,请在项目根目录执行(缺少 \"one\" 标记)。","context":{"cwd":"<volatile>","package_json_path":"<volatile>","missing":"one_marker"},"remediation":[{"action":"cd-to-workspace","hint":"切换到 One workspace 根目录后重试(package.json 中需有 \"one\" 字段)"},{"action":"specify-dir","hint":"通过 -d 参数显式指定工作区目录","command":"one <command> -d /path/to/workspace"}]}}
5 changes: 3 additions & 2 deletions packages/templates/go-api/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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**

Expand Down
7 changes: 7 additions & 0 deletions packages/templates/go-api/README.md.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/templates/go-api/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
openapi: 3.1.0
openapi: 3.0.3
info:
title: Go API Service
version: 0.1.0
Expand Down
2 changes: 2 additions & 0 deletions packages/templates/go-api/go.mod.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
6 changes: 6 additions & 0 deletions packages/templates/go-api/internal/http/router.go.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
Loading