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
33 changes: 19 additions & 14 deletions apps/dashboard/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
},
},
],
Comment thread
caorushizi marked this conversation as resolved.
},
},
},
Expand Down
11 changes: 11 additions & 0 deletions apps/docs/content/docs/zh/hk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions docs/plans/2026-09-18-generated-project-formatting.md
Original file line number Diff line number Diff line change
@@ -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.
118 changes: 118 additions & 0 deletions packages/cli/internal/modules/creation/json_fields.go
Original file line number Diff line number Diff line change
@@ -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:]...)
}
41 changes: 41 additions & 0 deletions packages/cli/internal/modules/creation/json_fields_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
57 changes: 33 additions & 24 deletions packages/cli/internal/modules/creation/languages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading