savepoint 1.0.2 → 1.0.4

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.
Files changed (242) hide show
  1. package/.claude/settings.local.json +12 -1
  2. package/.github/workflows/ci.yml +20 -0
  3. package/.golangci.yml +11 -0
  4. package/.savepoint/Design.md +40 -38
  5. package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
  6. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
  7. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
  8. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
  9. package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
  10. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
  11. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
  12. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
  13. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
  14. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
  15. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
  16. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
  17. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
  18. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
  19. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
  20. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
  21. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
  22. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
  23. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
  24. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
  25. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
  26. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
  27. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
  28. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
  29. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
  30. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
  31. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
  32. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
  33. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
  34. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
  35. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
  36. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
  37. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
  38. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
  39. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
  40. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
  41. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
  42. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
  43. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
  44. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
  45. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
  46. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
  47. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
  48. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
  49. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
  50. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
  51. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
  52. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
  53. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
  54. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
  55. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
  56. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
  57. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
  58. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
  59. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
  60. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
  61. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
  62. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
  63. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
  64. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
  65. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
  66. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
  67. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
  68. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
  69. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
  70. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
  71. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
  72. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
  73. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
  74. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
  75. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
  76. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
  77. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
  78. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
  79. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
  80. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
  81. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
  82. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
  83. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
  84. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
  85. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Audit.md +272 -0
  86. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +60 -0
  87. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
  88. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +34 -0
  89. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
  90. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
  91. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
  92. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
  93. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +33 -0
  94. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T008-ci-and-release-automation.md +46 -0
  95. package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
  96. package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
  97. package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
  98. package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
  99. package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
  100. package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
  101. package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
  102. package/.savepoint/router.md +10 -17
  103. package/AGENTS.md +39 -24
  104. package/Makefile +3 -1
  105. package/README.md +0 -1
  106. package/agent-skills/savepoint-audit/SKILL.md +86 -34
  107. package/agent-skills/savepoint-build-task/SKILL.md +7 -2
  108. package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
  109. package/agent-skills/savepoint-create-task/SKILL.md +44 -31
  110. package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
  111. package/agent-skills/savepoint-system-design/SKILL.md +7 -2
  112. package/agent_skills_test.go +91 -0
  113. package/cmd/board.go +59 -0
  114. package/cmd/board_test.go +137 -0
  115. package/cmd/doctor.go +53 -0
  116. package/cmd/doctor_test.go +146 -0
  117. package/cmd/init.go +63 -0
  118. package/cmd/init_test.go +104 -0
  119. package/internal/board/board.go +44 -36
  120. package/internal/board/board_test.go +27 -82
  121. package/internal/board/card.go +43 -23
  122. package/internal/board/card_test.go +74 -5
  123. package/internal/board/column.go +75 -15
  124. package/internal/board/column_test.go +76 -2
  125. package/internal/board/debug.go +26 -0
  126. package/internal/board/debug_test.go +108 -0
  127. package/internal/board/detail.go +33 -47
  128. package/internal/board/detail_test.go +48 -0
  129. package/internal/board/epic_panel.go +120 -22
  130. package/internal/board/epic_panel_test.go +302 -17
  131. package/internal/board/help.go +1 -0
  132. package/internal/board/help_test.go +1 -0
  133. package/internal/board/integration_test.go +266 -0
  134. package/internal/board/interfaces.go +65 -0
  135. package/internal/board/interfaces_test.go +114 -0
  136. package/internal/board/io.go +93 -0
  137. package/internal/board/model.go +79 -118
  138. package/internal/board/plain.go +88 -0
  139. package/internal/board/plain_test.go +117 -0
  140. package/internal/board/release.go +1 -9
  141. package/internal/board/release_test.go +6 -6
  142. package/internal/board/status.go +4 -4
  143. package/internal/board/theme.go +24 -0
  144. package/internal/board/theme_test.go +31 -0
  145. package/internal/board/transitions.go +113 -88
  146. package/internal/board/transitions_test.go +164 -141
  147. package/internal/board/tui.go +32 -0
  148. package/internal/board/update.go +344 -215
  149. package/internal/board/update_test.go +326 -18
  150. package/internal/board/util.go +76 -0
  151. package/internal/board/view.go +31 -28
  152. package/internal/board/view_test.go +74 -2
  153. package/internal/board/watch.go +41 -5
  154. package/internal/buildtool/main.go +45 -15
  155. package/internal/buildtool/main_test.go +224 -0
  156. package/internal/data/config.go +17 -3
  157. package/internal/data/config_test.go +49 -0
  158. package/internal/data/discover.go +26 -0
  159. package/internal/data/discover_test.go +34 -10
  160. package/internal/data/errors.go +4 -0
  161. package/internal/data/fuzz_test.go +75 -0
  162. package/internal/data/lifecycle.go +13 -6
  163. package/internal/data/lifecycle_test.go +14 -11
  164. package/internal/data/parser.go +22 -6
  165. package/internal/data/parser_test.go +31 -7
  166. package/internal/data/task.go +0 -9
  167. package/internal/data/testdata/fuzz/FuzzSplitFrontmatterBody/68eb66b0fe91e7e3 +2 -0
  168. package/internal/data/write.go +88 -11
  169. package/internal/data/write_test.go +167 -0
  170. package/internal/doctor/checks.go +567 -0
  171. package/internal/doctor/checks_test.go +716 -0
  172. package/internal/doctor/gates.go +193 -0
  173. package/internal/doctor/gates_test.go +166 -0
  174. package/internal/doctor/interfaces.go +64 -0
  175. package/internal/doctor/interfaces_test.go +104 -0
  176. package/internal/doctor/repairs.go +80 -0
  177. package/internal/doctor/repairs_test.go +81 -0
  178. package/internal/doctor/report.go +157 -0
  179. package/internal/doctor/report_test.go +89 -0
  180. package/internal/init/clipboard.go +146 -0
  181. package/internal/init/clipboard_test.go +74 -0
  182. package/internal/init/install.go +16 -0
  183. package/internal/init/integration_test.go +197 -0
  184. package/internal/init/prompt.go +14 -0
  185. package/internal/init/prompt_test.go +77 -0
  186. package/internal/init/scaffold.go +59 -0
  187. package/internal/init/scaffold_test.go +179 -0
  188. package/internal/init/template_freshness_test.go +56 -0
  189. package/internal/init/validate.go +85 -0
  190. package/internal/init/validate_test.go +141 -0
  191. package/internal/init/write.go +73 -0
  192. package/internal/init/write_test.go +91 -0
  193. package/internal/styles/styles_test.go +133 -0
  194. package/internal/testutil/fixture.go +113 -0
  195. package/internal/testutil/fs.go +26 -0
  196. package/main.go +120 -4
  197. package/package.json +2 -2
  198. package/project-audit/audit_report_glm_5.1.md +411 -0
  199. package/project-audit/audit_report_opus_4.6.md +406 -0
  200. package/project-audit/consolidated-audit-report.md +456 -0
  201. package/templates/project/.savepoint/Design.md +2 -2
  202. package/templates/project/.savepoint/router.md +10 -10
  203. package/templates/project/AGENTS.md +33 -21
  204. package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
  205. package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
  206. package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
  207. package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
  208. package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
  209. package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
  210. package/templates/prompts/audit-reconciliation.prompt.md +33 -28
  211. package/templates/prompts/design.prompt.md +3 -1
  212. package/.savepoint/audit/v1/E01/proposals.md +0 -168
  213. package/.savepoint/audit/v1/E01/snapshot.md +0 -78
  214. package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
  215. package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
  216. package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
  217. package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
  218. package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
  219. package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
  220. package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
  221. package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
  222. package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
  223. package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
  224. package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
  225. package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
  226. package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
  227. package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
  228. package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
  229. package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
  230. package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
  231. package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
  232. package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
  233. package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
  234. package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
  235. package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
  236. package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
  237. package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
  238. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
  239. package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
  240. package/ink-cli-ui-design.zip +0 -0
  241. package/savepoint +0 -0
  242. 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
+ }