From 490735b732e97e9f39576dc419bfd9f792c5e1a2 Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Fri, 18 Sep 2026 19:18:58 +0800 Subject: [PATCH 1/2] fix(templates): remove obsolete Vite React configuration --- apps/dashboard/vite.config.ts | 33 ++++++++++--------- packages/templates/react-spa/package.json | 2 +- packages/templates/react-spa/vite.config.ts | 35 ++++++++++++--------- 3 files changed, 40 insertions(+), 30 deletions(-) diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts index 5de97d8..66672db 100644 --- a/apps/dashboard/vite.config.ts +++ b/apps/dashboard/vite.config.ts @@ -41,21 +41,26 @@ export default defineConfig({ // to each chunk). Drop for the binary; rebuild with `--sourcemap` if // you need them locally. sourcemap: false, - rollupOptions: { + rolldownOptions: { output: { - manualChunks(id) { - if (id.includes("node_modules")) { - if ( - id.includes("@radix-ui") || - id.includes("lucide-react") || - id.includes("sonner") || - id.includes("class-variance-authority") - ) { - return "ui"; - } - if (id.includes("react-router")) return "router"; - return "vendor"; - } + codeSplitting: { + groups: [ + { + name(id) { + if (!id.includes("node_modules")) return null; + if ( + id.includes("@radix-ui") || + id.includes("lucide-react") || + id.includes("sonner") || + id.includes("class-variance-authority") + ) { + return "ui"; + } + if (id.includes("react-router")) return "router"; + return "vendor"; + }, + }, + ], }, }, }, diff --git a/packages/templates/react-spa/package.json b/packages/templates/react-spa/package.json index 2b8b4bb..2ad20b0 100644 --- a/packages/templates/react-spa/package.json +++ b/packages/templates/react-spa/package.json @@ -35,7 +35,7 @@ "@types/node": "^24.13.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "@vitejs/plugin-react-swc": "^4.3.3", + "@vitejs/plugin-react": "^6.1.1", "cross-env": "^10.1.0", "git-cz": "^4.9.0", "globals": "^17.12.0", diff --git a/packages/templates/react-spa/vite.config.ts b/packages/templates/react-spa/vite.config.ts index 0b14abc..885cc68 100644 --- a/packages/templates/react-spa/vite.config.ts +++ b/packages/templates/react-spa/vite.config.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import tailwindcss from "@tailwindcss/vite"; -import react from "@vitejs/plugin-react-swc"; +import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; const templateDir = path.dirname(fileURLToPath(import.meta.url)); @@ -20,21 +20,26 @@ export default defineConfig({ }, build: { sourcemap: true, - rollupOptions: { + rolldownOptions: { output: { - manualChunks(id) { - if (id.includes("node_modules")) { - if ( - id.includes("@radix-ui") || - id.includes("lucide-react") || - id.includes("sonner") || - id.includes("class-variance-authority") - ) { - return "ui"; - } - if (id.includes("react-router")) return "router"; - return "vendor"; - } + codeSplitting: { + groups: [ + { + name(id) { + if (!id.includes("node_modules")) return null; + if ( + id.includes("@radix-ui") || + id.includes("lucide-react") || + id.includes("sonner") || + id.includes("class-variance-authority") + ) { + return "ui"; + } + if (id.includes("react-router")) return "router"; + return "vendor"; + }, + }, + ], }, }, }, From 78564007018d49acabf335c6141be62b13afe8a5 Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Fri, 18 Sep 2026 22:24:49 +0800 Subject: [PATCH 2/2] fix(creation): preserve generated formatting and guide hook fixes --- apps/docs/content/docs/zh/hk.md | 11 ++ ...2026-09-18-generated-project-formatting.md | 47 +++++++ .../internal/modules/creation/json_fields.go | 118 ++++++++++++++++++ .../modules/creation/json_fields_test.go | 41 ++++++ .../internal/modules/creation/languages.go | 57 +++++---- .../internal/modules/creation/ordered_json.go | 7 +- .../modules/creation/package_format_test.go | 93 ++++++++++++++ .../modules/creation/workspace_content.go | 4 +- .../internal/platform/i18n/locales/en-US.json | 1 + .../internal/platform/i18n/locales/zh-CN.json | 1 + .../cli/internal/transport/cobra/hooks/cmd.go | 9 +- .../transport/cobra/hooks/cmd_test.go | 60 ++++++++- .../expo-mobile/src/app/(tabs)/index.tsx | 10 +- 13 files changed, 420 insertions(+), 39 deletions(-) create mode 100644 docs/plans/2026-09-18-generated-project-formatting.md create mode 100644 packages/cli/internal/modules/creation/json_fields.go create mode 100644 packages/cli/internal/modules/creation/json_fields_test.go create mode 100644 packages/cli/internal/modules/creation/package_format_test.go diff --git a/apps/docs/content/docs/zh/hk.md b/apps/docs/content/docs/zh/hk.md index 10d4204..1d3a658 100644 --- a/apps/docs/content/docs/zh/hk.md +++ b/apps/docs/content/docs/zh/hk.md @@ -17,6 +17,17 @@ one hk validate # 校验 hk 配置 One 从工作区根目录运行 hk;指定文件时使用相对工作区根目录的路径,例如 `one hk check apps/web/src/App.tsx`。hk 参数、标准输出和退出码直接透传。 +提交前检查失败时,One 会提示在工作区根目录执行 `one hk fix`。hk 会自动进入各项目目录,使用对应的 lint/format 工具;修复后检查改动,用 `git add` 重新暂存,再提交。无法自动修复的问题仍需根据检查输出手动处理。 + +`one mise` 保留当前工作目录。hk 输出中的 `package.json`、`src/...` 等路径可能相对于子项目,不能直接在工作区根目录复制执行。手动运行 web 项目的格式化工具时,应明确进入该项目: + +```bash +cd apps/web +one mise exec -- pnpm exec oxfmt --write package.json +``` + +`oxfmt` 来自项目的开发依赖;如果项目目录内仍提示找不到命令,先在工作区根目录运行 `one mise exec -- pnpm install`。 + 新工作区自动安装本地 `pre-commit` 和 `commit-msg` 启动器,继续正常使用 `git commit`。克隆已有工作区后,运行一次: ```bash diff --git a/docs/plans/2026-09-18-generated-project-formatting.md b/docs/plans/2026-09-18-generated-project-formatting.md new file mode 100644 index 0000000..40da17b --- /dev/null +++ b/docs/plans/2026-09-18-generated-project-formatting.md @@ -0,0 +1,47 @@ +# Generated Project Formatting Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Freshly generated projects pass formatting checks, and failed commit checks show a repair command that works from the workspace root. + +**Architecture:** Format the bundled Node templates with their declared Oxfmt version. Update generated package manifests by replacing only the requested JSON fields, preserving unrelated layout, key order, and literal characters. Append a localized repair hint at the One hk command boundary without changing child output or exit status. + +**Tech Stack:** Go, encoding/json, Cobra, Oxfmt, hk, mise. + +--- + +### Task 1: Preserve generated package formatting + +**Files:** `packages/cli/internal/modules/creation/languages.go`, `ordered_json.go`, a focused JSON field editor, and creation tests. + +1. Add failing tests for package name/manager updates and script replacement that preserve tabs, arrays, field order, and shell characters. +2. Replace whole-object map serialization with JSON-decoder-based field edits. Encode changed strings without HTML escaping; preserve script ordering. +3. Keep root workspace package generation formatted and preserve existing root fields during workspace updates. +4. Run creation tests for pnpm, npm, yarn, and bun. + +### Task 2: Normalize templates and verify generated projects + +**Files:** `packages/templates/` Node templates and a creation integration test. + +1. Run Oxfmt 0.67.0 within each Node template, using its own EditorConfig; inspect formatting-only changes. +2. Synchronize bundled resources with `task sync-bundled`. +3. Generate each Node project through the creation service, then run the real formatter against the generated files using `ONE_TEST_OXFMT_BINARY`. +4. Validate React's generated lint and formatting checks and a real first Git commit in a temporary workspace. + +### Task 3: Actionable hook failures + +**Files:** `packages/cli/internal/transport/cobra/hooks/cmd.go`, its tests, English/Chinese i18n catalogs, and hook documentation. + +1. Add tests for failed pre-commit/check commands, successful checks, commit-msg failures, and cancellation. +2. After failed checks, print a localized hint to run `one hk fix` from the workspace root, review and stage changes, then retry. Do not suggest formatting fixes for commit-message errors or interrupted commands. +3. Document that `one mise` keeps the current directory and show a complete subproject command. +4. Run `task check` and inspect the final diff. Leave changes for review without automatically committing or pushing. + +## Validation results + +- All eight bundled Node templates were formatted with Oxfmt 0.67.0; only the Expo home screen required a source formatting change. +- The creation-service integration test generated all eight Node projects and verified both their files and workspace package manifests with the real formatter. +- Package edit regressions cover pnpm/npm/yarn/bun, indentation, compact arrays, readable shell operators, field removal/addition, escaped keys, CRLF, and idempotence. +- A temporary React workspace created by the compiled CLI installed dependencies and completed its first Git commit with the real hk lint, formatting, and commit-message checks. +- A deliberately misformatted package manifest blocked the next temporary commit and displayed the repair hint. Running `one hk fix` at the workspace root restored formatting without changing the index. +- `task check` passed, including Go/E2E tests and all 56 Dashboard tests. diff --git a/packages/cli/internal/modules/creation/json_fields.go b/packages/cli/internal/modules/creation/json_fields.go new file mode 100644 index 0000000..317cd3f --- /dev/null +++ b/packages/cli/internal/modules/creation/json_fields.go @@ -0,0 +1,118 @@ +package creation + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" +) + +// marshalJSONValue retains shell operators and version ranges as readable JSON +// strings instead of escaping &, < and > for embedding in HTML. +func marshalJSONValue(value any) ([]byte, error) { + var b bytes.Buffer + encoder := json.NewEncoder(&b) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return nil, err + } + return bytes.TrimSuffix(b.Bytes(), []byte("\n")), nil +} + +// updateJSONFields edits immediate object members, leaving all other bytes +// intact. A nil value removes a field; json.RawMessage("null") sets JSON null. +// This preserves a template's indentation, ordering and compact arrays without +// requiring Node or a formatter to be installed during project creation. +func updateJSONFields(raw []byte, updates map[string]json.RawMessage) ([]byte, error) { + keys := make([]string, 0, len(updates)) + for key := range updates { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + var err error + raw, err = updateJSONField(raw, key, updates[key]) + if err != nil { + return nil, err + } + } + return raw, nil +} + +type jsonField struct { + key string + keyStart, valueStart, end int +} + +func updateJSONField(raw []byte, key string, value json.RawMessage) ([]byte, error) { + if !json.Valid(raw) || value != nil && !json.Valid(value) { + return nil, fmt.Errorf("invalid JSON while updating %q", key) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + token, _ := decoder.Token() + if token != json.Delim('{') { + return nil, fmt.Errorf("JSON document must be an object") + } + openEnd := int(decoder.InputOffset()) + var fields []jsonField + seen := make(map[string]bool) + for decoder.More() { + start := int(decoder.InputOffset()) + for raw[start] == ',' || raw[start] == ' ' || raw[start] == '\t' || raw[start] == '\r' || raw[start] == '\n' { + start++ + } + token, err := decoder.Token() + if err != nil { + return nil, err + } + fieldKey := token.(string) + if seen[fieldKey] { + return nil, fmt.Errorf("duplicate JSON field %q", fieldKey) + } + seen[fieldKey] = true + var fieldValue json.RawMessage + if err := decoder.Decode(&fieldValue); err != nil { + return nil, err + } + end := int(decoder.InputOffset()) + fields = append(fields, jsonField{fieldKey, start, end - len(fieldValue), end}) + } + _, _ = decoder.Token() + closeStart := int(decoder.InputOffset()) - 1 + for i, field := range fields { + if field.key != key { + continue + } + if value != nil { + return replaceJSONBytes(raw, field.valueStart, field.end, value), nil + } + if len(fields) == 1 { + return replaceJSONBytes(raw, openEnd, closeStart, nil), nil + } + if i == 0 { + return replaceJSONBytes(raw, field.keyStart, fields[1].keyStart, nil), nil + } + return replaceJSONBytes(raw, fields[i-1].end, field.end, nil), nil + } + if value == nil { + return raw, nil + } + encodedKey, _ := json.Marshal(key) + member := append(append(encodedKey, ':', ' '), value...) + if len(fields) == 0 { + return replaceJSONBytes(raw, openEnd, closeStart, member), nil + } + // Reuse the first member's leading whitespace, including CRLF or tabs. + prefix := raw[openEnd:fields[0].keyStart] + addition := append([]byte{','}, prefix...) + addition = append(addition, member...) + end := fields[len(fields)-1].end + return replaceJSONBytes(raw, end, end, addition), nil +} + +func replaceJSONBytes(raw []byte, start, end int, replacement []byte) []byte { + after := make([]byte, 0, len(raw)+len(replacement)-(end-start)) + after = append(after, raw[:start]...) + after = append(after, replacement...) + return append(after, raw[end:]...) +} diff --git a/packages/cli/internal/modules/creation/json_fields_test.go b/packages/cli/internal/modules/creation/json_fields_test.go new file mode 100644 index 0000000..37178da --- /dev/null +++ b/packages/cli/internal/modules/creation/json_fields_test.go @@ -0,0 +1,41 @@ +package creation + +import ( + "encoding/json" + "testing" +) + +func TestJSONFieldEditsPreserveSurroundingContent(t *testing.T) { + for _, tc := range []struct { + name, before, key, value, after string + }{ + {"remove first", "{\n \"manager\": \"pnpm\",\n \"name\": \"demo\"\n}\n", "manager", "", "{\n \"name\": \"demo\"\n}\n"}, + {"remove last", "{\n\t\"name\": \"demo\",\n\t\"manager\": \"pnpm\"\n}\n", "manager", "", "{\n\t\"name\": \"demo\"\n}\n"}, + {"remove middle", `{"name":"demo","manager":"pnpm","files":["src","dist"]}`, "manager", "", `{"name":"demo","files":["src","dist"]}`}, + {"remove only", "{\n \"manager\": \"pnpm\"\n}\n", "manager", "", "{}\n"}, + {"absent removal", `{"name":"demo"}`, "manager", "", `{"name":"demo"}`}, + {"empty object", "{}\n", "private", "true", "{\"private\": true}\n"}, + {"append with tabs and CRLF", "{\r\n\t\"name\": \"demo\"\r\n}\r\n", "private", "true", "{\r\n\t\"name\": \"demo\",\r\n\t\"private\": true\r\n}\r\n"}, + {"escaped key", `{"na\u006de" : "old", "exports":{".":["a","b"]}}`, "name", `"new"`, `{"na\u006de" : "new", "exports":{".":["a","b"]}}`}, + {"null value", `{"name":"demo","custom":false}`, "custom", "null", `{"name":"demo","custom":null}`}, + } { + t.Run(tc.name, func(t *testing.T) { + var value json.RawMessage + if tc.value != "" { + value = json.RawMessage(tc.value) + } + got, err := updateJSONFields([]byte(tc.before), map[string]json.RawMessage{tc.key: value}) + if err != nil || string(got) != tc.after || !json.Valid(got) { + t.Fatalf("edit: %v\nwant %q\ngot %q", err, tc.after, got) + } + }) + } +} + +func TestJSONFieldEditsRejectInvalidOrAmbiguousObjects(t *testing.T) { + for _, source := range []string{`null`, `[]`, `{"name":`, `{"name":"a","name":"b"}`, `{} {}`} { + if _, err := updateJSONFields([]byte(source), map[string]json.RawMessage{"name": json.RawMessage(`"web"`)}); err == nil { + t.Errorf("accepted %q", source) + } + } +} diff --git a/packages/cli/internal/modules/creation/languages.go b/packages/cli/internal/modules/creation/languages.go index c0da3f5..21c8918 100644 --- a/packages/cli/internal/modules/creation/languages.go +++ b/packages/cli/internal/modules/creation/languages.go @@ -96,7 +96,8 @@ func configureNodePackage(p *fsutil.FilePlan, dir, name, manager string) error { if pkg == nil { return fmt.Errorf("project package.json must be an object") } - pkg["name"], _ = json.Marshal(name) + updates := make(map[string]json.RawMessage) + updates["name"], _ = marshalJSONValue(name) // The root packageManager is authoritative, including its pinned version. root, err := p.Read("package.json") if err != nil { @@ -111,9 +112,9 @@ func configureNodePackage(p *fsutil.FilePlan, dir, name, manager string) error { } } if rootPkg.PackageManager != "" { - pkg["packageManager"], _ = json.Marshal(rootPkg.PackageManager) + updates["packageManager"], _ = marshalJSONValue(rootPkg.PackageManager) } else { - delete(pkg, "packageManager") + updates["packageManager"] = nil } if manager != "pnpm" { var scripts map[string]string @@ -122,18 +123,24 @@ func configureNodePackage(p *fsutil.FilePlan, dir, name, manager string) error { return err } } + scriptUpdates := make(map[string]json.RawMessage) for key, command := range scripts { - scripts[key] = strings.ReplaceAll(command, "pnpm run ", manager+" run ") + if updated := strings.ReplaceAll(command, "pnpm run ", manager+" run "); updated != command { + scriptUpdates[key], _ = marshalJSONValue(updated) + } } - if scripts != nil { - pkg["scripts"], _ = json.Marshal(scripts) + if len(scriptUpdates) > 0 { + updates["scripts"], err = updateJSONFields(pkg["scripts"], scriptUpdates) + if err != nil { + return err + } } } - after, err := json.MarshalIndent(pkg, "", " ") + after, err := updateJSONFields(raw, updates) if err != nil { return err } - return p.Set(filepath.Join(dir, "package.json"), append(after, '\n'), 0o644) + return p.Set(filepath.Join(dir, "package.json"), after, 0o644) } func planNodeWorkspace(p *fsutil.FilePlan, m *workspace.Manifest, dirs []string) error { @@ -151,11 +158,15 @@ func planNodeWorkspace(p *fsutil.FilePlan, m *workspace.Manifest, dirs []string) if m.Workspace != nil { name = m.Workspace.Name } - raw, err = json.MarshalIndent(buildPackageJSON(name), "", " ") + raw, err = marshalJSONValue(buildPackageJSON(name)) if err != nil { return err } - raw = append(raw, '\n') + var formatted bytes.Buffer + if err := json.Indent(&formatted, raw, "", " "); err != nil { + return err + } + raw = append(formatted.Bytes(), '\n') } var pkg map[string]json.RawMessage if err := json.Unmarshal(raw, &pkg); err != nil { @@ -164,19 +175,16 @@ func planNodeWorkspace(p *fsutil.FilePlan, m *workspace.Manifest, dirs []string) if pkg == nil { return fmt.Errorf("root package.json must be an object") } - changed := false + updates := make(map[string]json.RawMessage) if _, ok := pkg["private"]; !ok { - pkg["private"] = json.RawMessage("true") - changed = true + updates["private"] = json.RawMessage("true") } if newRoot && manager != "pnpm" { - delete(pkg, "packageManager") - changed = true + updates["packageManager"] = nil } if manager == "pnpm" { if _, ok := pkg["packageManager"]; !ok { - pkg["packageManager"], _ = json.Marshal(packageManagerSpec) - changed = true + updates["packageManager"], _ = marshalJSONValue(packageManagerSpec) } if err := planPNPMWorkspace(p, dirs); err != nil { return err @@ -199,17 +207,18 @@ func planNodeWorkspace(p *fsutil.FilePlan, m *workspace.Manifest, dirs []string) return err } if len(updated) != len(patterns) { - b, _ := json.Marshal(updated) + b, _ := marshalJSONValue(updated) if object != nil { - object["packages"] = b - b, _ = json.Marshal(object) + b, err = updateJSONFields(pkg["workspaces"], map[string]json.RawMessage{"packages": b}) + if err != nil { + return err + } } - pkg["workspaces"], changed = b, true + updates["workspaces"] = b } } - if changed { - raw, err = json.MarshalIndent(pkg, "", " ") - raw = append(raw, '\n') + if len(updates) > 0 { + raw, err = updateJSONFields(raw, updates) } if err != nil { return err diff --git a/packages/cli/internal/modules/creation/ordered_json.go b/packages/cli/internal/modules/creation/ordered_json.go index c432c29..0abdb47 100644 --- a/packages/cli/internal/modules/creation/ordered_json.go +++ b/packages/cli/internal/modules/creation/ordered_json.go @@ -2,11 +2,10 @@ package creation import ( "bytes" - "encoding/json" ) // orderedJSON is a key/value list that marshals into a JSON object preserving -// the order of its entries. Go's `map` randomises iteration; for files the +// the order of its entries. encoding/json sorts map keys; for files the // user (and code review!) reads — package.json and friends — we want a // deterministic shape. // @@ -36,13 +35,13 @@ func (o orderedJSON) MarshalJSON() ([]byte, error) { if i > 0 { buf.WriteByte(',') } - k, err := json.Marshal(entry.Key) + k, err := marshalJSONValue(entry.Key) if err != nil { return nil, err } buf.Write(k) buf.WriteByte(':') - v, err := json.Marshal(entry.Value) + v, err := marshalJSONValue(entry.Value) if err != nil { return nil, err } diff --git a/packages/cli/internal/modules/creation/package_format_test.go b/packages/cli/internal/modules/creation/package_format_test.go new file mode 100644 index 0000000..3ad21f4 --- /dev/null +++ b/packages/cli/internal/modules/creation/package_format_test.go @@ -0,0 +1,93 @@ +package creation + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + internaltoolchain "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/toolchain" + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" +) + +func TestConfigureNodePackagePreservesTemplateFormatting(t *testing.T) { + const source = "{\n\t\"name\": \"template\",\n\t\"scripts\": {\n\t\t\"lint\": \"echo '' && echo done\",\n\t\t\"check\": \"pnpm run lint && pnpm run format\"\n\t},\n\t\"files\": [\"src\", \"dist\"],\n\t\"custom\": { \"command\": \"pnpm run untouched\" },\n\t\"engines\": { \"node\": \">=24\" },\n\t\"packageManager\": \"pnpm@12.3.4\"\n}\n" + for _, manager := range []string{"pnpm", "npm", "yarn", "bun"} { + t.Run(manager, func(t *testing.T) { + p := fsutil.NewFilePlan(t.TempDir()) + root := `{"packageManager":"` + manager + `@1.2.3"}` + if err := p.Set("package.json", []byte(root), 0o644); err != nil { + t.Fatal(err) + } + if err := p.Set("apps/web/package.json", []byte(source), 0o644); err != nil { + t.Fatal(err) + } + if err := configureNodePackage(p, "apps/web", "web", manager); err != nil { + t.Fatal(err) + } + want := strings.Replace(source, `"name": "template"`, `"name": "web"`, 1) + want = strings.Replace(want, "pnpm@12.3.4", manager+"@1.2.3", 1) + want = strings.Replace(want, "pnpm run lint && pnpm run format", manager+" run lint && "+manager+" run format", 1) + got, err := p.Read("apps/web/package.json") + if err != nil || string(got) != want { + t.Fatalf("generated package changed unrelated formatting: %v\nwant:\n%s\ngot:\n%s", err, want, got) + } + if err := configureNodePackage(p, "apps/web", "web", manager); err != nil { + t.Fatal(err) + } + again, _ := p.Read("apps/web/package.json") + if string(again) != string(got) { + t.Fatal("configuration is not idempotent") + } + }) + } +} + +// Use the real formatter against projects created through the public service, +// including package rewrites and generated files, not just template sources. +func TestGeneratedNodeProjectsPassFormatting(t *testing.T) { + formatter := os.Getenv("ONE_TEST_OXFMT_BINARY") + if formatter == "" { + t.Skip("set ONE_TEST_OXFMT_BINARY to run generated-project formatting checks") + } + internaltoolchain.RegisterBundled() + registry, err := template.Fetch(context.Background(), "") + if err != nil { + t.Fatal(err) + } + for _, entry := range registry.Templates { + if entry.Toolchain != "node" { + continue + } + t.Run(entry.ID, func(t *testing.T) { + s := newCreationService(t) + root := filepath.Join(t.TempDir(), "format-check") + if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "format-check"}); err != nil { + t.Fatal(err) + } + result, err := s.AddProject(context.Background(), root, ProjectInput{Template: &entry, Name: "sample", DeferDeployment: true}) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(formatter, "--check", ".") + cmd.Dir = result.Project.TargetPath + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated %s formatting: %v\n%s", entry.ID, err, out) + } + cmd = exec.Command(formatter, "--check", "package.json") + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("workspace package formatting: %v\n%s", err, out) + } + b, err := os.ReadFile(filepath.Join(result.Project.TargetPath, "package.json")) + var pkg struct{ Name string } + if err != nil || json.Unmarshal(b, &pkg) != nil || pkg.Name != "sample" { + t.Fatalf("generated package name: %s (%v)", b, err) + } + }) + } +} diff --git a/packages/cli/internal/modules/creation/workspace_content.go b/packages/cli/internal/modules/creation/workspace_content.go index f0eb0d4..1c11558 100644 --- a/packages/cli/internal/modules/creation/workspace_content.go +++ b/packages/cli/internal/modules/creation/workspace_content.go @@ -53,10 +53,10 @@ const packageManagerSpec = "pnpm@12.3.4" func buildPackageJSON(name string) orderedJSON { return orderedJSON{ {Key: "name", Value: name}, - {Key: "private", Value: true}, {Key: "version", Value: "0.0.0"}, - {Key: "packageManager", Value: packageManagerSpec}, + {Key: "private", Value: true}, {Key: "engines", Value: orderedJSON{{Key: "node", Value: "^24.15.0 || >=26.0.0"}}}, + {Key: "packageManager", Value: packageManagerSpec}, } } diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index 1fdf8e6..eb7af58 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -303,6 +303,7 @@ "hooks.short": "Run workspace checks and fixes with hk through One", "hooks.tip": "Use one hk check for changed files, one hk check --all for CI, and one hk fix for explicit fixes. Git hook installation and migration are managed by one configure hooks. hk and its tool environment run through One's bundled mise; application dependencies remain project-specific.", "hooks.configure_short": "Generate hk checks, migrate default Husky hooks, and install local Git launchers", + "hooks.check_failed_hint": "Run `one hk fix` from the workspace root to apply available fixes, then review and stage the changes with `git add` before retrying. hk selects each project's directory and tools automatically.", "create.hooks_warning": "Workspace created, but Git hooks need setup (%v); run one configure hooks after resolving it", "mise.tip": "Optional access to mise for configuration trust, diagnostics, and tool installation. One includes a pinned mise runtime and extracts it locally on first use without downloading mise. Arguments and output are passed through; project environment secrets are not loaded by One here. Normal development continues to use one dev and one run.", "dev.install_confirm": "Dependencies are not installed. Run `%s` now and continue?", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index f581de7..ca6720d 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -303,6 +303,7 @@ "hooks.short": "通过 One 运行 hk 工作区检查与修复", "hooks.tip": "one hk check 检查变更文件,one hk check --all 用于 CI,one hk fix 显式修复。通过 one configure hooks 安装或迁移 Git hooks。hk 及工具环境由 One 内置的 mise 提供;项目依赖由各项目管理。", "hooks.configure_short": "生成 hk 检查、迁移默认 Husky hooks 并安装本地 Git 启动器", + "hooks.check_failed_hint": "请在工作区根目录运行 `one hk fix` 修复可自动处理的问题,检查改动并用 `git add` 重新暂存后再重试。hk 会自动选择各项目的目录和工具。", "create.hooks_warning": "工作区已创建,但 Git hooks 尚未安装(%v);解决后运行 one configure hooks", "mise.tip": "按需访问 mise 的配置信任、诊断和工具安装功能。One 已内置固定版本的 mise,首次使用从自身解压,无需下载 mise。参数和输出直接透传,此入口不加载 One 的项目密钥。日常开发继续使用 one dev 和 one run。", "dev.install_confirm": "尚未安装依赖。现在运行 `%s` 并继续吗?", diff --git a/packages/cli/internal/transport/cobra/hooks/cmd.go b/packages/cli/internal/transport/cobra/hooks/cmd.go index b3e3b9e..fa5b933 100644 --- a/packages/cli/internal/transport/cobra/hooks/cmd.go +++ b/packages/cli/internal/transport/cobra/hooks/cmd.go @@ -1,6 +1,7 @@ package hookscmd import ( + "errors" "fmt" "os" "os/exec" @@ -68,7 +69,13 @@ func Commands(provider runtimeport.Provider) []*cobra.Command { child := platformprocess.Command(prepared.Argv[0], prepared.Argv[1:]...) child.Dir, child.Env = prepared.Directory, prepared.Env child.Stdin, child.Stdout, child.Stderr = cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr() - return platformprocess.RunForwarded(cmd.Context(), child) + err = platformprocess.RunForwarded(cmd.Context(), child) + var status *platformprocess.ExitStatus + isCheck := args[0] == "check" || len(args) > 1 && args[0] == "run" && args[1] == "pre-commit" + if isCheck && cmd.Context().Err() == nil && errors.As(err, &status) && status.Code > 0 && status.Code < 128 { + fmt.Fprintln(cmd.ErrOrStderr(), "\n"+i18n.T("hooks.check_failed_hint")) + } + return err }, } i18n.MarkShort(cmd, "hooks.short") diff --git a/packages/cli/internal/transport/cobra/hooks/cmd_test.go b/packages/cli/internal/transport/cobra/hooks/cmd_test.go index 3b894e1..c94c8f4 100644 --- a/packages/cli/internal/transport/cobra/hooks/cmd_test.go +++ b/packages/cli/internal/transport/cobra/hooks/cmd_test.go @@ -3,6 +3,7 @@ package hookscmd import ( "bytes" "context" + "fmt" "os" "path/filepath" "runtime" @@ -14,14 +15,17 @@ import ( runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" ) -type recordingProvider struct{ command runtimeport.Command } +type recordingProvider struct { + command runtimeport.Command + exitCode int +} func (p *recordingProvider) Prepare(context.Context, runtimeport.Command) (runtimeport.Command, error) { panic("hk should use the runtime CLI entry") } func (p *recordingProvider) PrepareCLI(_ context.Context, c runtimeport.Command) (runtimeport.Command, error) { p.command = c - c.Argv = []string{"sh", "-c", "printf hk-output; exit 37"} + c.Argv = []string{"sh", "-c", fmt.Sprintf("printf hk-output; printf hk-error >&2; exit %d", p.exitCode)} return c, nil } @@ -34,7 +38,7 @@ func TestHKForwardsArgumentsAndExitStatus(t *testing.T) { if err := os.WriteFile(filepath.Join(root, ".env"), []byte("ONE_HOOK_SECRET=must-not-load\n"), 0o644); err != nil { t.Fatal(err) } - p := &recordingProvider{} + p := &recordingProvider{exitCode: 37} cmd := Commands(p)[0] cmd.SilenceUsage, cmd.SilenceErrors = true, true var out bytes.Buffer @@ -61,6 +65,56 @@ func TestHKForwardsArgumentsAndExitStatus(t *testing.T) { } } +func TestHKCheckFailureShowsWorkspaceRepairCommand(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX fake process") + } + t.Chdir(t.TempDir()) + for _, tc := range []struct { + name string + args []string + code int + wantHint bool + }{ + {"pre-commit", []string{"run", "pre-commit"}, 37, true}, + {"check", []string{"check", "--all"}, 1, true}, + {"successful check", []string{"check"}, 0, false}, + {"commit message", []string{"run", "commit-msg", "message.txt"}, 1, false}, + {"fix", []string{"fix"}, 1, false}, + {"interrupted", []string{"check"}, 130, false}, + {"terminated", []string{"run", "pre-commit"}, 143, false}, + } { + t.Run(tc.name, func(t *testing.T) { + cmd := Commands(&recordingProvider{exitCode: tc.code})[0] + cmd.SilenceUsage, cmd.SilenceErrors = true, true + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(tc.args) + err := cmd.Execute() + if tc.code == 0 { + if err != nil { + t.Fatal(err) + } + } else if status, ok := err.(*platformprocess.ExitStatus); !ok || status.Code != tc.code { + t.Fatalf("exit status changed: %v", err) + } + if stdout.String() != "hk-output" { + t.Fatalf("stdout changed: %q", stdout.String()) + } + if !strings.HasPrefix(stderr.String(), "hk-error") || !tc.wantHint && stderr.String() != "hk-error" { + t.Fatalf("child stderr changed: %q", stderr.String()) + } + if got := strings.Contains(stderr.String(), "one hk fix"); got != tc.wantHint { + t.Fatalf("repair hint = %t, want %t: %s", got, tc.wantHint, stderr.String()) + } + if tc.wantHint && (!strings.Contains(stderr.String(), "workspace root") || !strings.Contains(stderr.String(), "stage")) { + t.Fatalf("missing directory/staging guidance: %s", stderr.String()) + } + }) + } +} + func TestGofmtAdapterRejectsUnformattedAndInvalidInput(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "sample.go") diff --git a/packages/templates/expo-mobile/src/app/(tabs)/index.tsx b/packages/templates/expo-mobile/src/app/(tabs)/index.tsx index 8339f55..83a6e8d 100644 --- a/packages/templates/expo-mobile/src/app/(tabs)/index.tsx +++ b/packages/templates/expo-mobile/src/app/(tabs)/index.tsx @@ -53,7 +53,10 @@ export default function HomeScreen() { Welcome to One CLI - + EXPO · REACT NATIVE @@ -66,10 +69,7 @@ export default function HomeScreen() { - + 开始构建