savepoint 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +12 -1
- package/.golangci.yml +11 -0
- package/.savepoint/Design.md +37 -36
- package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
- package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +43 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +28 -0
- package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
- package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
- package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
- package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
- package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
- package/.savepoint/router.md +9 -16
- package/AGENTS.md +38 -23
- package/README.md +0 -1
- package/agent-skills/savepoint-audit/SKILL.md +86 -34
- package/agent-skills/savepoint-build-task/SKILL.md +7 -2
- package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
- package/agent-skills/savepoint-create-task/SKILL.md +44 -31
- package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
- package/agent-skills/savepoint-system-design/SKILL.md +7 -2
- package/agent_skills_test.go +91 -0
- package/cmd/board.go +59 -0
- package/cmd/board_test.go +137 -0
- package/cmd/doctor.go +53 -0
- package/cmd/doctor_test.go +146 -0
- package/cmd/init.go +63 -0
- package/cmd/init_test.go +104 -0
- package/internal/board/board.go +40 -36
- package/internal/board/board_test.go +27 -82
- package/internal/board/card.go +43 -23
- package/internal/board/card_test.go +41 -5
- package/internal/board/column.go +44 -13
- package/internal/board/column_test.go +5 -2
- package/internal/board/detail.go +0 -47
- package/internal/board/epic_panel.go +118 -22
- package/internal/board/epic_panel_test.go +302 -17
- package/internal/board/help.go +1 -0
- package/internal/board/help_test.go +1 -0
- package/internal/board/integration_test.go +266 -0
- package/internal/board/interfaces.go +65 -0
- package/internal/board/interfaces_test.go +114 -0
- package/internal/board/io.go +93 -0
- package/internal/board/model.go +79 -118
- package/internal/board/plain.go +88 -0
- package/internal/board/plain_test.go +117 -0
- package/internal/board/release.go +1 -9
- package/internal/board/release_test.go +6 -6
- package/internal/board/status.go +4 -4
- package/internal/board/theme.go +24 -0
- package/internal/board/theme_test.go +31 -0
- package/internal/board/transitions.go +113 -88
- package/internal/board/transitions_test.go +164 -141
- package/internal/board/tui.go +32 -0
- package/internal/board/update.go +325 -215
- package/internal/board/update_test.go +299 -18
- package/internal/board/util.go +76 -0
- package/internal/board/view.go +31 -28
- package/internal/board/view_test.go +12 -2
- package/internal/board/watch.go +35 -5
- package/internal/buildtool/main.go +2 -10
- package/internal/buildtool/main_test.go +46 -0
- package/internal/data/config.go +17 -3
- package/internal/data/config_test.go +49 -0
- package/internal/data/discover.go +26 -0
- package/internal/data/discover_test.go +34 -10
- package/internal/data/errors.go +4 -0
- package/internal/data/lifecycle.go +13 -6
- package/internal/data/lifecycle_test.go +14 -11
- package/internal/data/parser.go +21 -6
- package/internal/data/parser_test.go +31 -7
- package/internal/data/task.go +0 -9
- package/internal/data/write.go +85 -11
- package/internal/data/write_test.go +167 -0
- package/internal/doctor/checks.go +567 -0
- package/internal/doctor/checks_test.go +716 -0
- package/internal/doctor/gates.go +193 -0
- package/internal/doctor/gates_test.go +166 -0
- package/internal/doctor/interfaces.go +64 -0
- package/internal/doctor/interfaces_test.go +104 -0
- package/internal/doctor/repairs.go +80 -0
- package/internal/doctor/repairs_test.go +81 -0
- package/internal/doctor/report.go +157 -0
- package/internal/doctor/report_test.go +89 -0
- package/internal/init/clipboard.go +146 -0
- package/internal/init/clipboard_test.go +74 -0
- package/internal/init/install.go +16 -0
- package/internal/init/integration_test.go +197 -0
- package/internal/init/prompt.go +14 -0
- package/internal/init/prompt_test.go +77 -0
- package/internal/init/scaffold.go +59 -0
- package/internal/init/scaffold_test.go +179 -0
- package/internal/init/template_freshness_test.go +56 -0
- package/internal/init/validate.go +85 -0
- package/internal/init/validate_test.go +141 -0
- package/internal/init/write.go +73 -0
- package/internal/init/write_test.go +91 -0
- package/internal/styles/styles_test.go +133 -0
- package/internal/testutil/fixture.go +113 -0
- package/internal/testutil/fs.go +26 -0
- package/main.go +101 -4
- package/package.json +2 -2
- package/project-audit/audit_report_glm_5.1.md +411 -0
- package/project-audit/audit_report_opus_4.6 +406 -0
- package/project-audit/consolidated-audit-report.md +456 -0
- package/savepoint +0 -0
- package/templates/project/.savepoint/Design.md +2 -2
- package/templates/project/.savepoint/router.md +10 -10
- package/templates/project/AGENTS.md +33 -21
- package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
- package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
- package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
- package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
- package/templates/prompts/audit-reconciliation.prompt.md +33 -28
- package/templates/prompts/design.prompt.md +3 -1
- package/.savepoint/audit/v1/E01/proposals.md +0 -168
- package/.savepoint/audit/v1/E01/snapshot.md +0 -78
- package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
- package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
- package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
- package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
- package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
- package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
- package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
- package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
- package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
- package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
- package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
- package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
- package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
- package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
- package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
- package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
- package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
- package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
- package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
- package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
- package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
- package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
- package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
- package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
- package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
- package/ink-cli-ui-design.zip +0 -0
- package/savepoint.exe +0 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package init
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
"testing/fstest"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestRenderMagicPrompt_rendersTemplate(t *testing.T) {
|
|
10
|
+
templates := fstest.MapFS{
|
|
11
|
+
"magic-prompt.prompt.md": &fstest.MapFile{
|
|
12
|
+
Data: []byte("Project: {{PROJECT_NAME}}"),
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
got, err := RenderMagicPrompt(templates, "myapp")
|
|
17
|
+
if err != nil {
|
|
18
|
+
t.Fatalf("RenderMagicPrompt() error = %v", err)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
want := "Project: myapp"
|
|
22
|
+
if got != want {
|
|
23
|
+
t.Fatalf("RenderMagicPrompt() = %q, want %q", got, want)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestRenderMagicPrompt_interpolatesAllVariables(t *testing.T) {
|
|
28
|
+
templates := fstest.MapFS{
|
|
29
|
+
"magic-prompt.prompt.md": &fstest.MapFile{
|
|
30
|
+
Data: []byte("{{PROJECT_NAME}} v{{RELEASE_NUMBER}}"),
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
got, err := RenderMagicPrompt(templates, "myapp")
|
|
35
|
+
if err != nil {
|
|
36
|
+
t.Fatalf("RenderMagicPrompt() error = %v", err)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
want := "myapp v1"
|
|
40
|
+
if got != want {
|
|
41
|
+
t.Fatalf("RenderMagicPrompt() = %q, want %q", got, want)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func TestRenderMagicPrompt_handlesMissingTemplate(t *testing.T) {
|
|
46
|
+
_, err := RenderMagicPrompt(fstest.MapFS{}, "myapp")
|
|
47
|
+
if err == nil {
|
|
48
|
+
t.Fatal("RenderMagicPrompt() expected error for missing template")
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestRenderMagicPrompt_usesEmbeddedTemplate(t *testing.T) {
|
|
53
|
+
templates := fstest.MapFS{
|
|
54
|
+
"magic-prompt.prompt.md": &fstest.MapFile{
|
|
55
|
+
Data: []byte("<!-- AGENT: Read AGENTS.md -->\n\nProject: {{PROJECT_NAME}}"),
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
got, err := RenderMagicPrompt(templates, "my-project")
|
|
60
|
+
if err != nil {
|
|
61
|
+
t.Fatalf("RenderMagicPrompt() error = %v", err)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if !strings.Contains(got, "my-project") {
|
|
65
|
+
t.Fatalf("RenderMagicPrompt() = %q, does not contain project name", got)
|
|
66
|
+
}
|
|
67
|
+
if !strings.Contains(got, "AGENT") {
|
|
68
|
+
t.Fatalf("RenderMagicPrompt() = %q, does not contain template content", got)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func TestRenderMagicPrompt_handlesEmptyMapFS(t *testing.T) {
|
|
73
|
+
_, err := RenderMagicPrompt(fstest.MapFS{}, "")
|
|
74
|
+
if err == nil {
|
|
75
|
+
t.Fatal("RenderMagicPrompt() expected error for empty MapFS")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
package init
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io/fs"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const ReleaseNumber = "1"
|
|
12
|
+
|
|
13
|
+
func Scaffold(templates fs.FS, targetDir, projectName string, force bool) error {
|
|
14
|
+
return fs.WalkDir(templates, ".", func(path string, d fs.DirEntry, err error) error {
|
|
15
|
+
if err != nil {
|
|
16
|
+
return fmt.Errorf("walk error at %s: %w", path, err)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
targetPath := filepath.Join(targetDir, path)
|
|
20
|
+
|
|
21
|
+
if d.IsDir() {
|
|
22
|
+
if path == "." {
|
|
23
|
+
return nil
|
|
24
|
+
}
|
|
25
|
+
return os.MkdirAll(targetPath, 0755)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if path == "." {
|
|
29
|
+
return nil
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
content, err := fs.ReadFile(templates, path)
|
|
33
|
+
if err != nil {
|
|
34
|
+
return fmt.Errorf("read %s: %w", path, err)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interpolated := interpolate(string(content), projectName)
|
|
38
|
+
|
|
39
|
+
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
40
|
+
return fmt.Errorf("create parent dirs for %s: %w", targetPath, err)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return AtomicWrite(targetPath, []byte(interpolated))
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func ProjectNameFromDir(dir string) string {
|
|
48
|
+
abs, err := filepath.Abs(dir)
|
|
49
|
+
if err != nil {
|
|
50
|
+
return "my-project"
|
|
51
|
+
}
|
|
52
|
+
return filepath.Base(abs)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func interpolate(content, projectName string) string {
|
|
56
|
+
result := strings.ReplaceAll(content, "{{PROJECT_NAME}}", projectName)
|
|
57
|
+
result = strings.ReplaceAll(result, "{{RELEASE_NUMBER}}", ReleaseNumber)
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
package init
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"io/fs"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"testing"
|
|
8
|
+
"testing/fstest"
|
|
9
|
+
|
|
10
|
+
"github.com/opencode/savepoint/internal/testutil"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestScaffold_createsDirectories(t *testing.T) {
|
|
14
|
+
target := t.TempDir()
|
|
15
|
+
templates := fstest.MapFS{
|
|
16
|
+
".savepoint": &fstest.MapFile{Mode: fs.ModeDir | 0755},
|
|
17
|
+
".savepoint/config.yml": &fstest.MapFile{Data: []byte("key: value")},
|
|
18
|
+
"AGENTS.md": &fstest.MapFile{Data: []byte("# Agents Guide")},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if err := Scaffold(templates, target, "myapp", false); err != nil {
|
|
22
|
+
t.Fatalf("Scaffold() error = %v", err)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if _, err := os.Stat(filepath.Join(target, ".savepoint", "config.yml")); err != nil {
|
|
26
|
+
t.Errorf(".savepoint/config.yml not created: %v", err)
|
|
27
|
+
}
|
|
28
|
+
if _, err := os.Stat(filepath.Join(target, "AGENTS.md")); err != nil {
|
|
29
|
+
t.Errorf("AGENTS.md not created: %v", err)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestScaffold_interpolatesProjectName(t *testing.T) {
|
|
34
|
+
target := t.TempDir()
|
|
35
|
+
templates := fstest.MapFS{
|
|
36
|
+
"Design.md": &fstest.MapFile{Data: []byte("# {{PROJECT_NAME}} Design")},
|
|
37
|
+
"PRD.md": &fstest.MapFile{Data: []byte("Project: {{PROJECT_NAME}}")},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if err := Scaffold(templates, target, "myapp", false); err != nil {
|
|
41
|
+
t.Fatalf("Scaffold() error = %v", err)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
data, err := os.ReadFile(filepath.Join(target, "Design.md"))
|
|
45
|
+
if err != nil {
|
|
46
|
+
t.Fatal(err)
|
|
47
|
+
}
|
|
48
|
+
if string(data) != "# myapp Design" {
|
|
49
|
+
t.Fatalf("Design.md = %q, want %q", string(data), "# myapp Design")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
data, err = os.ReadFile(filepath.Join(target, "PRD.md"))
|
|
53
|
+
if err != nil {
|
|
54
|
+
t.Fatal(err)
|
|
55
|
+
}
|
|
56
|
+
if string(data) != "Project: myapp" {
|
|
57
|
+
t.Fatalf("PRD.md = %q, want %q", string(data), "Project: myapp")
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func TestScaffold_interpolatesReleaseNumber(t *testing.T) {
|
|
62
|
+
target := t.TempDir()
|
|
63
|
+
templates := fstest.MapFS{
|
|
64
|
+
"AGENTS.md": &fstest.MapFile{Data: []byte("release: v{{RELEASE_NUMBER}}")},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if err := Scaffold(templates, target, "myapp", false); err != nil {
|
|
68
|
+
t.Fatalf("Scaffold() error = %v", err)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
data, err := os.ReadFile(filepath.Join(target, "AGENTS.md"))
|
|
72
|
+
if err != nil {
|
|
73
|
+
t.Fatal(err)
|
|
74
|
+
}
|
|
75
|
+
if string(data) != "release: v1" {
|
|
76
|
+
t.Fatalf("AGENTS.md = %q, want %q", string(data), "release: v1")
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestScaffold_createsParentDirs(t *testing.T) {
|
|
81
|
+
target := t.TempDir()
|
|
82
|
+
templates := fstest.MapFS{
|
|
83
|
+
"deep/nested/dir/file.txt": &fstest.MapFile{Data: []byte("content")},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if err := Scaffold(templates, target, "myapp", false); err != nil {
|
|
87
|
+
t.Fatalf("Scaffold() error = %v", err)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
path := filepath.Join(target, "deep", "nested", "dir", "file.txt")
|
|
91
|
+
if _, err := os.Stat(path); err != nil {
|
|
92
|
+
t.Errorf("deep/nested/dir/file.txt not created: %v", err)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func TestScaffold_overwritesWithForce(t *testing.T) {
|
|
97
|
+
target := t.TempDir()
|
|
98
|
+
existingPath := filepath.Join(target, "file.txt")
|
|
99
|
+
testutil.WriteFile(t, existingPath, "old")
|
|
100
|
+
|
|
101
|
+
templates := fstest.MapFS{
|
|
102
|
+
"file.txt": &fstest.MapFile{Data: []byte("new")},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if err := Scaffold(templates, target, "myapp", true); err != nil {
|
|
106
|
+
t.Fatalf("Scaffold() with force error = %v", err)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
data, err := os.ReadFile(existingPath)
|
|
110
|
+
if err != nil {
|
|
111
|
+
t.Fatal(err)
|
|
112
|
+
}
|
|
113
|
+
if string(data) != "new" {
|
|
114
|
+
t.Fatalf("file.txt = %q, want %q", string(data), "new")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func TestScaffold_overwritesExistingAfterValidation(t *testing.T) {
|
|
119
|
+
target := t.TempDir()
|
|
120
|
+
existingPath := filepath.Join(target, "file.txt")
|
|
121
|
+
testutil.WriteFile(t, existingPath, "old")
|
|
122
|
+
|
|
123
|
+
templates := fstest.MapFS{
|
|
124
|
+
"file.txt": &fstest.MapFile{Data: []byte("new")},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Without force, scaffold still overwrites since validation
|
|
128
|
+
// guarantees no conflicts. The force param is for explicit override.
|
|
129
|
+
if err := Scaffold(templates, target, "myapp", false); err != nil {
|
|
130
|
+
t.Fatalf("Scaffold() error = %v", err)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
data, err := os.ReadFile(existingPath)
|
|
134
|
+
if err != nil {
|
|
135
|
+
t.Fatal(err)
|
|
136
|
+
}
|
|
137
|
+
// Without force we still write (validation has already cleared conflicts)
|
|
138
|
+
if string(data) != "new" {
|
|
139
|
+
t.Fatalf("file.txt = %q, want %q", string(data), "new")
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func TestProjectNameFromDir(t *testing.T) {
|
|
144
|
+
dir := t.TempDir()
|
|
145
|
+
name := filepath.Base(dir)
|
|
146
|
+
got := ProjectNameFromDir(dir)
|
|
147
|
+
if got != name {
|
|
148
|
+
t.Fatalf("ProjectNameFromDir(%q) = %q, want %q", dir, got, name)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func TestProjectNameFromDir_dot(t *testing.T) {
|
|
153
|
+
got := ProjectNameFromDir(".")
|
|
154
|
+
cwd, _ := os.Getwd()
|
|
155
|
+
want := filepath.Base(cwd)
|
|
156
|
+
if got != want {
|
|
157
|
+
t.Fatalf("ProjectNameFromDir(\".\") = %q, want %q", got, want)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func TestInterpolate(t *testing.T) {
|
|
162
|
+
tests := []struct {
|
|
163
|
+
input string
|
|
164
|
+
name string
|
|
165
|
+
want string
|
|
166
|
+
}{
|
|
167
|
+
{input: "# {{PROJECT_NAME}}", name: "myapp", want: "# myapp"},
|
|
168
|
+
{input: "v{{RELEASE_NUMBER}}", name: "myapp", want: "v1"},
|
|
169
|
+
{input: "{{PROJECT_NAME}} v{{RELEASE_NUMBER}}", name: "foo", want: "foo v1"},
|
|
170
|
+
{input: "no variables", name: "myapp", want: "no variables"},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for _, tt := range tests {
|
|
174
|
+
got := interpolate(tt.input, tt.name)
|
|
175
|
+
if got != tt.want {
|
|
176
|
+
t.Errorf("interpolate(%q, %q) = %q, want %q", tt.input, tt.name, got, tt.want)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package init
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestProjectTemplatesUseCurrentWorkflow(t *testing.T) {
|
|
11
|
+
root := filepath.Join("..", "..")
|
|
12
|
+
agents := readTemplate(t, root, "templates", "project", "AGENTS.md")
|
|
13
|
+
router := readTemplate(t, root, "templates", "project", ".savepoint", "router.md")
|
|
14
|
+
auditSkill := readTemplate(t, root, "templates", "project", "agent-skills", "savepoint-audit", "SKILL.md")
|
|
15
|
+
|
|
16
|
+
assertNotContains(t, agents, "`phase` (build/test/audit)")
|
|
17
|
+
assertNotContains(t, agents, "npm run build && npm run test")
|
|
18
|
+
assertContains(t, agents, "`stage` (build/test/audit): **required** when `status: in_progress`")
|
|
19
|
+
assertContains(t, agents, "make build && make test")
|
|
20
|
+
|
|
21
|
+
assertNotContains(t, router, ".savepoint/audit/{E##-epic}/snapshot.md")
|
|
22
|
+
assertNotContains(t, router, ".savepoint/audit/{release}/{E##-epic}/proposals.md")
|
|
23
|
+
assertNotContains(t, router, ".savepoint/audit/{E##-epic}/proposals.md")
|
|
24
|
+
assertContains(t, router, ".savepoint/releases/{release}/epics/{E##-epic}/E##-Audit.md")
|
|
25
|
+
assertContains(t, router, "`## Proposed Changes` — admin/apply metadata")
|
|
26
|
+
assertContains(t, agents, "During audit apply/close, update the same `E##-Audit.md` visible sections")
|
|
27
|
+
assertContains(t, auditSkill, "Update `E##-Audit.md` visible sections")
|
|
28
|
+
assertContains(t, auditSkill, "Updated audit findings")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func readTemplate(t *testing.T, root string, parts ...string) string {
|
|
32
|
+
t.Helper()
|
|
33
|
+
|
|
34
|
+
path := filepath.Join(append([]string{root}, parts...)...)
|
|
35
|
+
data, err := os.ReadFile(path)
|
|
36
|
+
if err != nil {
|
|
37
|
+
t.Fatalf("read template %s: %v", path, err)
|
|
38
|
+
}
|
|
39
|
+
return string(data)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func assertContains(t *testing.T, content, want string) {
|
|
43
|
+
t.Helper()
|
|
44
|
+
|
|
45
|
+
if !strings.Contains(content, want) {
|
|
46
|
+
t.Fatalf("template missing %q", want)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func assertNotContains(t *testing.T, content, stale string) {
|
|
51
|
+
t.Helper()
|
|
52
|
+
|
|
53
|
+
if strings.Contains(content, stale) {
|
|
54
|
+
t.Fatalf("template contains stale text %q", stale)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
package init
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
var (
|
|
11
|
+
ErrTargetMissing = errors.New("target directory does not exist")
|
|
12
|
+
ErrNotADirectory = errors.New("target is not a directory")
|
|
13
|
+
ErrPermissionDenied = errors.New("permission denied")
|
|
14
|
+
ErrAlreadyInit = errors.New("target already contains .savepoint directory")
|
|
15
|
+
ErrConflict = errors.New("target has conflicting files")
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
var conflictingFiles = []string{
|
|
19
|
+
"AGENTS.md",
|
|
20
|
+
"agent-skills",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ValidationError struct {
|
|
24
|
+
Type error
|
|
25
|
+
Message string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func (e *ValidationError) Error() string { return e.Message }
|
|
29
|
+
func (e *ValidationError) Unwrap() error { return e.Type }
|
|
30
|
+
|
|
31
|
+
func ValidateTarget(path string, force bool) error {
|
|
32
|
+
abs, err := filepath.Abs(path)
|
|
33
|
+
if err != nil {
|
|
34
|
+
return &ValidationError{Type: ErrPermissionDenied, Message: fmt.Sprintf("cannot resolve path %q: permission denied", path)}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
info, err := os.Stat(abs)
|
|
38
|
+
if err != nil {
|
|
39
|
+
if os.IsNotExist(err) {
|
|
40
|
+
return &ValidationError{Type: ErrTargetMissing, Message: fmt.Sprintf("target directory %q does not exist", path)}
|
|
41
|
+
}
|
|
42
|
+
if os.IsPermission(err) {
|
|
43
|
+
return &ValidationError{Type: ErrPermissionDenied, Message: fmt.Sprintf("permission denied accessing %q", path)}
|
|
44
|
+
}
|
|
45
|
+
return &ValidationError{Type: ErrPermissionDenied, Message: fmt.Sprintf("cannot access %q: %v", path, err)}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if !info.IsDir() {
|
|
49
|
+
return &ValidationError{Type: ErrNotADirectory, Message: fmt.Sprintf("target %q is not a directory", path)}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if err := checkWritable(abs); err != nil {
|
|
53
|
+
return err
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
savepointDir := filepath.Join(abs, ".savepoint")
|
|
57
|
+
if _, err := os.Stat(savepointDir); err == nil {
|
|
58
|
+
if !force {
|
|
59
|
+
return &ValidationError{Type: ErrAlreadyInit, Message: fmt.Sprintf("target %q already contains a .savepoint directory (use --force to overwrite)", path)}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if !force {
|
|
64
|
+
for _, name := range conflictingFiles {
|
|
65
|
+
conflictPath := filepath.Join(abs, name)
|
|
66
|
+
if _, err := os.Stat(conflictPath); err == nil {
|
|
67
|
+
return &ValidationError{Type: ErrConflict, Message: fmt.Sprintf("target %q has conflicting file %q (use --force to overwrite)", path, name)}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return nil
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func checkWritable(dir string) error {
|
|
76
|
+
testFile := filepath.Join(dir, ".savepoint-write-test")
|
|
77
|
+
if err := os.WriteFile(testFile, []byte{}, 0644); err != nil {
|
|
78
|
+
if os.IsPermission(err) {
|
|
79
|
+
return &ValidationError{Type: ErrPermissionDenied, Message: fmt.Sprintf("target directory %q is not writable", dir)}
|
|
80
|
+
}
|
|
81
|
+
return &ValidationError{Type: ErrPermissionDenied, Message: fmt.Sprintf("cannot write to %q: %v", dir, err)}
|
|
82
|
+
}
|
|
83
|
+
os.Remove(testFile)
|
|
84
|
+
return nil
|
|
85
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
package init
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/opencode/savepoint/internal/testutil"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestValidateTarget_missing(t *testing.T) {
|
|
13
|
+
err := ValidateTarget(filepath.Join(t.TempDir(), "nonexistent"), false)
|
|
14
|
+
if err == nil {
|
|
15
|
+
t.Fatal("ValidateTarget() expected error for missing directory")
|
|
16
|
+
}
|
|
17
|
+
if !errors.Is(err, ErrTargetMissing) {
|
|
18
|
+
t.Fatalf("ValidateTarget() error type = %v, want ErrTargetMissing", err)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func TestValidateTarget_notADirectory(t *testing.T) {
|
|
23
|
+
dir := t.TempDir()
|
|
24
|
+
filePath := filepath.Join(dir, "file")
|
|
25
|
+
testutil.WriteFile(t, filePath, "content")
|
|
26
|
+
|
|
27
|
+
err := ValidateTarget(filePath, false)
|
|
28
|
+
if err == nil {
|
|
29
|
+
t.Fatal("ValidateTarget() expected error for non-directory")
|
|
30
|
+
}
|
|
31
|
+
if !errors.Is(err, ErrNotADirectory) {
|
|
32
|
+
t.Fatalf("ValidateTarget() error type = %v, want ErrNotADirectory", err)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func TestValidateTarget_empty(t *testing.T) {
|
|
37
|
+
dir := t.TempDir()
|
|
38
|
+
|
|
39
|
+
err := ValidateTarget(dir, false)
|
|
40
|
+
if err != nil {
|
|
41
|
+
t.Fatalf("ValidateTarget() error = %v, want nil for empty directory", err)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func TestValidateTarget_hasCompatibleFiles(t *testing.T) {
|
|
46
|
+
dir := t.TempDir()
|
|
47
|
+
for _, name := range []string{"package.json", ".git", "README.md"} {
|
|
48
|
+
testutil.WriteFile(t, filepath.Join(dir, name), "")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
err := ValidateTarget(dir, false)
|
|
52
|
+
if err != nil {
|
|
53
|
+
t.Fatalf("ValidateTarget() error = %v, want nil for compatible files", err)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func TestValidateTarget_existingSavepoint(t *testing.T) {
|
|
58
|
+
dir := t.TempDir()
|
|
59
|
+
savepointDir := filepath.Join(dir, ".savepoint")
|
|
60
|
+
if err := os.Mkdir(savepointDir, 0755); err != nil {
|
|
61
|
+
t.Fatal(err)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
err := ValidateTarget(dir, false)
|
|
65
|
+
if err == nil {
|
|
66
|
+
t.Fatal("ValidateTarget() expected error for existing .savepoint")
|
|
67
|
+
}
|
|
68
|
+
if !errors.Is(err, ErrAlreadyInit) {
|
|
69
|
+
t.Fatalf("ValidateTarget() error type = %v, want ErrAlreadyInit", err)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func TestValidateTarget_existingSavepointWithForce(t *testing.T) {
|
|
74
|
+
dir := t.TempDir()
|
|
75
|
+
savepointDir := filepath.Join(dir, ".savepoint")
|
|
76
|
+
if err := os.Mkdir(savepointDir, 0755); err != nil {
|
|
77
|
+
t.Fatal(err)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
err := ValidateTarget(dir, true)
|
|
81
|
+
if err != nil {
|
|
82
|
+
t.Fatalf("ValidateTarget() with --force error = %v, want nil", err)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func TestValidateTarget_conflictingFile(t *testing.T) {
|
|
87
|
+
dir := t.TempDir()
|
|
88
|
+
testutil.WriteFile(t, filepath.Join(dir, "AGENTS.md"), "existing")
|
|
89
|
+
|
|
90
|
+
err := ValidateTarget(dir, false)
|
|
91
|
+
if err == nil {
|
|
92
|
+
t.Fatal("ValidateTarget() expected error for conflicting AGENTS.md")
|
|
93
|
+
}
|
|
94
|
+
if !errors.Is(err, ErrConflict) {
|
|
95
|
+
t.Fatalf("ValidateTarget() error type = %v, want ErrConflict", err)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func TestValidateTarget_conflictingFileWithForce(t *testing.T) {
|
|
100
|
+
dir := t.TempDir()
|
|
101
|
+
testutil.WriteFile(t, filepath.Join(dir, "AGENTS.md"), "existing")
|
|
102
|
+
|
|
103
|
+
err := ValidateTarget(dir, true)
|
|
104
|
+
if err != nil {
|
|
105
|
+
t.Fatalf("ValidateTarget() with --force error = %v, want nil", err)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestValidateTarget_conflictingAgentSkillsDirectory(t *testing.T) {
|
|
110
|
+
dir := t.TempDir()
|
|
111
|
+
if err := os.Mkdir(filepath.Join(dir, "agent-skills"), 0755); err != nil {
|
|
112
|
+
t.Fatal(err)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
err := ValidateTarget(dir, false)
|
|
116
|
+
if err == nil {
|
|
117
|
+
t.Fatal("ValidateTarget() expected error for conflicting agent-skills directory")
|
|
118
|
+
}
|
|
119
|
+
if !errors.Is(err, ErrConflict) {
|
|
120
|
+
t.Fatalf("ValidateTarget() error type = %v, want ErrConflict", err)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func TestValidateTarget_conflictingAgentSkillsDirectoryWithForce(t *testing.T) {
|
|
125
|
+
dir := t.TempDir()
|
|
126
|
+
if err := os.Mkdir(filepath.Join(dir, "agent-skills"), 0755); err != nil {
|
|
127
|
+
t.Fatal(err)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
err := ValidateTarget(dir, true)
|
|
131
|
+
if err != nil {
|
|
132
|
+
t.Fatalf("ValidateTarget() with --force error = %v, want nil", err)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func TestValidateTarget_emptyStringResolvesToDot(t *testing.T) {
|
|
137
|
+
err := ValidateTarget("", false)
|
|
138
|
+
if err != nil {
|
|
139
|
+
t.Fatalf("ValidateTarget(\"\") error = %v, want nil (resolves to cwd)", err)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package init
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func AtomicWrite(target string, content []byte) error {
|
|
11
|
+
dir := filepath.Dir(target)
|
|
12
|
+
tmp, err := os.CreateTemp(dir, ".tmp-*.write")
|
|
13
|
+
if err != nil {
|
|
14
|
+
return fmt.Errorf("create temp file: %w", err)
|
|
15
|
+
}
|
|
16
|
+
tmpName := tmp.Name()
|
|
17
|
+
|
|
18
|
+
success := false
|
|
19
|
+
defer func() {
|
|
20
|
+
if !success {
|
|
21
|
+
tmp.Close()
|
|
22
|
+
os.Remove(tmpName)
|
|
23
|
+
}
|
|
24
|
+
}()
|
|
25
|
+
|
|
26
|
+
if _, err := tmp.Write(content); err != nil {
|
|
27
|
+
return fmt.Errorf("write temp file: %w", err)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if err := tmp.Sync(); err != nil {
|
|
31
|
+
return fmt.Errorf("sync temp file: %w", err)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if err := tmp.Close(); err != nil {
|
|
35
|
+
return fmt.Errorf("close temp file: %w", err)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if err := replaceFile(tmpName, target); err != nil {
|
|
39
|
+
return fmt.Errorf("replace target with temp file: %w", err)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
success = true
|
|
43
|
+
return nil
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func replaceFile(tmpName, target string) error {
|
|
47
|
+
if err := os.Rename(tmpName, target); err == nil {
|
|
48
|
+
return nil
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
src, err := os.Open(tmpName)
|
|
52
|
+
if err != nil {
|
|
53
|
+
return fmt.Errorf("open temp file: %w", err)
|
|
54
|
+
}
|
|
55
|
+
defer src.Close()
|
|
56
|
+
|
|
57
|
+
dst, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
|
58
|
+
if err != nil {
|
|
59
|
+
return fmt.Errorf("create target file: %w", err)
|
|
60
|
+
}
|
|
61
|
+
defer dst.Close()
|
|
62
|
+
|
|
63
|
+
if _, err := io.Copy(dst, src); err != nil {
|
|
64
|
+
return fmt.Errorf("copy content: %w", err)
|
|
65
|
+
}
|
|
66
|
+
if err := dst.Sync(); err != nil {
|
|
67
|
+
return fmt.Errorf("sync target: %w", err)
|
|
68
|
+
}
|
|
69
|
+
if err := os.Remove(tmpName); err != nil {
|
|
70
|
+
return fmt.Errorf("remove temp file: %w", err)
|
|
71
|
+
}
|
|
72
|
+
return nil
|
|
73
|
+
}
|