@topogram/cli 0.3.34

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 (257) hide show
  1. package/ARCHITECTURE.md +67 -0
  2. package/CHANGELOG.md +240 -0
  3. package/README.md +223 -0
  4. package/package.json +51 -0
  5. package/src/adoption/index.js +3 -0
  6. package/src/adoption/plan.js +702 -0
  7. package/src/adoption/reporting.js +464 -0
  8. package/src/adoption/review-groups.js +313 -0
  9. package/src/agent-ops/query-builders.js +5012 -0
  10. package/src/archive/archive.js +141 -0
  11. package/src/archive/compact.js +26 -0
  12. package/src/archive/jsonl.js +70 -0
  13. package/src/archive/resolver-bridge.js +82 -0
  14. package/src/archive/schema.js +87 -0
  15. package/src/archive/unarchive.js +108 -0
  16. package/src/catalog.js +752 -0
  17. package/src/cli/catalog-alias.js +166 -0
  18. package/src/cli.js +9738 -0
  19. package/src/component-behavior.js +173 -0
  20. package/src/example-implementation.js +91 -0
  21. package/src/format.js +19 -0
  22. package/src/generator/adapters.d.ts +4 -0
  23. package/src/generator/adapters.js +325 -0
  24. package/src/generator/api.d.ts +1 -0
  25. package/src/generator/api.js +1196 -0
  26. package/src/generator/check.js +355 -0
  27. package/src/generator/component-conformance.js +767 -0
  28. package/src/generator/components.js +39 -0
  29. package/src/generator/context/bundle.js +291 -0
  30. package/src/generator/context/diff.js +256 -0
  31. package/src/generator/context/digest.js +182 -0
  32. package/src/generator/context/domain-coverage.js +94 -0
  33. package/src/generator/context/domain-page.js +137 -0
  34. package/src/generator/context/index.js +42 -0
  35. package/src/generator/context/report.js +121 -0
  36. package/src/generator/context/shared.js +1397 -0
  37. package/src/generator/context/slice.js +703 -0
  38. package/src/generator/context/task-mode.js +466 -0
  39. package/src/generator/docs.js +327 -0
  40. package/src/generator/index.js +161 -0
  41. package/src/generator/native/parity-bundle.js +311 -0
  42. package/src/generator/output.js +300 -0
  43. package/src/generator/registry.js +482 -0
  44. package/src/generator/runtime/app-bundle.js +456 -0
  45. package/src/generator/runtime/bundle-shared.js +166 -0
  46. package/src/generator/runtime/compile-check.js +163 -0
  47. package/src/generator/runtime/deployment.js +287 -0
  48. package/src/generator/runtime/environment.js +635 -0
  49. package/src/generator/runtime/index.js +32 -0
  50. package/src/generator/runtime/runtime-check.js +554 -0
  51. package/src/generator/runtime/shared.js +515 -0
  52. package/src/generator/runtime/smoke.js +219 -0
  53. package/src/generator/schema.js +204 -0
  54. package/src/generator/sdlc/board.js +66 -0
  55. package/src/generator/sdlc/doc-page.js +53 -0
  56. package/src/generator/sdlc/index.js +23 -0
  57. package/src/generator/sdlc/release-notes.js +62 -0
  58. package/src/generator/sdlc/traceability-matrix.js +65 -0
  59. package/src/generator/shared.js +29 -0
  60. package/src/generator/surfaces/contracts.js +146 -0
  61. package/src/generator/surfaces/databases/contract.js +40 -0
  62. package/src/generator/surfaces/databases/index.js +84 -0
  63. package/src/generator/surfaces/databases/lifecycle-shared.d.ts +1 -0
  64. package/src/generator/surfaces/databases/lifecycle-shared.js +612 -0
  65. package/src/generator/surfaces/databases/migration-plan.js +281 -0
  66. package/src/generator/surfaces/databases/postgres/capabilities.js +14 -0
  67. package/src/generator/surfaces/databases/postgres/drizzle.js +99 -0
  68. package/src/generator/surfaces/databases/postgres/index.js +9 -0
  69. package/src/generator/surfaces/databases/postgres/lifecycle.js +16 -0
  70. package/src/generator/surfaces/databases/postgres/prisma.js +159 -0
  71. package/src/generator/surfaces/databases/postgres/sql-migration.js +102 -0
  72. package/src/generator/surfaces/databases/postgres/sql-schema.js +34 -0
  73. package/src/generator/surfaces/databases/shared.d.ts +1 -0
  74. package/src/generator/surfaces/databases/shared.js +350 -0
  75. package/src/generator/surfaces/databases/snapshot.js +96 -0
  76. package/src/generator/surfaces/databases/sqlite/capabilities.js +14 -0
  77. package/src/generator/surfaces/databases/sqlite/index.js +8 -0
  78. package/src/generator/surfaces/databases/sqlite/lifecycle.js +16 -0
  79. package/src/generator/surfaces/databases/sqlite/prisma.js +143 -0
  80. package/src/generator/surfaces/databases/sqlite/sql-migration.js +65 -0
  81. package/src/generator/surfaces/databases/sqlite/sql-schema.js +27 -0
  82. package/src/generator/surfaces/index.js +25 -0
  83. package/src/generator/surfaces/native/swiftui-app.js +38 -0
  84. package/src/generator/surfaces/native/swiftui-templates/Package.swift.txt +20 -0
  85. package/src/generator/surfaces/native/swiftui-templates/README.generated.md +26 -0
  86. package/src/generator/surfaces/native/swiftui-templates/runtime/DynamicScreens.swift +682 -0
  87. package/src/generator/surfaces/native/swiftui-templates/runtime/TodoAPIClient.swift +156 -0
  88. package/src/generator/surfaces/native/swiftui-templates/runtime/TodoSwiftUIApp.swift +44 -0
  89. package/src/generator/surfaces/native/swiftui-templates/runtime/Visibility.swift +183 -0
  90. package/src/generator/surfaces/services/express.d.ts +1 -0
  91. package/src/generator/surfaces/services/express.js +766 -0
  92. package/src/generator/surfaces/services/hono.d.ts +1 -0
  93. package/src/generator/surfaces/services/hono.js +204 -0
  94. package/src/generator/surfaces/services/index.js +42 -0
  95. package/src/generator/surfaces/services/persistence-wiring.js +240 -0
  96. package/src/generator/surfaces/services/runtime-helpers.js +631 -0
  97. package/src/generator/surfaces/services/server-contract.js +80 -0
  98. package/src/generator/surfaces/services/stateless.d.ts +1 -0
  99. package/src/generator/surfaces/services/stateless.js +97 -0
  100. package/src/generator/surfaces/shared.js +64 -0
  101. package/src/generator/surfaces/web/api-client.js +1 -0
  102. package/src/generator/surfaces/web/forms.js +1 -0
  103. package/src/generator/surfaces/web/index.d.ts +2 -0
  104. package/src/generator/surfaces/web/index.js +53 -0
  105. package/src/generator/surfaces/web/react-components.js +248 -0
  106. package/src/generator/surfaces/web/react.js +538 -0
  107. package/src/generator/surfaces/web/routes.js +1 -0
  108. package/src/generator/surfaces/web/screens.js +1 -0
  109. package/src/generator/surfaces/web/shared.js +369 -0
  110. package/src/generator/surfaces/web/sveltekit-actions.js +28 -0
  111. package/src/generator/surfaces/web/sveltekit-components.js +234 -0
  112. package/src/generator/surfaces/web/sveltekit.js +426 -0
  113. package/src/generator/surfaces/web/ui-web-contract.js +65 -0
  114. package/src/generator/surfaces/web/vanilla.js +239 -0
  115. package/src/generator/verification.js +84 -0
  116. package/src/generator.js +1 -0
  117. package/src/import/core/context.js +52 -0
  118. package/src/import/core/contracts.js +23 -0
  119. package/src/import/core/registry.js +81 -0
  120. package/src/import/core/runner.js +646 -0
  121. package/src/import/core/shared.js +910 -0
  122. package/src/import/enrichers/auth-session.js +18 -0
  123. package/src/import/enrichers/django-rest.js +226 -0
  124. package/src/import/enrichers/doc-linking.js +20 -0
  125. package/src/import/enrichers/rails-controllers.js +246 -0
  126. package/src/import/enrichers/rails-models.js +130 -0
  127. package/src/import/enrichers/workflow-target-state.js +10 -0
  128. package/src/import/extractors/api/aspnet-core.js +304 -0
  129. package/src/import/extractors/api/django-routes.js +318 -0
  130. package/src/import/extractors/api/express.js +154 -0
  131. package/src/import/extractors/api/fastify.js +371 -0
  132. package/src/import/extractors/api/flutter-dio.js +135 -0
  133. package/src/import/extractors/api/generic-route-fallback.js +90 -0
  134. package/src/import/extractors/api/graphql-code-first.js +565 -0
  135. package/src/import/extractors/api/graphql-sdl.js +309 -0
  136. package/src/import/extractors/api/jaxrs.js +303 -0
  137. package/src/import/extractors/api/micronaut.js +213 -0
  138. package/src/import/extractors/api/next-route.js +50 -0
  139. package/src/import/extractors/api/next-server-action.js +51 -0
  140. package/src/import/extractors/api/nextauth.js +52 -0
  141. package/src/import/extractors/api/openapi-code.js +242 -0
  142. package/src/import/extractors/api/openapi.js +232 -0
  143. package/src/import/extractors/api/rails-routes.js +230 -0
  144. package/src/import/extractors/api/react-native-repository.js +128 -0
  145. package/src/import/extractors/api/retrofit.js +103 -0
  146. package/src/import/extractors/api/spring-web.js +372 -0
  147. package/src/import/extractors/api/swift-webapi.js +116 -0
  148. package/src/import/extractors/api/trpc.js +212 -0
  149. package/src/import/extractors/db/django-models.js +232 -0
  150. package/src/import/extractors/db/dotnet-models.js +93 -0
  151. package/src/import/extractors/db/drizzle.js +242 -0
  152. package/src/import/extractors/db/ef-core.js +221 -0
  153. package/src/import/extractors/db/flutter-entities.js +120 -0
  154. package/src/import/extractors/db/jpa.js +120 -0
  155. package/src/import/extractors/db/liquibase.js +180 -0
  156. package/src/import/extractors/db/mybatis-xml.js +145 -0
  157. package/src/import/extractors/db/prisma.js +185 -0
  158. package/src/import/extractors/db/rails-schema.js +175 -0
  159. package/src/import/extractors/db/react-native-entities.js +95 -0
  160. package/src/import/extractors/db/room.js +193 -0
  161. package/src/import/extractors/db/snapshot.js +112 -0
  162. package/src/import/extractors/db/sql.js +180 -0
  163. package/src/import/extractors/db/swiftdata.js +137 -0
  164. package/src/import/extractors/ui/android-compose.js +230 -0
  165. package/src/import/extractors/ui/backend-only.js +70 -0
  166. package/src/import/extractors/ui/blazor.js +227 -0
  167. package/src/import/extractors/ui/flutter-screens.js +152 -0
  168. package/src/import/extractors/ui/maui-xaml.js +135 -0
  169. package/src/import/extractors/ui/next-app-router.js +83 -0
  170. package/src/import/extractors/ui/next-pages-router.js +141 -0
  171. package/src/import/extractors/ui/razor-pages.js +181 -0
  172. package/src/import/extractors/ui/react-native-screens.js +166 -0
  173. package/src/import/extractors/ui/react-router.js +139 -0
  174. package/src/import/extractors/ui/sveltekit.js +123 -0
  175. package/src/import/extractors/ui/swiftui.js +193 -0
  176. package/src/import/extractors/ui/uikit.js +175 -0
  177. package/src/import/extractors/verification/generic.js +290 -0
  178. package/src/import/extractors/workflows/generic.js +137 -0
  179. package/src/import/index.js +7 -0
  180. package/src/import/provenance.js +158 -0
  181. package/src/new-project.js +2107 -0
  182. package/src/parser.js +439 -0
  183. package/src/policy/review-boundaries.js +165 -0
  184. package/src/project-config.js +535 -0
  185. package/src/proofs/backend-parity.js +19 -0
  186. package/src/proofs/contract-audit.js +220 -0
  187. package/src/proofs/ios-parity.js +7 -0
  188. package/src/proofs/issues-parity.js +10 -0
  189. package/src/proofs/web-parity.js +50 -0
  190. package/src/realization/api/build-api-realization.js +5 -0
  191. package/src/realization/api/index.js +1 -0
  192. package/src/realization/backend/build-backend-runtime-realization.js +82 -0
  193. package/src/realization/backend/index.d.ts +1 -0
  194. package/src/realization/backend/index.js +4 -0
  195. package/src/realization/db/build-db-realization.js +17 -0
  196. package/src/realization/db/index.js +3 -0
  197. package/src/realization/db/migration-plan.js +5 -0
  198. package/src/realization/db/snapshot.js +5 -0
  199. package/src/realization/ui/build-ui-shared-realization.js +305 -0
  200. package/src/realization/ui/build-web-realization.js +189 -0
  201. package/src/realization/ui/index.js +2 -0
  202. package/src/reconcile/docs.js +280 -0
  203. package/src/reconcile/index.js +3 -0
  204. package/src/reconcile/journeys.js +441 -0
  205. package/src/resolver/docs.js +1 -0
  206. package/src/resolver/enrich/acceptance-criterion.js +14 -0
  207. package/src/resolver/enrich/bug.js +12 -0
  208. package/src/resolver/enrich/component.js +2 -0
  209. package/src/resolver/enrich/index.js +1 -0
  210. package/src/resolver/enrich/pitch.js +18 -0
  211. package/src/resolver/enrich/requirement.js +20 -0
  212. package/src/resolver/enrich/task.js +16 -0
  213. package/src/resolver/expressions.js +1 -0
  214. package/src/resolver/index.js +2422 -0
  215. package/src/resolver/normalize.js +1 -0
  216. package/src/resolver.js +1 -0
  217. package/src/sdlc/adopt.js +65 -0
  218. package/src/sdlc/check.js +86 -0
  219. package/src/sdlc/dod/acceptance-criterion.js +22 -0
  220. package/src/sdlc/dod/bug.js +26 -0
  221. package/src/sdlc/dod/document.js +23 -0
  222. package/src/sdlc/dod/index.js +25 -0
  223. package/src/sdlc/dod/pitch.js +23 -0
  224. package/src/sdlc/dod/requirement.js +34 -0
  225. package/src/sdlc/dod/task.js +39 -0
  226. package/src/sdlc/explain.js +116 -0
  227. package/src/sdlc/history.js +80 -0
  228. package/src/sdlc/paths.js +11 -0
  229. package/src/sdlc/release.js +106 -0
  230. package/src/sdlc/scaffold.js +89 -0
  231. package/src/sdlc/status-filter.js +54 -0
  232. package/src/sdlc/transition.js +112 -0
  233. package/src/sdlc/transitions/acceptance-criterion.js +28 -0
  234. package/src/sdlc/transitions/bug.js +31 -0
  235. package/src/sdlc/transitions/document.js +29 -0
  236. package/src/sdlc/transitions/index.js +56 -0
  237. package/src/sdlc/transitions/pitch.js +34 -0
  238. package/src/sdlc/transitions/requirement.js +31 -0
  239. package/src/sdlc/transitions/task.js +34 -0
  240. package/src/template-trust.js +597 -0
  241. package/src/validator/expressions.js +1 -0
  242. package/src/validator/index.js +3424 -0
  243. package/src/validator/kinds.js +346 -0
  244. package/src/validator/per-kind/acceptance-criterion.js +91 -0
  245. package/src/validator/per-kind/bug.js +77 -0
  246. package/src/validator/per-kind/component.js +274 -0
  247. package/src/validator/per-kind/domain.js +205 -0
  248. package/src/validator/per-kind/pitch.js +101 -0
  249. package/src/validator/per-kind/requirement.js +75 -0
  250. package/src/validator/per-kind/task.js +96 -0
  251. package/src/validator/registry.js +1 -0
  252. package/src/validator/utils.js +12 -0
  253. package/src/validator.js +1 -0
  254. package/src/workflows.js +7597 -0
  255. package/src/workspace-docs.js +265 -0
  256. package/template-helpers/react.js +5 -0
  257. package/template-helpers/sveltekit.js +5 -0
@@ -0,0 +1,2107 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs";
4
+ import childProcess from "node:child_process";
5
+ import crypto from "node:crypto";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ import { writeTemplateTrustRecord } from "./template-trust.js";
10
+
11
+ const CLI_PACKAGE_NAME = "@topogram/cli";
12
+ const DEFAULT_TEMPLATE_NAME = "hello-web";
13
+ const TEMPLATE_MANIFEST = "topogram-template.json";
14
+ const TEMPLATE_FILES_MANIFEST = ".topogram-template-files.json";
15
+ const TEMPLATE_POLICY_FILE = "topogram.template-policy.json";
16
+ const MAX_TEXT_DIFF_BYTES = 256 * 1024;
17
+
18
+ const GENERATOR_LABELS = new Map([
19
+ ["topogram/express", "Express"],
20
+ ["topogram/hono", "Hono"],
21
+ ["topogram/postgres", "Postgres"],
22
+ ["topogram/react", "React"],
23
+ ["topogram/sqlite", "SQLite"],
24
+ ["topogram/sveltekit", "SvelteKit"],
25
+ ["topogram/vanilla-web", "Vanilla HTML/CSS/JS"]
26
+ ]);
27
+
28
+ const SURFACE_ORDER = new Map([
29
+ ["web", 10],
30
+ ["api", 20],
31
+ ["database", 30],
32
+ ["native", 40]
33
+ ]);
34
+
35
+ /**
36
+ * @typedef {Object} CreateNewProjectOptions
37
+ * @property {string} targetPath
38
+ * @property {string} [templateName]
39
+ * @property {string} engineRoot
40
+ * @property {string} templatesRoot
41
+ * @property {CatalogTemplateProvenance|null} [templateProvenance]
42
+ */
43
+
44
+ /**
45
+ * @typedef {Object} TemplateUpdatePlanOptions
46
+ * @property {string} projectRoot
47
+ * @property {Record<string, any>} projectConfig
48
+ * @property {string|null} [templateName]
49
+ * @property {string} templatesRoot
50
+ */
51
+
52
+ /**
53
+ * @typedef {TemplateUpdatePlanOptions & { filePath: string, action: "accept-current"|"accept-candidate"|"delete-current" }} TemplateUpdateFileActionOptions
54
+ */
55
+
56
+ /**
57
+ * @typedef {Object} TemplateOwnedFileRecord
58
+ * @property {string} path
59
+ * @property {string} sha256
60
+ * @property {number} size
61
+ */
62
+
63
+ /**
64
+ * @typedef {Object} TemplateManifest
65
+ * @property {string} id
66
+ * @property {string} version
67
+ * @property {string} kind
68
+ * @property {string} topogramVersion
69
+ * @property {boolean} [includesExecutableImplementation]
70
+ * @property {string} [description]
71
+ * @property {Record<string, string>} [starterScripts]
72
+ */
73
+
74
+ /**
75
+ * @typedef {Object} TemplateTopologySummary
76
+ * @property {string[]} surfaces
77
+ * @property {string[]} generators
78
+ * @property {string} stack
79
+ */
80
+
81
+ /**
82
+ * @typedef {Object} TemplatePolicy
83
+ * @property {string} version
84
+ * @property {Array<"local"|"package">} allowedSources
85
+ * @property {string[]} allowedTemplateIds
86
+ * @property {string[]} [allowedPackageScopes]
87
+ * @property {"allow"|"warn"|"deny"} executableImplementation
88
+ * @property {Record<string, string>} [pinnedVersions]
89
+ */
90
+
91
+ /**
92
+ * @typedef {Object} TemplatePolicyInfo
93
+ * @property {string} path
94
+ * @property {TemplatePolicy|null} policy
95
+ * @property {boolean} exists
96
+ * @property {TemplateUpdateDiagnostic[]} diagnostics
97
+ */
98
+
99
+ /**
100
+ * @typedef {Object} TemplateUpdateDiagnostic
101
+ * @property {string} code
102
+ * @property {"error"|"warning"} severity
103
+ * @property {string} message
104
+ * @property {string|null} path
105
+ * @property {string|null} suggestedFix
106
+ * @property {string|null} step
107
+ */
108
+
109
+ /**
110
+ * @typedef {Object} ResolvedTemplate
111
+ * @property {string} requested
112
+ * @property {string} root
113
+ * @property {TemplateManifest} manifest
114
+ * @property {"local"|"package"} source
115
+ * @property {string|null} packageSpec
116
+ */
117
+
118
+ /**
119
+ * @typedef {Object} CatalogTemplateProvenance
120
+ * @property {string} id
121
+ * @property {string} source
122
+ * @property {string} package
123
+ * @property {string} version
124
+ * @property {string} packageSpec
125
+ */
126
+
127
+ /**
128
+ * @param {string} projectRoot
129
+ * @returns {string}
130
+ */
131
+ function packageNameFromPath(projectRoot) {
132
+ const baseName = path.basename(path.resolve(projectRoot)).toLowerCase();
133
+ const normalized = baseName
134
+ .replace(/[^a-z0-9._-]+/g, "-")
135
+ .replace(/^[._-]+/, "")
136
+ .replace(/[._-]+$/, "");
137
+ return normalized || "topogram-app";
138
+ }
139
+
140
+ /**
141
+ * @param {string} projectRoot
142
+ * @param {string} engineRoot
143
+ * @returns {string}
144
+ */
145
+ function fileDependencyForEngine(projectRoot, engineRoot) {
146
+ const relative = path.relative(projectRoot, engineRoot).replace(/\\/g, "/");
147
+ if (!relative || relative.startsWith("..")) {
148
+ return `file:${engineRoot}`;
149
+ }
150
+ return `file:./${relative}`;
151
+ }
152
+
153
+ /**
154
+ * @param {string} engineRoot
155
+ * @returns {{ name: string, version: string }}
156
+ */
157
+ function readCliPackageMetadata(engineRoot) {
158
+ const packagePath = path.join(engineRoot, "package.json");
159
+ const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
160
+ return {
161
+ name: typeof pkg.name === "string" ? pkg.name : CLI_PACKAGE_NAME,
162
+ version: typeof pkg.version === "string" ? pkg.version : "0.0.0"
163
+ };
164
+ }
165
+
166
+ /**
167
+ * @param {string} engineRoot
168
+ * @returns {boolean}
169
+ */
170
+ function isSourceCheckoutEngine(engineRoot) {
171
+ return fs.existsSync(path.join(engineRoot, "tests", "active"));
172
+ }
173
+
174
+ /**
175
+ * @param {string} projectRoot
176
+ * @param {string} engineRoot
177
+ * @returns {{ name: string, spec: string }}
178
+ */
179
+ function cliDependencyForProject(projectRoot, engineRoot) {
180
+ const metadata = readCliPackageMetadata(engineRoot);
181
+ const overrideSpec = process.env.TOPOGRAM_CLI_PACKAGE_SPEC || "";
182
+ if (overrideSpec) {
183
+ return { name: metadata.name, spec: overrideSpec };
184
+ }
185
+ if (isSourceCheckoutEngine(engineRoot)) {
186
+ return { name: metadata.name, spec: fileDependencyForEngine(projectRoot, engineRoot) };
187
+ }
188
+ return { name: metadata.name, spec: metadata.version };
189
+ }
190
+
191
+ /**
192
+ * @param {string} projectRoot
193
+ * @param {{ name: string, spec: string }} cliDependency
194
+ * @returns {void}
195
+ */
196
+ function writeProjectNpmConfig(projectRoot, cliDependency) {
197
+ void projectRoot;
198
+ void cliDependency;
199
+ }
200
+
201
+ /**
202
+ * @param {string} templateRoot
203
+ * @returns {Record<string, string>}
204
+ */
205
+ function generatorDependenciesForTemplate(templateRoot) {
206
+ const packagePath = path.join(templateRoot, "package.json");
207
+ if (!fs.existsSync(packagePath)) {
208
+ return {};
209
+ }
210
+ const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
211
+ const explicit = pkg.topogramGeneratorDependencies &&
212
+ typeof pkg.topogramGeneratorDependencies === "object" &&
213
+ !Array.isArray(pkg.topogramGeneratorDependencies)
214
+ ? pkg.topogramGeneratorDependencies
215
+ : {};
216
+ const dependencies = {
217
+ ...(pkg.dependencies || {}),
218
+ ...(pkg.devDependencies || {}),
219
+ ...explicit
220
+ };
221
+ return Object.fromEntries(Object.entries(dependencies).filter(([name, spec]) =>
222
+ typeof name === "string" &&
223
+ (name.includes("topogram-generator") || name.startsWith("@topogram/generator-")) &&
224
+ typeof spec === "string" &&
225
+ spec.length > 0
226
+ ));
227
+ }
228
+
229
+ /**
230
+ * @param {string} parent
231
+ * @param {string} child
232
+ * @returns {boolean}
233
+ */
234
+ function isSameOrInside(parent, child) {
235
+ const relative = path.relative(parent, child);
236
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
237
+ }
238
+
239
+ /**
240
+ * @param {string} value
241
+ * @returns {boolean}
242
+ */
243
+ function isLocalTemplateSpec(value) {
244
+ return value === "." ||
245
+ value.startsWith("./") ||
246
+ value.startsWith("../") ||
247
+ path.isAbsolute(value);
248
+ }
249
+
250
+ /**
251
+ * @param {string} spec
252
+ * @returns {string}
253
+ */
254
+ export function packageNameFromSpec(spec) {
255
+ if (spec.startsWith("@")) {
256
+ const segments = spec.split("/");
257
+ if (segments.length < 2) {
258
+ throw new Error(`Invalid scoped template package spec '${spec}'.`);
259
+ }
260
+ const scope = segments[0];
261
+ const nameAndVersion = segments[1];
262
+ const versionIndex = nameAndVersion.indexOf("@");
263
+ const name = versionIndex >= 0 ? nameAndVersion.slice(0, versionIndex) : nameAndVersion;
264
+ return `${scope}/${name}`;
265
+ }
266
+ const versionIndex = spec.indexOf("@");
267
+ return versionIndex >= 0 ? spec.slice(0, versionIndex) : spec;
268
+ }
269
+
270
+ /**
271
+ * @param {string|null|undefined} spec
272
+ * @returns {string|null}
273
+ */
274
+ export function packageScopeFromSpec(spec) {
275
+ if (!spec) {
276
+ return null;
277
+ }
278
+ const packageName = packageNameFromSpec(spec);
279
+ return packageName.startsWith("@") ? packageName.split("/")[0] : null;
280
+ }
281
+
282
+ /**
283
+ * @param {unknown} value
284
+ * @returns {TemplateManifest}
285
+ */
286
+ function validateTemplateManifest(value) {
287
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
288
+ throw new Error(`${TEMPLATE_MANIFEST} must contain a JSON object.`);
289
+ }
290
+ const manifest = /** @type {Record<string, unknown>} */ (value);
291
+ for (const field of ["id", "version", "kind", "topogramVersion"]) {
292
+ if (typeof manifest[field] !== "string" || !manifest[field]) {
293
+ throw new Error(`${TEMPLATE_MANIFEST} is missing required string field '${field}'.`);
294
+ }
295
+ }
296
+ if (manifest.kind !== "starter") {
297
+ throw new Error(`${TEMPLATE_MANIFEST} kind must be 'starter'.`);
298
+ }
299
+ if (
300
+ Object.prototype.hasOwnProperty.call(manifest, "includesExecutableImplementation") &&
301
+ typeof manifest.includesExecutableImplementation !== "boolean"
302
+ ) {
303
+ throw new Error(`${TEMPLATE_MANIFEST} field 'includesExecutableImplementation' must be a boolean.`);
304
+ }
305
+ if (Object.prototype.hasOwnProperty.call(manifest, "starterScripts")) {
306
+ if (!manifest.starterScripts || typeof manifest.starterScripts !== "object" || Array.isArray(manifest.starterScripts)) {
307
+ throw new Error(`${TEMPLATE_MANIFEST} field 'starterScripts' must be an object of package.json script names to commands.`);
308
+ }
309
+ for (const [scriptName, command] of Object.entries(manifest.starterScripts)) {
310
+ if (typeof scriptName !== "string" || !scriptName.trim() || scriptName.startsWith("-") || scriptName.includes("\n")) {
311
+ throw new Error(`${TEMPLATE_MANIFEST} starterScripts contains an invalid script name.`);
312
+ }
313
+ if (typeof command !== "string" || !command.trim()) {
314
+ throw new Error(`${TEMPLATE_MANIFEST} starterScripts.${scriptName} must be a non-empty string.`);
315
+ }
316
+ }
317
+ }
318
+ return /** @type {TemplateManifest} */ (manifest);
319
+ }
320
+
321
+ /**
322
+ * @param {string} templateRoot
323
+ * @returns {TemplateManifest}
324
+ */
325
+ function readTemplateManifest(templateRoot) {
326
+ const manifestPath = path.join(templateRoot, TEMPLATE_MANIFEST);
327
+ if (!fs.existsSync(manifestPath)) {
328
+ throw new Error(`Template at '${templateRoot}' is missing ${TEMPLATE_MANIFEST}.`);
329
+ }
330
+ return validateTemplateManifest(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
331
+ }
332
+
333
+ /**
334
+ * @param {string} templateRoot
335
+ * @returns {TemplateManifest}
336
+ */
337
+ function validateTemplateRoot(templateRoot) {
338
+ const manifest = readTemplateManifest(templateRoot);
339
+ const topogramRoot = path.join(templateRoot, "topogram");
340
+ const projectConfigPath = path.join(templateRoot, "topogram.project.json");
341
+ if (!fs.existsSync(topogramRoot) || !fs.statSync(topogramRoot).isDirectory()) {
342
+ throw new Error(`Template '${manifest.id}' is missing topogram/.`);
343
+ }
344
+ if (!fs.existsSync(projectConfigPath) || !fs.statSync(projectConfigPath).isFile()) {
345
+ throw new Error(`Template '${manifest.id}' is missing topogram.project.json.`);
346
+ }
347
+ if (manifest.includesExecutableImplementation) {
348
+ const implementationRoot = path.join(templateRoot, "implementation");
349
+ if (!fs.existsSync(implementationRoot) || !fs.statSync(implementationRoot).isDirectory()) {
350
+ throw new Error(
351
+ `Template '${manifest.id}' declares executable implementation code but is missing implementation/.`
352
+ );
353
+ }
354
+ }
355
+ return manifest;
356
+ }
357
+
358
+ /**
359
+ * @param {string} generatorId
360
+ * @returns {string}
361
+ */
362
+ function generatorLabel(generatorId) {
363
+ return GENERATOR_LABELS.get(generatorId) || generatorId.replace(/^topogram\//, "");
364
+ }
365
+
366
+ /**
367
+ * @param {string} templateRoot
368
+ * @returns {TemplateTopologySummary}
369
+ */
370
+ function summarizeTemplateTopology(templateRoot) {
371
+ const projectConfigPath = path.join(templateRoot, "topogram.project.json");
372
+ const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, "utf8"));
373
+ const rawComponents = /** @type {any[]} */ (
374
+ Array.isArray(projectConfig.topology?.components) ? projectConfig.topology.components : []
375
+ );
376
+ /** @type {Array<Record<string, any>>} */
377
+ const components = [];
378
+ for (const component of rawComponents) {
379
+ if (component && typeof component === "object" && typeof component.type === "string") {
380
+ components.push(/** @type {Record<string, any>} */ (component));
381
+ }
382
+ }
383
+ const sortedComponents = [...components].sort((a, b) => {
384
+ const aOrder = SURFACE_ORDER.get(a.type) ?? 100;
385
+ const bOrder = SURFACE_ORDER.get(b.type) ?? 100;
386
+ return aOrder - bOrder;
387
+ });
388
+ const surfaces = [...new Set(sortedComponents.map((component) => String(component.type)))];
389
+ const generators = [
390
+ ...new Set(
391
+ sortedComponents
392
+ .map((component) => component.generator?.id)
393
+ .filter((generatorId) => typeof generatorId === "string")
394
+ .map((generatorId) => String(generatorId))
395
+ )
396
+ ];
397
+ return {
398
+ surfaces,
399
+ generators,
400
+ stack: generators.map(generatorLabel).join(" + ")
401
+ };
402
+ }
403
+
404
+ /**
405
+ * @param {string} templateSpec
406
+ * @returns {string}
407
+ */
408
+ export function installPackageSpec(templateSpec) {
409
+ const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "topogram-template-"));
410
+ const npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
411
+ const localNpmConfig = path.join(process.cwd(), ".npmrc");
412
+ const npmConfigEnv = !process.env.NPM_CONFIG_USERCONFIG && fs.existsSync(localNpmConfig)
413
+ ? { NPM_CONFIG_USERCONFIG: localNpmConfig }
414
+ : {};
415
+ const result = childProcess.spawnSync(
416
+ npmBin,
417
+ [
418
+ "install",
419
+ "--prefix",
420
+ installRoot,
421
+ "--ignore-scripts",
422
+ "--no-audit",
423
+ "--no-fund",
424
+ "--package-lock=false",
425
+ templateSpec
426
+ ],
427
+ {
428
+ encoding: "utf8",
429
+ env: {
430
+ ...process.env,
431
+ ...npmConfigEnv,
432
+ PATH: process.env.PATH || ""
433
+ }
434
+ }
435
+ );
436
+ if (result.status !== 0) {
437
+ throw new Error(formatPackageInstallError(templateSpec, result));
438
+ }
439
+ const packageRoot = path.join(installRoot, "node_modules", packageNameFromSpec(templateSpec));
440
+ if (fs.existsSync(packageRoot)) {
441
+ return packageRoot;
442
+ }
443
+ return findInstalledTemplatePackageRoot(installRoot, templateSpec);
444
+ }
445
+
446
+ /**
447
+ * @param {string} templateSpec
448
+ * @param {any} result
449
+ * @returns {string}
450
+ */
451
+ function formatPackageInstallError(templateSpec, result) {
452
+ const output = [result.error?.message, result.stderr, result.stdout].filter(Boolean).join("\n").trim();
453
+ const normalized = output.toLowerCase();
454
+ const npmrcHint = "Ensure npm can access the registry required by this template package.";
455
+ const packageAccessHint = "For private package registries, configure a token with package read access.";
456
+ const authHint = "For private template packages, configure npm auth for the package registry before installing.";
457
+ const doctorHint = "Run `topogram doctor` to check Node.js, npm, package, and catalog access.";
458
+ if (result.error?.code === "ENOENT") {
459
+ return [
460
+ `Failed to install template package '${templateSpec}': npm was not found.`,
461
+ "Install Node.js/npm and retry."
462
+ ].join("\n");
463
+ }
464
+ if (/\b(e401|eneedauth)\b/.test(normalized) || normalized.includes("unauthenticated") || normalized.includes("authentication required")) {
465
+ return [
466
+ `Authentication is required to install template package '${templateSpec}'.`,
467
+ authHint,
468
+ npmrcHint,
469
+ packageAccessHint,
470
+ doctorHint,
471
+ output
472
+ ].filter(Boolean).join("\n");
473
+ }
474
+ if (/\be403\b/.test(normalized) || normalized.includes("forbidden") || normalized.includes("permission")) {
475
+ return [
476
+ `Package access was denied while installing template package '${templateSpec}'.`,
477
+ authHint,
478
+ packageAccessHint,
479
+ doctorHint,
480
+ output
481
+ ].filter(Boolean).join("\n");
482
+ }
483
+ if (/\b(e404|404)\b/.test(normalized) || normalized.includes("not found")) {
484
+ return [
485
+ `Template package '${templateSpec}' was not found, or the current token does not have access to it.`,
486
+ "Check the package name/version and registry access.",
487
+ packageAccessHint,
488
+ doctorHint,
489
+ output
490
+ ].filter(Boolean).join("\n");
491
+ }
492
+ if (/\beintegrity\b/.test(normalized) || normalized.includes("integrity checksum failed")) {
493
+ return [
494
+ `Package integrity failed while installing template package '${templateSpec}'.`,
495
+ "Refresh package-lock.json from the published registry tarball instead of a local npm pack tarball.",
496
+ output
497
+ ].filter(Boolean).join("\n");
498
+ }
499
+ return `Failed to install template package '${templateSpec}'.\n${output || "unknown error"}`.trim();
500
+ }
501
+
502
+ /**
503
+ * @param {string} installRoot
504
+ * @param {string} templateSpec
505
+ * @returns {string}
506
+ */
507
+ function findInstalledTemplatePackageRoot(installRoot, templateSpec) {
508
+ const nodeModules = path.join(installRoot, "node_modules");
509
+ if (!fs.existsSync(nodeModules)) {
510
+ throw new Error(`Template package '${templateSpec}' did not create node_modules.`);
511
+ }
512
+ /** @type {string[]} */
513
+ const candidates = [];
514
+ for (const entry of fs.readdirSync(nodeModules)) {
515
+ if (entry === ".bin") {
516
+ continue;
517
+ }
518
+ const entryPath = path.join(nodeModules, entry);
519
+ if (entry.startsWith("@")) {
520
+ for (const scopedEntry of fs.readdirSync(entryPath)) {
521
+ candidates.push(path.join(entryPath, scopedEntry));
522
+ }
523
+ continue;
524
+ }
525
+ candidates.push(entryPath);
526
+ }
527
+ const templateRoots = candidates.filter((candidate) =>
528
+ fs.existsSync(path.join(candidate, TEMPLATE_MANIFEST))
529
+ );
530
+ if (templateRoots.length === 1) {
531
+ return templateRoots[0];
532
+ }
533
+ if (templateRoots.length > 1) {
534
+ throw new Error(`Template package '${templateSpec}' installed multiple template manifests.`);
535
+ }
536
+ throw new Error(`Template package '${templateSpec}' did not install a package with ${TEMPLATE_MANIFEST}.`);
537
+ }
538
+
539
+ /**
540
+ * @param {string} templateName
541
+ * @param {string} templatesRoot
542
+ * @returns {ResolvedTemplate}
543
+ */
544
+ export function resolveTemplate(templateName, templatesRoot) {
545
+ void templatesRoot;
546
+
547
+ if (isLocalTemplateSpec(templateName)) {
548
+ const templateRoot = path.resolve(templateName);
549
+ if (!fs.existsSync(templateRoot)) {
550
+ throw new Error(`Local template path '${templateName}' does not exist.`);
551
+ }
552
+ if (!fs.statSync(templateRoot).isDirectory()) {
553
+ const packageTemplateRoot = installPackageSpec(templateName);
554
+ return {
555
+ requested: templateName,
556
+ root: packageTemplateRoot,
557
+ manifest: validateTemplateRoot(packageTemplateRoot),
558
+ source: "package",
559
+ packageSpec: templateName
560
+ };
561
+ }
562
+ return {
563
+ requested: templateName,
564
+ root: templateRoot,
565
+ manifest: validateTemplateRoot(templateRoot),
566
+ source: "local",
567
+ packageSpec: null
568
+ };
569
+ }
570
+
571
+ const templateRoot = installPackageSpec(templateName);
572
+ if (!fs.existsSync(templateRoot)) {
573
+ throw new Error(`Template package '${templateName}' did not install to '${templateRoot}'.`);
574
+ }
575
+ return {
576
+ requested: templateName,
577
+ root: templateRoot,
578
+ manifest: validateTemplateRoot(templateRoot),
579
+ source: "package",
580
+ packageSpec: templateName
581
+ };
582
+ }
583
+
584
+ /**
585
+ * @param {string} projectRoot
586
+ * @param {string} engineRoot
587
+ * @returns {void}
588
+ */
589
+ function assertProjectOutsideEngine(projectRoot, engineRoot) {
590
+ if (isSameOrInside(path.resolve(engineRoot), path.resolve(projectRoot))) {
591
+ throw new Error(
592
+ `Refusing to create a generated project inside the engine directory. Use a path outside engine, for example '../${path.basename(projectRoot)}'.`
593
+ );
594
+ }
595
+ }
596
+
597
+ /**
598
+ * @param {string} projectRoot
599
+ * @returns {void}
600
+ */
601
+ function ensureCreatableProjectRoot(projectRoot) {
602
+ if (!fs.existsSync(projectRoot)) {
603
+ fs.mkdirSync(projectRoot, { recursive: true });
604
+ return;
605
+ }
606
+ if (!fs.statSync(projectRoot).isDirectory()) {
607
+ throw new Error(`Cannot create project at '${projectRoot}' because it is not a directory.`);
608
+ }
609
+ /** @type {string[]} */
610
+ const dirEntries = fs.readdirSync(projectRoot);
611
+ const entries = dirEntries.filter((entry) => entry !== ".DS_Store");
612
+ if (entries.length > 0) {
613
+ throw new Error(`Refusing to create a Topogram project in non-empty directory '${projectRoot}'.`);
614
+ }
615
+ }
616
+
617
+ /**
618
+ * @param {string} templateRoot
619
+ * @param {string} projectRoot
620
+ * @returns {void}
621
+ */
622
+ function copyTopogramWorkspace(templateRoot, projectRoot) {
623
+ const topogramRoot = path.join(projectRoot, "topogram");
624
+ fs.cpSync(path.join(templateRoot, "topogram"), topogramRoot, { recursive: true });
625
+
626
+ fs.cpSync(
627
+ path.join(templateRoot, "topogram.project.json"),
628
+ path.join(projectRoot, "topogram.project.json")
629
+ );
630
+ const implementationRoot = path.join(templateRoot, "implementation");
631
+ if (fs.existsSync(implementationRoot)) {
632
+ fs.cpSync(
633
+ implementationRoot,
634
+ path.join(projectRoot, "implementation"),
635
+ { recursive: true }
636
+ );
637
+ }
638
+ }
639
+
640
+ /**
641
+ * @param {string} projectRoot
642
+ * @param {ResolvedTemplate} template
643
+ * @param {CatalogTemplateProvenance|null} [templateProvenance]
644
+ * @returns {Record<string, any>}
645
+ */
646
+ function writeProjectTemplateMetadata(projectRoot, template, templateProvenance = null) {
647
+ const projectConfigPath = path.join(projectRoot, "topogram.project.json");
648
+ const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, "utf8"));
649
+ projectConfig.template = projectTemplateMetadata(template, templateProvenance);
650
+ fs.writeFileSync(projectConfigPath, `${JSON.stringify(projectConfig, null, 2)}\n`, "utf8");
651
+ return projectConfig;
652
+ }
653
+
654
+ /**
655
+ * @param {ResolvedTemplate} template
656
+ * @param {CatalogTemplateProvenance|null} [templateProvenance]
657
+ * @returns {{ id: string, version: string, source: string, requested: string, sourceSpec: string, sourceRoot: string|null, includesExecutableImplementation: boolean, catalog?: CatalogTemplateProvenance }}
658
+ */
659
+ function projectTemplateMetadata(template, templateProvenance = null) {
660
+ /** @type {{ id: string, version: string, source: string, requested: string, sourceSpec: string, sourceRoot: string|null, includesExecutableImplementation: boolean, catalog?: CatalogTemplateProvenance }} */
661
+ const metadata = {
662
+ id: template.manifest.id,
663
+ version: template.manifest.version,
664
+ source: template.source,
665
+ requested: templateProvenance?.id || template.requested,
666
+ sourceSpec: template.packageSpec || template.requested,
667
+ sourceRoot: template.source === "local" ? template.root : null,
668
+ includesExecutableImplementation: Boolean(template.manifest.includesExecutableImplementation)
669
+ };
670
+ if (templateProvenance) {
671
+ metadata.catalog = templateProvenance;
672
+ }
673
+ return metadata;
674
+ }
675
+
676
+ /**
677
+ * @param {Record<string, any>} input
678
+ * @returns {TemplateUpdateDiagnostic}
679
+ */
680
+ function templateUpdateDiagnostic(input) {
681
+ return {
682
+ code: String(input.code || "template_update_failed"),
683
+ severity: input.severity === "warning" ? "warning" : "error",
684
+ message: String(input.message || "Template update failed."),
685
+ path: typeof input.path === "string" ? input.path : null,
686
+ suggestedFix: typeof input.suggestedFix === "string" ? input.suggestedFix : null,
687
+ step: typeof input.step === "string" ? input.step : null
688
+ };
689
+ }
690
+
691
+ /**
692
+ * @param {unknown} value
693
+ * @param {string} policyPath
694
+ * @returns {TemplatePolicy}
695
+ */
696
+ function validateTemplatePolicy(value, policyPath) {
697
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
698
+ throw new Error(`${TEMPLATE_POLICY_FILE} must contain a JSON object.`);
699
+ }
700
+ const policy = /** @type {Record<string, unknown>} */ (value);
701
+ const version = typeof policy.version === "string" && policy.version ? policy.version : "0.1";
702
+ const allowedSources = Array.isArray(policy.allowedSources) ? policy.allowedSources : ["local", "package"];
703
+ const invalidSource = allowedSources.find((source) => !["local", "package"].includes(String(source)));
704
+ if (invalidSource) {
705
+ throw new Error(`${policyPath} has invalid allowedSources value '${String(invalidSource)}'.`);
706
+ }
707
+ const allowedTemplateIds = Array.isArray(policy.allowedTemplateIds)
708
+ ? policy.allowedTemplateIds.map(String).filter(Boolean)
709
+ : [];
710
+ const allowedPackageScopes = Array.isArray(policy.allowedPackageScopes)
711
+ ? policy.allowedPackageScopes.map(String).filter(Boolean)
712
+ : [];
713
+ const executableImplementation = policy.executableImplementation === "deny" || policy.executableImplementation === "warn"
714
+ ? policy.executableImplementation
715
+ : "allow";
716
+ const pinnedVersions = policy.pinnedVersions && typeof policy.pinnedVersions === "object" && !Array.isArray(policy.pinnedVersions)
717
+ ? Object.fromEntries(Object.entries(policy.pinnedVersions).filter(([, pin]) => typeof pin === "string"))
718
+ : {};
719
+ return {
720
+ version,
721
+ allowedSources: /** @type {Array<"local"|"package">} */ (allowedSources),
722
+ allowedTemplateIds,
723
+ allowedPackageScopes,
724
+ executableImplementation,
725
+ pinnedVersions
726
+ };
727
+ }
728
+
729
+ /**
730
+ * @param {string} projectRoot
731
+ * @returns {TemplatePolicyInfo}
732
+ */
733
+ export function loadTemplatePolicy(projectRoot) {
734
+ const policyPath = path.join(projectRoot, TEMPLATE_POLICY_FILE);
735
+ if (!fs.existsSync(policyPath)) {
736
+ return {
737
+ path: policyPath,
738
+ policy: null,
739
+ exists: false,
740
+ diagnostics: []
741
+ };
742
+ }
743
+ try {
744
+ return {
745
+ path: policyPath,
746
+ policy: validateTemplatePolicy(JSON.parse(fs.readFileSync(policyPath, "utf8")), policyPath),
747
+ exists: true,
748
+ diagnostics: []
749
+ };
750
+ } catch (error) {
751
+ return {
752
+ path: policyPath,
753
+ policy: null,
754
+ exists: true,
755
+ diagnostics: [templateUpdateDiagnostic({
756
+ code: "template_policy_invalid",
757
+ message: error instanceof Error ? error.message : String(error),
758
+ path: policyPath,
759
+ suggestedFix: "Fix topogram.template-policy.json or regenerate it with `topogram template policy init`.",
760
+ step: "policy"
761
+ })]
762
+ };
763
+ }
764
+ }
765
+
766
+ /**
767
+ * @param {ResolvedTemplate} template
768
+ * @returns {TemplatePolicy}
769
+ */
770
+ function defaultTemplatePolicyForTemplate(template) {
771
+ const allowedPackageScopes = [];
772
+ const idScope = template.source === "package"
773
+ ? packageScopeFromSpec(template.packageSpec || template.requested)
774
+ : null;
775
+ if (template.source === "package" && idScope) {
776
+ allowedPackageScopes.push(idScope);
777
+ }
778
+ return {
779
+ version: "0.1",
780
+ allowedSources: ["local", "package"],
781
+ allowedTemplateIds: [template.manifest.id],
782
+ allowedPackageScopes,
783
+ executableImplementation: "allow",
784
+ pinnedVersions: {}
785
+ };
786
+ }
787
+
788
+ /**
789
+ * @param {string} projectRoot
790
+ * @param {TemplatePolicy} policy
791
+ * @returns {TemplatePolicy}
792
+ */
793
+ export function writeTemplatePolicy(projectRoot, policy) {
794
+ fs.writeFileSync(path.join(projectRoot, TEMPLATE_POLICY_FILE), `${stableJsonStringify(policy)}\n`, "utf8");
795
+ return policy;
796
+ }
797
+
798
+ /**
799
+ * @param {string} projectRoot
800
+ * @param {Record<string, any>} projectConfig
801
+ * @returns {TemplatePolicy}
802
+ */
803
+ export function writeTemplatePolicyForProject(projectRoot, projectConfig) {
804
+ const current = currentTemplateMetadata(projectConfig);
805
+ /** @type {string[]} */
806
+ const allowedPackageScopes = [];
807
+ if (current.source === "package") {
808
+ const currentScope = packageScopeFromSpec(current.sourceSpec) ||
809
+ (current.id?.startsWith("@") ? current.id.split("/")[0] : null);
810
+ if (currentScope) {
811
+ allowedPackageScopes.push(currentScope);
812
+ }
813
+ }
814
+ return writeTemplatePolicy(projectRoot, {
815
+ version: "0.1",
816
+ allowedSources: ["local", "package"],
817
+ allowedTemplateIds: current.id ? [current.id] : [],
818
+ allowedPackageScopes,
819
+ executableImplementation: "allow",
820
+ pinnedVersions: {}
821
+ });
822
+ }
823
+
824
+ /**
825
+ * @param {TemplatePolicyInfo} policyInfo
826
+ * @param {ResolvedTemplate} template
827
+ * @param {string} step
828
+ * @returns {TemplateUpdateDiagnostic[]}
829
+ */
830
+ export function templatePolicyDiagnosticsForTemplate(policyInfo, template, step) {
831
+ if (policyInfo.diagnostics.length > 0) {
832
+ return policyInfo.diagnostics;
833
+ }
834
+ if (!policyInfo.policy) {
835
+ return [];
836
+ }
837
+ const policy = policyInfo.policy;
838
+ /** @type {TemplateUpdateDiagnostic[]} */
839
+ const diagnostics = [];
840
+ if (policy.allowedSources.length > 0 && !policy.allowedSources.includes(template.source)) {
841
+ diagnostics.push(templateUpdateDiagnostic({
842
+ code: "template_source_denied",
843
+ message: `Template source '${template.source}' is not allowed by ${TEMPLATE_POLICY_FILE}.`,
844
+ path: policyInfo.path,
845
+ suggestedFix: `Run \`topogram template policy init\` to reset from the current project, or add '${template.source}' to allowedSources after review.`,
846
+ step
847
+ }));
848
+ }
849
+ if (policy.allowedTemplateIds.length > 0 && !policy.allowedTemplateIds.includes(template.manifest.id)) {
850
+ diagnostics.push(templateUpdateDiagnostic({
851
+ code: "template_id_denied",
852
+ message: `Template '${template.manifest.id}' is not allowed by ${TEMPLATE_POLICY_FILE}.`,
853
+ path: policyInfo.path,
854
+ suggestedFix: `Run \`topogram template policy pin ${template.manifest.id}@${template.manifest.version}\` after review, or choose an allowed template.`,
855
+ step
856
+ }));
857
+ }
858
+ if (template.source === "package" && policy.allowedPackageScopes && policy.allowedPackageScopes.length > 0) {
859
+ const scope = packageScopeFromSpec(template.packageSpec || template.requested) ||
860
+ (template.manifest.id.startsWith("@") ? template.manifest.id.split("/")[0] : null);
861
+ if (!scope || !policy.allowedPackageScopes.includes(scope)) {
862
+ diagnostics.push(templateUpdateDiagnostic({
863
+ code: "template_package_scope_denied",
864
+ message: `Template package scope '${scope || "(unscoped)"}' is not allowed by ${TEMPLATE_POLICY_FILE}.`,
865
+ path: policyInfo.path,
866
+ suggestedFix: `Add '${scope || "(unscoped)"}' to allowedPackageScopes after review, or choose a package from an allowed scope.`,
867
+ step
868
+ }));
869
+ }
870
+ }
871
+ const pinnedVersion = policy.pinnedVersions?.[template.manifest.id];
872
+ if (pinnedVersion && pinnedVersion !== template.manifest.version) {
873
+ diagnostics.push(templateUpdateDiagnostic({
874
+ code: "template_version_mismatch",
875
+ message: `Template '${template.manifest.id}' is pinned to version '${pinnedVersion}', but candidate version is '${template.manifest.version}'.`,
876
+ path: policyInfo.path,
877
+ suggestedFix: `Run \`topogram template policy pin ${template.manifest.id}@${template.manifest.version}\` after review, or use version '${pinnedVersion}'.`,
878
+ step
879
+ }));
880
+ }
881
+ if (template.manifest.includesExecutableImplementation) {
882
+ if (policy.executableImplementation === "deny") {
883
+ diagnostics.push(templateUpdateDiagnostic({
884
+ code: "template_executable_denied",
885
+ message: `Template '${template.manifest.id}' includes executable implementation code, which is denied by ${TEMPLATE_POLICY_FILE}.`,
886
+ path: policyInfo.path,
887
+ suggestedFix: "Use a non-executable template, or set executableImplementation to 'allow' after reviewing implementation/.",
888
+ step
889
+ }));
890
+ } else if (policy.executableImplementation === "warn") {
891
+ diagnostics.push(templateUpdateDiagnostic({
892
+ code: "template_executable_warning",
893
+ severity: "warning",
894
+ message: `Template '${template.manifest.id}' includes executable implementation code.`,
895
+ path: policyInfo.path,
896
+ suggestedFix: "Review implementation/ before running topogram generate.",
897
+ step
898
+ }));
899
+ }
900
+ }
901
+ return diagnostics;
902
+ }
903
+
904
+ /**
905
+ * @param {string} projectRoot
906
+ * @param {ResolvedTemplate} template
907
+ * @param {string} step
908
+ * @returns {TemplateUpdateDiagnostic[]}
909
+ */
910
+ function templatePolicyDiagnosticsForProject(projectRoot, template, step) {
911
+ return templatePolicyDiagnosticsForTemplate(loadTemplatePolicy(projectRoot), template, step);
912
+ }
913
+
914
+ /**
915
+ * @param {TemplateUpdateDiagnostic[]} diagnostics
916
+ * @returns {string[]}
917
+ */
918
+ function issueMessagesFromDiagnostics(diagnostics) {
919
+ return diagnostics
920
+ .filter((diagnostic) => diagnostic.severity === "error")
921
+ .map((diagnostic) => diagnostic.message);
922
+ }
923
+
924
+ /**
925
+ * @param {Record<string, any>} projectConfig
926
+ * @returns {{ id: string|null, version: string|null, source: string|null, sourceSpec: string|null, requested: string|null }}
927
+ */
928
+ function currentTemplateMetadata(projectConfig) {
929
+ const currentTemplate = projectConfig.template || {};
930
+ return {
931
+ id: typeof currentTemplate.id === "string" ? currentTemplate.id : null,
932
+ version: typeof currentTemplate.version === "string" ? currentTemplate.version : null,
933
+ source: typeof currentTemplate.source === "string" ? currentTemplate.source : null,
934
+ sourceSpec: typeof currentTemplate.sourceSpec === "string" ? currentTemplate.sourceSpec : null,
935
+ requested: typeof currentTemplate.requested === "string" ? currentTemplate.requested : null
936
+ };
937
+ }
938
+
939
+ /**
940
+ * @param {string} filePath
941
+ * @returns {string}
942
+ */
943
+ function normalizeTemplateUpdateActionPath(filePath) {
944
+ const normalized = path.posix.normalize(filePath.replace(/\\/g, "/"));
945
+ if (
946
+ !filePath ||
947
+ path.isAbsolute(filePath) ||
948
+ normalized === "." ||
949
+ normalized.startsWith("../") ||
950
+ normalized === ".."
951
+ ) {
952
+ throw new Error(`Template update action requires a relative template-owned file path: ${filePath || "(missing)"}`);
953
+ }
954
+ return normalized;
955
+ }
956
+
957
+ /**
958
+ * @param {any} bytes
959
+ * @returns {boolean}
960
+ */
961
+ function isLikelyText(bytes) {
962
+ if (bytes.includes(0)) {
963
+ return false;
964
+ }
965
+ const length = Math.min(bytes.length, 4096);
966
+ let suspicious = 0;
967
+ for (let index = 0; index < length; index += 1) {
968
+ const byte = bytes[index];
969
+ if (byte === 9 || byte === 10 || byte === 13) {
970
+ continue;
971
+ }
972
+ if (byte < 32 || byte === 127) {
973
+ suspicious += 1;
974
+ }
975
+ }
976
+ return length === 0 || suspicious / length < 0.02;
977
+ }
978
+
979
+ /**
980
+ * @param {string} text
981
+ * @returns {string[]}
982
+ */
983
+ function linesForDiff(text) {
984
+ const lines = text.split("\n");
985
+ if (lines.at(-1) === "") {
986
+ lines.pop();
987
+ }
988
+ return lines;
989
+ }
990
+
991
+ /**
992
+ * @param {string[]} before
993
+ * @param {string[]} after
994
+ * @returns {Array<{ type: "same"|"added"|"removed", text: string }>}
995
+ */
996
+ function diffLines(before, after) {
997
+ const rows = before.length;
998
+ const columns = after.length;
999
+ /** @type {number[][]} */
1000
+ const table = Array.from({ length: rows + 1 }, () => Array(columns + 1).fill(0));
1001
+ for (let row = rows - 1; row >= 0; row -= 1) {
1002
+ for (let column = columns - 1; column >= 0; column -= 1) {
1003
+ table[row][column] = before[row] === after[column]
1004
+ ? table[row + 1][column + 1] + 1
1005
+ : Math.max(table[row + 1][column], table[row][column + 1]);
1006
+ }
1007
+ }
1008
+ /** @type {Array<{ type: "same"|"added"|"removed", text: string }>} */
1009
+ const changes = [];
1010
+ let row = 0;
1011
+ let column = 0;
1012
+ while (row < rows && column < columns) {
1013
+ if (before[row] === after[column]) {
1014
+ changes.push({ type: "same", text: before[row] });
1015
+ row += 1;
1016
+ column += 1;
1017
+ } else if (table[row + 1][column] >= table[row][column + 1]) {
1018
+ changes.push({ type: "removed", text: before[row] });
1019
+ row += 1;
1020
+ } else {
1021
+ changes.push({ type: "added", text: after[column] });
1022
+ column += 1;
1023
+ }
1024
+ }
1025
+ while (row < rows) {
1026
+ changes.push({ type: "removed", text: before[row] });
1027
+ row += 1;
1028
+ }
1029
+ while (column < columns) {
1030
+ changes.push({ type: "added", text: after[column] });
1031
+ column += 1;
1032
+ }
1033
+ return changes;
1034
+ }
1035
+
1036
+ /**
1037
+ * @param {string} relativePath
1038
+ * @param {string|null} beforeText
1039
+ * @param {string|null} afterText
1040
+ * @returns {string|null}
1041
+ */
1042
+ function unifiedTextDiff(relativePath, beforeText, afterText) {
1043
+ if (beforeText === null && afterText === null) {
1044
+ return null;
1045
+ }
1046
+ const beforeLines = beforeText === null ? [] : linesForDiff(beforeText);
1047
+ const afterLines = afterText === null ? [] : linesForDiff(afterText);
1048
+ const changes = diffLines(beforeLines, afterLines);
1049
+ const lines = [
1050
+ `--- current/${relativePath}`,
1051
+ `+++ candidate/${relativePath}`,
1052
+ `@@ -1,${beforeLines.length} +1,${afterLines.length} @@`
1053
+ ];
1054
+ for (const change of changes) {
1055
+ const prefix = change.type === "added" ? "+" : change.type === "removed" ? "-" : " ";
1056
+ lines.push(`${prefix}${change.text}`);
1057
+ }
1058
+ return `${lines.join("\n")}\n`;
1059
+ }
1060
+
1061
+ /**
1062
+ * @param {string} value
1063
+ * @returns {number}
1064
+ */
1065
+ function utf8ByteLength(value) {
1066
+ let length = 0;
1067
+ for (const char of value) {
1068
+ const codePoint = char.codePointAt(0) || 0;
1069
+ if (codePoint <= 0x7f) {
1070
+ length += 1;
1071
+ } else if (codePoint <= 0x7ff) {
1072
+ length += 2;
1073
+ } else if (codePoint <= 0xffff) {
1074
+ length += 3;
1075
+ } else {
1076
+ length += 4;
1077
+ }
1078
+ }
1079
+ return length;
1080
+ }
1081
+
1082
+ /**
1083
+ * @param {any} value
1084
+ * @returns {any}
1085
+ */
1086
+ function sortJsonValue(value) {
1087
+ if (Array.isArray(value)) {
1088
+ return value.map(sortJsonValue);
1089
+ }
1090
+ if (value && typeof value === "object") {
1091
+ /** @type {Record<string, any>} */
1092
+ const sorted = {};
1093
+ for (const key of Object.keys(value).sort((left, right) => left.localeCompare(right))) {
1094
+ sorted[key] = sortJsonValue(value[key]);
1095
+ }
1096
+ return sorted;
1097
+ }
1098
+ return value;
1099
+ }
1100
+
1101
+ /**
1102
+ * @param {any} value
1103
+ * @returns {string}
1104
+ */
1105
+ function stableJsonStringify(value) {
1106
+ return JSON.stringify(sortJsonValue(value), null, 2);
1107
+ }
1108
+
1109
+ /**
1110
+ * @param {string} root
1111
+ * @param {string} currentDir
1112
+ * @param {string[]} files
1113
+ * @returns {void}
1114
+ */
1115
+ function collectFiles(root, currentDir, files) {
1116
+ if (!fs.existsSync(currentDir)) {
1117
+ return;
1118
+ }
1119
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
1120
+ if (entry.name === ".DS_Store" || entry.name === "node_modules" || entry.name === ".tmp") {
1121
+ continue;
1122
+ }
1123
+ const entryPath = path.join(currentDir, entry.name);
1124
+ if (entry.isDirectory()) {
1125
+ collectFiles(root, entryPath, files);
1126
+ continue;
1127
+ }
1128
+ if (entry.isFile()) {
1129
+ files.push(path.relative(root, entryPath).replace(/\\/g, "/"));
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * @param {string|null} absolutePath
1136
+ * @param {string|null} content
1137
+ * @returns {{ sha256: string, size: number, text: string|null, binary: boolean, diffOmitted: boolean }|null}
1138
+ */
1139
+ function fileSnapshot(absolutePath, content = null) {
1140
+ if (!absolutePath && content === null) {
1141
+ return null;
1142
+ }
1143
+ if (content !== null) {
1144
+ return {
1145
+ sha256: crypto.createHash("sha256").update(content, "utf8").digest("hex"),
1146
+ size: utf8ByteLength(content),
1147
+ text: content,
1148
+ binary: false,
1149
+ diffOmitted: false
1150
+ };
1151
+ }
1152
+ const bytes = fs.readFileSync(absolutePath || "");
1153
+ const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
1154
+ if (bytes.length > MAX_TEXT_DIFF_BYTES) {
1155
+ return { sha256, size: bytes.length, text: null, binary: false, diffOmitted: true };
1156
+ }
1157
+ if (!isLikelyText(bytes)) {
1158
+ return { sha256, size: bytes.length, text: null, binary: true, diffOmitted: false };
1159
+ }
1160
+ return { sha256, size: bytes.length, text: bytes.toString("utf8"), binary: false, diffOmitted: false };
1161
+ }
1162
+
1163
+ /**
1164
+ * @param {{ absolutePath: string|null, content: string|null }} file
1165
+ * @returns {{ sha256: string, size: number }}
1166
+ */
1167
+ function fileHash(file) {
1168
+ const snapshot = fileSnapshot(file.absolutePath, file.content);
1169
+ if (!snapshot) {
1170
+ throw new Error("Cannot hash missing template-owned file.");
1171
+ }
1172
+ return {
1173
+ sha256: snapshot.sha256,
1174
+ size: snapshot.size
1175
+ };
1176
+ }
1177
+
1178
+ /**
1179
+ * @param {ResolvedTemplate} template
1180
+ * @param {Record<string, any>|null} [currentProjectConfig]
1181
+ * @returns {Map<string, { path: string, content: string|null, absolutePath: string|null }>}
1182
+ */
1183
+ function candidateTemplateFiles(template, currentProjectConfig = null) {
1184
+ const files = new Map();
1185
+ for (const rootName of ["topogram", "implementation"]) {
1186
+ const root = path.join(template.root, rootName);
1187
+ if (!fs.existsSync(root)) {
1188
+ continue;
1189
+ }
1190
+ /** @type {string[]} */
1191
+ const relativeFiles = [];
1192
+ collectFiles(template.root, root, relativeFiles);
1193
+ for (const relativePath of relativeFiles) {
1194
+ files.set(relativePath, {
1195
+ path: relativePath,
1196
+ content: null,
1197
+ absolutePath: path.join(template.root, relativePath)
1198
+ });
1199
+ }
1200
+ }
1201
+ const candidateProjectConfig = JSON.parse(fs.readFileSync(path.join(template.root, "topogram.project.json"), "utf8"));
1202
+ candidateProjectConfig.template = candidateProjectTemplateMetadata(template, currentProjectConfig);
1203
+ files.set("topogram.project.json", {
1204
+ path: "topogram.project.json",
1205
+ content: `${stableJsonStringify(candidateProjectConfig)}\n`,
1206
+ absolutePath: null
1207
+ });
1208
+ return files;
1209
+ }
1210
+
1211
+ /**
1212
+ * @param {ResolvedTemplate} template
1213
+ * @param {Record<string, any>|null} currentProjectConfig
1214
+ * @returns {ReturnType<typeof projectTemplateMetadata>}
1215
+ */
1216
+ function candidateProjectTemplateMetadata(template, currentProjectConfig) {
1217
+ const metadata = projectTemplateMetadata(template);
1218
+ const currentTemplate = currentProjectConfig?.template || null;
1219
+ if (!currentTemplate || currentTemplate.id !== metadata.id) {
1220
+ return metadata;
1221
+ }
1222
+ if (typeof currentTemplate.requested === "string" && currentTemplate.requested) {
1223
+ metadata.requested = currentTemplate.requested;
1224
+ }
1225
+ if (currentTemplate.catalog && typeof currentTemplate.catalog === "object") {
1226
+ metadata.catalog = {
1227
+ ...currentTemplate.catalog,
1228
+ package: typeof currentTemplate.catalog.package === "string"
1229
+ ? currentTemplate.catalog.package
1230
+ : metadata.id,
1231
+ version: metadata.version,
1232
+ packageSpec: metadata.sourceSpec
1233
+ };
1234
+ }
1235
+ return metadata;
1236
+ }
1237
+
1238
+ /**
1239
+ * @param {string} projectRoot
1240
+ * @param {boolean} includeImplementation
1241
+ * @param {Record<string, any>} projectConfig
1242
+ * @returns {Map<string, { path: string, absolutePath: string|null, content: string|null }>}
1243
+ */
1244
+ function currentTemplateOwnedFiles(projectRoot, includeImplementation, projectConfig) {
1245
+ const files = new Map();
1246
+ for (const rootName of includeImplementation ? ["topogram", "implementation"] : ["topogram"]) {
1247
+ const root = path.join(projectRoot, rootName);
1248
+ if (!fs.existsSync(root)) {
1249
+ continue;
1250
+ }
1251
+ /** @type {string[]} */
1252
+ const relativeFiles = [];
1253
+ collectFiles(projectRoot, root, relativeFiles);
1254
+ for (const relativePath of relativeFiles) {
1255
+ files.set(relativePath, {
1256
+ path: relativePath,
1257
+ absolutePath: path.join(projectRoot, relativePath),
1258
+ content: null
1259
+ });
1260
+ }
1261
+ }
1262
+ const projectConfigPath = path.join(projectRoot, "topogram.project.json");
1263
+ if (fs.existsSync(projectConfigPath)) {
1264
+ files.set("topogram.project.json", {
1265
+ path: "topogram.project.json",
1266
+ absolutePath: null,
1267
+ content: `${stableJsonStringify(projectConfig)}\n`
1268
+ });
1269
+ }
1270
+ return files;
1271
+ }
1272
+
1273
+ /**
1274
+ * @param {Record<string, any>} projectConfig
1275
+ * @returns {boolean}
1276
+ */
1277
+ function includesTemplateImplementation(projectConfig) {
1278
+ const template = projectConfig.template || {};
1279
+ return Boolean(
1280
+ projectConfig.implementation ||
1281
+ template.includesExecutableImplementation
1282
+ );
1283
+ }
1284
+
1285
+ /**
1286
+ * @param {string} projectRoot
1287
+ * @param {Record<string, any>} projectConfig
1288
+ * @returns {Map<string, TemplateOwnedFileRecord>}
1289
+ */
1290
+ function currentTemplateOwnedFileHashes(projectRoot, projectConfig) {
1291
+ const files = currentTemplateOwnedFiles(projectRoot, includesTemplateImplementation(projectConfig), projectConfig);
1292
+ return new Map([...files.entries()].map(([relativePath, file]) => {
1293
+ const hash = fileHash(file);
1294
+ return [relativePath, { path: relativePath, ...hash }];
1295
+ }));
1296
+ }
1297
+
1298
+ /**
1299
+ * @param {string} projectRoot
1300
+ * @returns {{ version: string, template: Record<string, any>, files: TemplateOwnedFileRecord[] }|null}
1301
+ */
1302
+ function readTemplateFilesManifest(projectRoot) {
1303
+ const manifestPath = path.join(projectRoot, TEMPLATE_FILES_MANIFEST);
1304
+ if (!fs.existsSync(manifestPath)) {
1305
+ return null;
1306
+ }
1307
+ return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
1308
+ }
1309
+
1310
+ /**
1311
+ * @param {string} projectRoot
1312
+ * @param {Record<string, any>} projectConfig
1313
+ * @returns {{ version: string, template: Record<string, any>, files: TemplateOwnedFileRecord[] }}
1314
+ */
1315
+ export function writeTemplateFilesManifest(projectRoot, projectConfig) {
1316
+ const fileRecords = [...currentTemplateOwnedFileHashes(projectRoot, projectConfig).values()]
1317
+ .sort((left, right) => left.path.localeCompare(right.path));
1318
+ const manifest = {
1319
+ version: "0.1",
1320
+ template: {
1321
+ id: projectConfig.template?.id || null,
1322
+ version: projectConfig.template?.version || null,
1323
+ source: projectConfig.template?.source || null,
1324
+ sourceSpec: projectConfig.template?.sourceSpec || null,
1325
+ requested: projectConfig.template?.requested || null,
1326
+ catalog: projectConfig.template?.catalog || null
1327
+ },
1328
+ files: fileRecords
1329
+ };
1330
+ fs.writeFileSync(path.join(projectRoot, TEMPLATE_FILES_MANIFEST), `${stableJsonStringify(manifest)}\n`, "utf8");
1331
+ return manifest;
1332
+ }
1333
+
1334
+ /**
1335
+ * @param {string} projectRoot
1336
+ * @param {{ version: string, template: Record<string, any>, files: TemplateOwnedFileRecord[] }} manifest
1337
+ * @returns {void}
1338
+ */
1339
+ function writeTemplateFilesManifestData(projectRoot, manifest) {
1340
+ const sortedManifest = {
1341
+ ...manifest,
1342
+ files: [...manifest.files].sort((left, right) => left.path.localeCompare(right.path))
1343
+ };
1344
+ fs.writeFileSync(path.join(projectRoot, TEMPLATE_FILES_MANIFEST), `${stableJsonStringify(sortedManifest)}\n`, "utf8");
1345
+ }
1346
+
1347
+ /**
1348
+ * @param {string} projectRoot
1349
+ * @param {{ version: string, template: Record<string, any>, files: TemplateOwnedFileRecord[] }} manifest
1350
+ * @param {string} relativePath
1351
+ * @param {TemplateOwnedFileRecord|null} record
1352
+ * @returns {void}
1353
+ */
1354
+ function updateTemplateFilesManifestRecord(projectRoot, manifest, relativePath, record) {
1355
+ const byPath = new Map(manifest.files.map((file) => [file.path, file]));
1356
+ if (record) {
1357
+ byPath.set(relativePath, record);
1358
+ } else {
1359
+ byPath.delete(relativePath);
1360
+ }
1361
+ writeTemplateFilesManifestData(projectRoot, {
1362
+ ...manifest,
1363
+ files: [...byPath.values()]
1364
+ });
1365
+ }
1366
+
1367
+ /**
1368
+ * @param {TemplateUpdatePlanOptions} options
1369
+ * @returns {{ ok: boolean, mode: "plan", writes: false, current: { id: string|null, version: string|null, source: string|null, sourceSpec: string|null, requested: string|null }, candidate: { id: string, version: string, source: string, sourceSpec: string, requested: string }, compatible: boolean, issues: string[], diagnostics: TemplateUpdateDiagnostic[], summary: { added: number, changed: number, currentOnly: number, unchanged: number }, files: Array<{ path: string, kind: "added"|"changed"|"current-only"|"unchanged", current: { sha256: string, size: number }|null, candidate: { sha256: string, size: number }|null, binary: boolean, diffOmitted: boolean, unifiedDiff: string|null }> }}
1370
+ */
1371
+ export function buildTemplateUpdatePlan({
1372
+ projectRoot,
1373
+ projectConfig,
1374
+ templateName = null,
1375
+ templatesRoot
1376
+ }) {
1377
+ const currentTemplate = projectConfig.template || {};
1378
+ const templateSpec = templateName || currentTemplate.sourceSpec || currentTemplate.requested || currentTemplate.id;
1379
+ if (!templateSpec || typeof templateSpec !== "string") {
1380
+ throw new Error("Cannot plan template update because topogram.project.json has no template source spec.");
1381
+ }
1382
+ const candidateTemplate = resolveTemplate(templateSpec, templatesRoot);
1383
+ const candidateMetadata = projectTemplateMetadata(candidateTemplate);
1384
+ /** @type {TemplateUpdateDiagnostic[]} */
1385
+ const diagnostics = templatePolicyDiagnosticsForProject(projectRoot, candidateTemplate, "policy");
1386
+ if (currentTemplate.id && currentTemplate.id !== candidateMetadata.id) {
1387
+ diagnostics.push(templateUpdateDiagnostic({
1388
+ code: "template_id_mismatch",
1389
+ message: `Candidate template id '${candidateMetadata.id}' does not match current template id '${currentTemplate.id}'.`,
1390
+ path: path.join(projectRoot, "topogram.project.json"),
1391
+ suggestedFix: "Use a template with the same id, or create a new project from the other template.",
1392
+ step: "resolve-candidate"
1393
+ }));
1394
+ }
1395
+ const candidateFiles = candidateTemplateFiles(candidateTemplate, projectConfig);
1396
+ const currentFiles = currentTemplateOwnedFiles(
1397
+ projectRoot,
1398
+ Boolean(includesTemplateImplementation(projectConfig) || candidateMetadata.includesExecutableImplementation),
1399
+ projectConfig
1400
+ );
1401
+ const allPaths = new Set([...candidateFiles.keys(), ...currentFiles.keys()]);
1402
+ /** @type {Array<{ path: string, kind: "added"|"changed"|"current-only"|"unchanged", current: { sha256: string, size: number }|null, candidate: { sha256: string, size: number }|null, binary: boolean, diffOmitted: boolean, unifiedDiff: string|null }>} */
1403
+ const files = [];
1404
+
1405
+ for (const relativePath of [...allPaths].sort((a, b) => a.localeCompare(b))) {
1406
+ const candidateFile = candidateFiles.get(relativePath) || null;
1407
+ const currentFile = currentFiles.get(relativePath) || null;
1408
+ const candidateSnapshot = candidateFile
1409
+ ? fileSnapshot(candidateFile.absolutePath, candidateFile.content)
1410
+ : null;
1411
+ const currentSnapshot = currentFile
1412
+ ? fileSnapshot(currentFile.absolutePath, currentFile.content)
1413
+ : null;
1414
+ let kind = /** @type {"added"|"changed"|"current-only"|"unchanged"} */ ("unchanged");
1415
+ if (!currentSnapshot && candidateSnapshot) {
1416
+ kind = "added";
1417
+ } else if (currentSnapshot && !candidateSnapshot) {
1418
+ kind = "current-only";
1419
+ } else if (currentSnapshot && candidateSnapshot && (
1420
+ currentSnapshot.sha256 !== candidateSnapshot.sha256 ||
1421
+ currentSnapshot.size !== candidateSnapshot.size
1422
+ )) {
1423
+ kind = "changed";
1424
+ }
1425
+ const binary = Boolean(currentSnapshot?.binary || candidateSnapshot?.binary);
1426
+ const diffOmitted = binary || Boolean(currentSnapshot?.diffOmitted || candidateSnapshot?.diffOmitted);
1427
+ files.push({
1428
+ path: relativePath,
1429
+ kind,
1430
+ current: currentSnapshot ? { sha256: currentSnapshot.sha256, size: currentSnapshot.size } : null,
1431
+ candidate: candidateSnapshot ? { sha256: candidateSnapshot.sha256, size: candidateSnapshot.size } : null,
1432
+ binary,
1433
+ diffOmitted,
1434
+ unifiedDiff: diffOmitted
1435
+ ? null
1436
+ : unifiedTextDiff(relativePath, currentSnapshot?.text || null, candidateSnapshot?.text || null)
1437
+ });
1438
+ }
1439
+ const visibleFiles = files.filter((file) => file.kind !== "unchanged");
1440
+ const summary = {
1441
+ added: visibleFiles.filter((file) => file.kind === "added").length,
1442
+ changed: visibleFiles.filter((file) => file.kind === "changed").length,
1443
+ currentOnly: visibleFiles.filter((file) => file.kind === "current-only").length,
1444
+ unchanged: files.filter((file) => file.kind === "unchanged").length
1445
+ };
1446
+ const issues = issueMessagesFromDiagnostics(diagnostics);
1447
+ return {
1448
+ ok: issues.length === 0,
1449
+ mode: "plan",
1450
+ writes: false,
1451
+ current: currentTemplateMetadata(projectConfig),
1452
+ candidate: {
1453
+ id: candidateMetadata.id,
1454
+ version: candidateMetadata.version,
1455
+ source: candidateMetadata.source,
1456
+ sourceSpec: candidateMetadata.sourceSpec,
1457
+ requested: candidateMetadata.requested
1458
+ },
1459
+ compatible: issues.length === 0,
1460
+ issues,
1461
+ diagnostics,
1462
+ summary,
1463
+ files: visibleFiles
1464
+ };
1465
+ }
1466
+
1467
+ /**
1468
+ * @param {TemplateUpdatePlanOptions} options
1469
+ * @returns {{ ok: boolean, mode: "check", writes: false, current: ReturnType<typeof buildTemplateUpdatePlan>["current"], candidate: ReturnType<typeof buildTemplateUpdatePlan>["candidate"], compatible: boolean, issues: string[], diagnostics: TemplateUpdateDiagnostic[], summary: ReturnType<typeof buildTemplateUpdatePlan>["summary"], files: ReturnType<typeof buildTemplateUpdatePlan>["files"] }}
1470
+ */
1471
+ export function buildTemplateUpdateCheck(options) {
1472
+ const plan = buildTemplateUpdatePlan(options);
1473
+ const diagnostics = [...plan.diagnostics];
1474
+ if (plan.ok && plan.files.length > 0) {
1475
+ diagnostics.push(templateUpdateDiagnostic({
1476
+ code: "template_update_available",
1477
+ message: `Template update has ${plan.files.length} template-owned file change(s).`,
1478
+ path: options.projectRoot,
1479
+ suggestedFix: "Run `topogram template update --plan` to review, then `topogram template update --apply` after approval.",
1480
+ step: "check"
1481
+ }));
1482
+ }
1483
+ const issues = issueMessagesFromDiagnostics(diagnostics);
1484
+ return {
1485
+ ...plan,
1486
+ ok: issues.length === 0,
1487
+ mode: "check",
1488
+ writes: false,
1489
+ issues,
1490
+ diagnostics
1491
+ };
1492
+ }
1493
+
1494
+ /**
1495
+ * @param {TemplateUpdatePlanOptions} options
1496
+ * @param {ReturnType<typeof buildTemplateUpdatePlan>} plan
1497
+ * @param {"apply"|"status"} mode
1498
+ * @returns {{ diagnostics: TemplateUpdateDiagnostic[], issues: string[], skipped: Array<{ path: string, kind: "current-only", reason: string }>, conflicts: Array<{ path: string, reason: string }> }}
1499
+ */
1500
+ function analyzeTemplateUpdateApplication(options, plan, mode) {
1501
+ /** @type {Array<{ path: string, kind: "current-only", reason: string }>} */
1502
+ const skipped = [];
1503
+ /** @type {Array<{ path: string, reason: string }>} */
1504
+ const conflicts = [];
1505
+ /** @type {TemplateUpdateDiagnostic[]} */
1506
+ const diagnostics = [...plan.diagnostics];
1507
+ if (!plan.ok) {
1508
+ return {
1509
+ diagnostics,
1510
+ issues: issueMessagesFromDiagnostics(diagnostics),
1511
+ skipped,
1512
+ conflicts
1513
+ };
1514
+ }
1515
+
1516
+ const baselineManifest = readTemplateFilesManifest(options.projectRoot);
1517
+ if (!baselineManifest) {
1518
+ diagnostics.push(templateUpdateDiagnostic({
1519
+ code: "template_baseline_missing",
1520
+ message: `Cannot apply template update because ${TEMPLATE_FILES_MANIFEST} is missing. Review current template-owned files, then run 'topogram trust template' to record the baseline before applying template updates.`,
1521
+ path: path.join(options.projectRoot, TEMPLATE_FILES_MANIFEST),
1522
+ suggestedFix: "Review current template-owned files, then run `topogram trust template` to record the baseline before applying template updates.",
1523
+ step: "baseline"
1524
+ }));
1525
+ }
1526
+ const baselineByPath = new Map((baselineManifest?.files || []).map((file) => [file.path, file]));
1527
+ const currentHashes = currentTemplateOwnedFileHashes(options.projectRoot, options.projectConfig);
1528
+ for (const file of plan.files) {
1529
+ if (file.kind === "current-only") {
1530
+ skipped.push({
1531
+ path: file.path,
1532
+ kind: "current-only",
1533
+ reason: "Deletes are not applied by template update --apply in this milestone."
1534
+ });
1535
+ diagnostics.push(templateUpdateDiagnostic({
1536
+ code: "template_current_only_skipped",
1537
+ severity: "warning",
1538
+ message: `Current-only file '${file.path}' needs manual delete review. Deletes are not applied by template update --apply in this milestone.`,
1539
+ path: path.join(options.projectRoot, file.path),
1540
+ suggestedFix: "Delete the file manually after review if it should be removed from this project.",
1541
+ step: mode
1542
+ }));
1543
+ continue;
1544
+ }
1545
+ if (file.kind !== "added" && file.kind !== "changed") {
1546
+ continue;
1547
+ }
1548
+ const baseline = baselineByPath.get(file.path) || null;
1549
+ const currentHash = currentHashes.get(file.path) || null;
1550
+ if (!fileMatchesBaseline(baseline, currentHash)) {
1551
+ const reason = baseline
1552
+ ? "Current file differs from the last trusted template-owned baseline."
1553
+ : "Current file is not part of the trusted template-owned baseline.";
1554
+ conflicts.push({
1555
+ path: file.path,
1556
+ reason
1557
+ });
1558
+ diagnostics.push(templateUpdateDiagnostic({
1559
+ code: "template_update_conflict",
1560
+ message: `Template update conflict in '${file.path}': ${reason}`,
1561
+ path: path.join(options.projectRoot, file.path),
1562
+ suggestedFix: "Review local edits; keep them manually or refresh the baseline with `topogram trust template` after review.",
1563
+ step: "conflict-check"
1564
+ }));
1565
+ }
1566
+ }
1567
+ return {
1568
+ diagnostics,
1569
+ issues: issueMessagesFromDiagnostics(diagnostics),
1570
+ skipped,
1571
+ conflicts
1572
+ };
1573
+ }
1574
+
1575
+ /**
1576
+ * @param {TemplateUpdatePlanOptions} options
1577
+ * @returns {{ ok: boolean, mode: "status", writes: false, current: ReturnType<typeof buildTemplateUpdatePlan>["current"], candidate: ReturnType<typeof buildTemplateUpdatePlan>["candidate"], compatible: boolean, issues: string[], diagnostics: TemplateUpdateDiagnostic[], summary: ReturnType<typeof buildTemplateUpdatePlan>["summary"], applied: Array<{ path: string, kind: "added"|"changed" }>, skipped: Array<{ path: string, kind: "current-only", reason: string }>, conflicts: Array<{ path: string, reason: string }>, files: ReturnType<typeof buildTemplateUpdatePlan>["files"] }}
1578
+ */
1579
+ export function buildTemplateUpdateStatus(options) {
1580
+ const plan = buildTemplateUpdatePlan(options);
1581
+ const analysis = analyzeTemplateUpdateApplication(options, plan, "status");
1582
+ const diagnostics = [...analysis.diagnostics];
1583
+ if (plan.ok && plan.files.length > 0) {
1584
+ diagnostics.push(templateUpdateDiagnostic({
1585
+ code: "template_update_available",
1586
+ message: `Template update has ${plan.files.length} template-owned file change(s).`,
1587
+ path: options.projectRoot,
1588
+ suggestedFix: "Run `topogram template update --plan` to review, then `topogram template update --apply` after approval.",
1589
+ step: "status"
1590
+ }));
1591
+ }
1592
+ const issues = issueMessagesFromDiagnostics(diagnostics);
1593
+ return {
1594
+ ...plan,
1595
+ ok: issues.length === 0,
1596
+ mode: "status",
1597
+ writes: false,
1598
+ issues,
1599
+ diagnostics,
1600
+ applied: [],
1601
+ skipped: analysis.skipped,
1602
+ conflicts: analysis.conflicts
1603
+ };
1604
+ }
1605
+
1606
+ /**
1607
+ * @param {{ absolutePath: string|null, content: string|null }} candidateFile
1608
+ * @param {string} destinationPath
1609
+ * @returns {void}
1610
+ */
1611
+ function writeCandidateFile(candidateFile, destinationPath) {
1612
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
1613
+ if (candidateFile.content !== null) {
1614
+ fs.writeFileSync(destinationPath, candidateFile.content, "utf8");
1615
+ return;
1616
+ }
1617
+ if (!candidateFile.absolutePath) {
1618
+ throw new Error(`Cannot apply template file without content or source path: ${destinationPath}`);
1619
+ }
1620
+ fs.cpSync(candidateFile.absolutePath, destinationPath);
1621
+ }
1622
+
1623
+ /**
1624
+ * @param {TemplateOwnedFileRecord|null} baseline
1625
+ * @param {{ sha256: string, size: number }|null} currentHash
1626
+ * @returns {boolean}
1627
+ */
1628
+ function fileMatchesBaseline(baseline, currentHash) {
1629
+ if (!baseline && !currentHash) {
1630
+ return true;
1631
+ }
1632
+ if (!baseline || !currentHash) {
1633
+ return false;
1634
+ }
1635
+ return baseline.sha256 === currentHash.sha256 && baseline.size === currentHash.size;
1636
+ }
1637
+
1638
+ /**
1639
+ * @param {string} projectRoot
1640
+ * @param {string} action
1641
+ * @returns {TemplateUpdateDiagnostic}
1642
+ */
1643
+ function templateBaselineMissingDiagnostic(projectRoot, action) {
1644
+ return templateUpdateDiagnostic({
1645
+ code: "template_baseline_missing",
1646
+ message: `Cannot ${action} because ${TEMPLATE_FILES_MANIFEST} is missing. Review current template-owned files, then run 'topogram trust template' to record the baseline before applying template updates.`,
1647
+ path: path.join(projectRoot, TEMPLATE_FILES_MANIFEST),
1648
+ suggestedFix: "Review current template-owned files, then run `topogram trust template` to record the baseline before applying template updates.",
1649
+ step: "baseline"
1650
+ });
1651
+ }
1652
+
1653
+ /**
1654
+ * @param {TemplateUpdateDiagnostic[]} diagnostics
1655
+ * @param {ReturnType<typeof buildTemplateUpdatePlan>|null} plan
1656
+ * @param {TemplateUpdateFileActionOptions["action"]} action
1657
+ * @param {string} relativePath
1658
+ * @param {Array<{ path: string, kind: "added"|"changed" }>} applied
1659
+ * @param {Array<{ path: string, kind: "accepted-current" }>} accepted
1660
+ * @param {Array<{ path: string, kind: "current-only" }>} deleted
1661
+ * @param {Array<{ path: string, reason: string }>} conflicts
1662
+ * @param {ReturnType<typeof currentTemplateMetadata>} [current]
1663
+ * @returns {{ ok: boolean, mode: TemplateUpdateFileActionOptions["action"], writes: boolean, current: ReturnType<typeof currentTemplateMetadata>, candidate: ReturnType<typeof buildTemplateUpdatePlan>["candidate"]|null, compatible: boolean, issues: string[], diagnostics: TemplateUpdateDiagnostic[], summary: ReturnType<typeof buildTemplateUpdatePlan>["summary"], applied: Array<{ path: string, kind: "added"|"changed" }>, accepted: Array<{ path: string, kind: "accepted-current" }>, deleted: Array<{ path: string, kind: "current-only" }>, skipped: Array<{ path: string, kind: "current-only", reason: string }>, conflicts: Array<{ path: string, reason: string }>, files: ReturnType<typeof buildTemplateUpdatePlan>["files"], action: TemplateUpdateFileActionOptions["action"], path: string }}
1664
+ */
1665
+ function templateUpdateFileActionResult(diagnostics, plan, action, relativePath, applied, accepted, deleted, conflicts, current = { id: null, version: null, source: null, sourceSpec: null, requested: null }) {
1666
+ const issues = issueMessagesFromDiagnostics(diagnostics);
1667
+ return {
1668
+ ...(plan || {}),
1669
+ ok: issues.length === 0,
1670
+ mode: action,
1671
+ writes: applied.length > 0 || accepted.length > 0 || deleted.length > 0,
1672
+ current: plan?.current || current,
1673
+ candidate: plan?.candidate || null,
1674
+ compatible: plan?.compatible || issues.length === 0,
1675
+ issues,
1676
+ diagnostics,
1677
+ summary: plan?.summary || { added: 0, changed: 0, currentOnly: 0, unchanged: 0 },
1678
+ applied,
1679
+ accepted,
1680
+ deleted,
1681
+ skipped: [],
1682
+ conflicts,
1683
+ files: plan?.files || [],
1684
+ action,
1685
+ path: relativePath
1686
+ };
1687
+ }
1688
+
1689
+ /**
1690
+ * @param {TemplateUpdateFileActionOptions} options
1691
+ * @returns {{ ok: boolean, mode: "accept-current"|"accept-candidate"|"delete-current", writes: boolean, current: ReturnType<typeof currentTemplateMetadata>, candidate: ReturnType<typeof buildTemplateUpdatePlan>["candidate"]|null, compatible: boolean, issues: string[], diagnostics: TemplateUpdateDiagnostic[], summary: ReturnType<typeof buildTemplateUpdatePlan>["summary"], applied: Array<{ path: string, kind: "added"|"changed" }>, accepted: Array<{ path: string, kind: "accepted-current" }>, deleted: Array<{ path: string, kind: "current-only" }>, skipped: Array<{ path: string, kind: "current-only", reason: string }>, conflicts: Array<{ path: string, reason: string }>, files: ReturnType<typeof buildTemplateUpdatePlan>["files"], action: "accept-current"|"accept-candidate"|"delete-current", path: string }}
1692
+ */
1693
+ export function applyTemplateUpdateFileAction(options) {
1694
+ const relativePath = normalizeTemplateUpdateActionPath(options.filePath);
1695
+ /** @type {TemplateUpdateDiagnostic[]} */
1696
+ const diagnostics = [];
1697
+ /** @type {Array<{ path: string, kind: "added"|"changed" }>} */
1698
+ const applied = [];
1699
+ /** @type {Array<{ path: string, kind: "accepted-current" }>} */
1700
+ const accepted = [];
1701
+ /** @type {Array<{ path: string, kind: "current-only" }>} */
1702
+ const deleted = [];
1703
+ /** @type {Array<{ path: string, reason: string }>} */
1704
+ const conflicts = [];
1705
+ const baselineManifest = readTemplateFilesManifest(options.projectRoot);
1706
+ const current = currentTemplateMetadata(options.projectConfig);
1707
+ if (!baselineManifest) {
1708
+ diagnostics.push(templateBaselineMissingDiagnostic(options.projectRoot, options.action));
1709
+ return templateUpdateFileActionResult(diagnostics, null, options.action, relativePath, applied, accepted, deleted, conflicts, current);
1710
+ }
1711
+
1712
+ if (options.action === "accept-current") {
1713
+ const currentHashes = currentTemplateOwnedFileHashes(options.projectRoot, options.projectConfig);
1714
+ const currentHash = currentHashes.get(relativePath) || null;
1715
+ if (!currentHash) {
1716
+ diagnostics.push(templateUpdateDiagnostic({
1717
+ code: "template_file_not_current",
1718
+ message: `Cannot accept current file '${relativePath}' because it is not a current template-owned file.`,
1719
+ path: path.join(options.projectRoot, relativePath),
1720
+ suggestedFix: "Pass a file under topogram/, topogram.project.json, or trusted implementation/.",
1721
+ step: "accept-current"
1722
+ }));
1723
+ return templateUpdateFileActionResult(diagnostics, null, options.action, relativePath, applied, accepted, deleted, conflicts, current);
1724
+ }
1725
+ updateTemplateFilesManifestRecord(options.projectRoot, baselineManifest, relativePath, currentHash);
1726
+ accepted.push({ path: relativePath, kind: "accepted-current" });
1727
+ return templateUpdateFileActionResult(diagnostics, null, options.action, relativePath, applied, accepted, deleted, conflicts, current);
1728
+ }
1729
+
1730
+ const plan = buildTemplateUpdatePlan(options);
1731
+ diagnostics.push(...plan.diagnostics);
1732
+ if (!plan.ok) {
1733
+ return templateUpdateFileActionResult(diagnostics, plan, options.action, relativePath, applied, accepted, deleted, conflicts);
1734
+ }
1735
+ const file = plan.files.find((item) => item.path === relativePath) || null;
1736
+ if (!file) {
1737
+ diagnostics.push(templateUpdateDiagnostic({
1738
+ code: "template_file_unchanged",
1739
+ message: `Template-owned file '${relativePath}' has no candidate update action.`,
1740
+ path: path.join(options.projectRoot, relativePath),
1741
+ suggestedFix: "Run `topogram template update --status` to see files that need adoption.",
1742
+ step: options.action
1743
+ }));
1744
+ return templateUpdateFileActionResult(diagnostics, plan, options.action, relativePath, applied, accepted, deleted, conflicts);
1745
+ }
1746
+
1747
+ const baselineByPath = new Map(baselineManifest.files.map((record) => [record.path, record]));
1748
+ const currentHashes = currentTemplateOwnedFileHashes(options.projectRoot, options.projectConfig);
1749
+ const baseline = baselineByPath.get(relativePath) || null;
1750
+ const currentHash = currentHashes.get(relativePath) || null;
1751
+
1752
+ if (options.action === "delete-current") {
1753
+ if (file.kind !== "current-only") {
1754
+ diagnostics.push(templateUpdateDiagnostic({
1755
+ code: "template_delete_not_current_only",
1756
+ message: `Cannot delete '${relativePath}' because it is not a current-only template-owned file.`,
1757
+ path: path.join(options.projectRoot, relativePath),
1758
+ suggestedFix: "Use delete-current only for files the candidate template removed.",
1759
+ step: "delete-current"
1760
+ }));
1761
+ return templateUpdateFileActionResult(diagnostics, plan, options.action, relativePath, applied, accepted, deleted, conflicts);
1762
+ }
1763
+ if (!fileMatchesBaseline(baseline, currentHash)) {
1764
+ const reason = baseline
1765
+ ? "Current file differs from the last trusted template-owned baseline."
1766
+ : "Current file is not part of the trusted template-owned baseline.";
1767
+ conflicts.push({ path: relativePath, reason });
1768
+ diagnostics.push(templateUpdateDiagnostic({
1769
+ code: "template_update_conflict",
1770
+ message: `Template delete conflict in '${relativePath}': ${reason}`,
1771
+ path: path.join(options.projectRoot, relativePath),
1772
+ suggestedFix: "Review local edits before deleting, or accept current as the new baseline.",
1773
+ step: "delete-current"
1774
+ }));
1775
+ return templateUpdateFileActionResult(diagnostics, plan, options.action, relativePath, applied, accepted, deleted, conflicts);
1776
+ }
1777
+ fs.rmSync(path.join(options.projectRoot, relativePath));
1778
+ updateTemplateFilesManifestRecord(options.projectRoot, baselineManifest, relativePath, null);
1779
+ deleted.push({ path: relativePath, kind: "current-only" });
1780
+ return templateUpdateFileActionResult(diagnostics, plan, options.action, relativePath, applied, accepted, deleted, conflicts);
1781
+ }
1782
+
1783
+ if (file.kind !== "added" && file.kind !== "changed") {
1784
+ diagnostics.push(templateUpdateDiagnostic({
1785
+ code: "template_candidate_not_applicable",
1786
+ message: `Cannot accept candidate for '${relativePath}' because the candidate has no added or changed file.`,
1787
+ path: path.join(options.projectRoot, relativePath),
1788
+ suggestedFix: "Use accept-candidate only for added or changed candidate files.",
1789
+ step: "accept-candidate"
1790
+ }));
1791
+ return templateUpdateFileActionResult(diagnostics, plan, options.action, relativePath, applied, accepted, deleted, conflicts);
1792
+ }
1793
+ if (file.kind === "changed" && !fileMatchesBaseline(baseline, currentHash)) {
1794
+ const reason = baseline
1795
+ ? "Current file differs from the last trusted template-owned baseline."
1796
+ : "Current file is not part of the trusted template-owned baseline.";
1797
+ conflicts.push({ path: relativePath, reason });
1798
+ diagnostics.push(templateUpdateDiagnostic({
1799
+ code: "template_update_conflict",
1800
+ message: `Template candidate conflict in '${relativePath}': ${reason}`,
1801
+ path: path.join(options.projectRoot, relativePath),
1802
+ suggestedFix: "Review local edits before accepting the candidate file.",
1803
+ step: "accept-candidate"
1804
+ }));
1805
+ return templateUpdateFileActionResult(diagnostics, plan, options.action, relativePath, applied, accepted, deleted, conflicts);
1806
+ }
1807
+ const currentTemplate = options.projectConfig.template || {};
1808
+ const templateSpec = options.templateName || currentTemplate.sourceSpec || currentTemplate.requested || currentTemplate.id;
1809
+ const candidateTemplate = resolveTemplate(templateSpec, options.templatesRoot);
1810
+ const candidateFile = candidateTemplateFiles(candidateTemplate, options.projectConfig).get(relativePath);
1811
+ if (!candidateFile) {
1812
+ throw new Error(`Cannot accept missing candidate template file: ${relativePath}`);
1813
+ }
1814
+ writeCandidateFile(candidateFile, path.join(options.projectRoot, relativePath));
1815
+ const nextHash = fileHash({
1816
+ absolutePath: path.join(options.projectRoot, relativePath),
1817
+ content: null
1818
+ });
1819
+ updateTemplateFilesManifestRecord(options.projectRoot, baselineManifest, relativePath, {
1820
+ path: relativePath,
1821
+ sha256: nextHash.sha256,
1822
+ size: nextHash.size
1823
+ });
1824
+ applied.push({ path: relativePath, kind: file.kind });
1825
+ return templateUpdateFileActionResult(diagnostics, plan, options.action, relativePath, applied, accepted, deleted, conflicts);
1826
+ }
1827
+
1828
+ /**
1829
+ * @param {TemplateUpdatePlanOptions} options
1830
+ * @returns {{ ok: boolean, mode: "apply", writes: boolean, current: ReturnType<typeof buildTemplateUpdatePlan>["current"], candidate: ReturnType<typeof buildTemplateUpdatePlan>["candidate"], compatible: boolean, issues: string[], diagnostics: TemplateUpdateDiagnostic[], summary: ReturnType<typeof buildTemplateUpdatePlan>["summary"], applied: Array<{ path: string, kind: "added"|"changed" }>, skipped: Array<{ path: string, kind: "current-only", reason: string }>, conflicts: Array<{ path: string, reason: string }>, files: ReturnType<typeof buildTemplateUpdatePlan>["files"] }}
1831
+ */
1832
+ export function applyTemplateUpdate(options) {
1833
+ const plan = buildTemplateUpdatePlan(options);
1834
+ /** @type {Array<{ path: string, kind: "added"|"changed" }>} */
1835
+ const applied = [];
1836
+ const analysis = analyzeTemplateUpdateApplication(options, plan, "apply");
1837
+ const { diagnostics, issues, skipped, conflicts } = analysis;
1838
+ if (!plan.ok || issues.length > 0) {
1839
+ return {
1840
+ ...plan,
1841
+ ok: false,
1842
+ mode: "apply",
1843
+ writes: false,
1844
+ applied,
1845
+ skipped,
1846
+ conflicts,
1847
+ issues,
1848
+ diagnostics
1849
+ };
1850
+ }
1851
+
1852
+ const currentTemplate = options.projectConfig.template || {};
1853
+ const templateSpec = options.templateName || currentTemplate.sourceSpec || currentTemplate.requested || currentTemplate.id;
1854
+ const candidateTemplate = resolveTemplate(templateSpec, options.templatesRoot);
1855
+ const candidateFiles = candidateTemplateFiles(candidateTemplate, options.projectConfig);
1856
+ for (const file of plan.files) {
1857
+ if (file.kind !== "added" && file.kind !== "changed") {
1858
+ continue;
1859
+ }
1860
+ const candidateFile = candidateFiles.get(file.path);
1861
+ if (!candidateFile) {
1862
+ throw new Error(`Cannot apply missing candidate template file: ${file.path}`);
1863
+ }
1864
+ writeCandidateFile(candidateFile, path.join(options.projectRoot, file.path));
1865
+ applied.push({ path: file.path, kind: file.kind });
1866
+ }
1867
+
1868
+ if (applied.length > 0) {
1869
+ const nextProjectConfig = JSON.parse(fs.readFileSync(path.join(options.projectRoot, "topogram.project.json"), "utf8"));
1870
+ writeTemplateFilesManifest(options.projectRoot, nextProjectConfig);
1871
+ if (nextProjectConfig.implementation) {
1872
+ writeTemplateTrustRecord(options.projectRoot, nextProjectConfig);
1873
+ }
1874
+ }
1875
+ return {
1876
+ ...plan,
1877
+ ok: true,
1878
+ mode: "apply",
1879
+ writes: applied.length > 0,
1880
+ issues,
1881
+ diagnostics,
1882
+ applied,
1883
+ skipped,
1884
+ conflicts
1885
+ };
1886
+ }
1887
+
1888
+ /**
1889
+ * @param {string} projectRoot
1890
+ * @param {string} engineRoot
1891
+ * @param {ResolvedTemplate} template
1892
+ * @returns {void}
1893
+ */
1894
+ function writeProjectPackage(projectRoot, engineRoot, template) {
1895
+ const cliDependency = cliDependencyForProject(projectRoot, engineRoot);
1896
+ const generatorDependencies = generatorDependenciesForTemplate(template.root);
1897
+ const starterScripts = template.manifest.starterScripts || {};
1898
+ const pkg = {
1899
+ name: packageNameFromPath(projectRoot),
1900
+ private: true,
1901
+ type: "module",
1902
+ scripts: {
1903
+ explain: "node ./scripts/explain.mjs",
1904
+ doctor: "topogram doctor",
1905
+ "source:status": "topogram source status --local",
1906
+ "source:status:remote": "topogram source status --remote",
1907
+ check: "topogram check",
1908
+ "check:json": "topogram check --json",
1909
+ "query:list": "topogram query list --json",
1910
+ "query:show": "topogram query show",
1911
+ generate: "topogram generate",
1912
+ "template:explain": "topogram template explain",
1913
+ "template:status": "topogram template status",
1914
+ "template:detach": "topogram template detach",
1915
+ "template:detach:dry-run": "topogram template detach --dry-run",
1916
+ "template:policy:check": "topogram template policy check",
1917
+ "template:policy:explain": "topogram template policy explain",
1918
+ "template:update:status": "topogram template update --status",
1919
+ "template:update:recommend": "topogram template update --recommend",
1920
+ "template:update:plan": "topogram template update --plan",
1921
+ "template:update:check": "topogram template update --check",
1922
+ "template:update:apply": "topogram template update --apply",
1923
+ "trust:status": "topogram trust status",
1924
+ "trust:diff": "topogram trust diff",
1925
+ verify: "npm run app:compile",
1926
+ bootstrap: "npm run app:bootstrap",
1927
+ dev: "npm run app:dev",
1928
+ "app:bootstrap": "npm --prefix ./app run bootstrap",
1929
+ "app:dev": "npm --prefix ./app run dev",
1930
+ "app:compile": "npm --prefix ./app run compile",
1931
+ "app:smoke": "npm --prefix ./app run smoke",
1932
+ "app:runtime-check": "npm --prefix ./app run runtime-check",
1933
+ "app:check": "npm run app:compile",
1934
+ "app:probe": "npm run app:smoke && npm run app:runtime-check",
1935
+ "app:runtime": "npm --prefix ./app run runtime",
1936
+ ...starterScripts
1937
+ },
1938
+ devDependencies: {
1939
+ [cliDependency.name]: cliDependency.spec,
1940
+ ...generatorDependencies
1941
+ }
1942
+ };
1943
+ fs.writeFileSync(path.join(projectRoot, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
1944
+ writeProjectNpmConfig(projectRoot, cliDependency);
1945
+ }
1946
+
1947
+ /**
1948
+ * @param {string} projectRoot
1949
+ * @returns {void}
1950
+ */
1951
+ function writeExplainScript(projectRoot) {
1952
+ const scriptDir = path.join(projectRoot, "scripts");
1953
+ fs.mkdirSync(scriptDir, { recursive: true });
1954
+ const script = `const message = \`
1955
+ Topogram app workflow
1956
+
1957
+ 1. Edit:
1958
+ topogram/
1959
+ topogram.project.json
1960
+
1961
+ 2. Validate:
1962
+ npm run doctor
1963
+ npm run source:status
1964
+ npm run template:explain
1965
+ npm run check
1966
+
1967
+ 3. Regenerate:
1968
+ npm run generate
1969
+
1970
+ 4. Verify generated app:
1971
+ npm run verify
1972
+
1973
+ 5. Run locally:
1974
+ npm run bootstrap
1975
+ npm run dev
1976
+
1977
+ 6. Probe the running app from another terminal:
1978
+ npm run app:probe
1979
+
1980
+ Or run self-contained local runtime verification:
1981
+ npm run app:runtime
1982
+
1983
+ Useful inspection:
1984
+ npm run check:json
1985
+ npm run doctor
1986
+ npm run source:status
1987
+ npm run source:status:remote
1988
+ npm run template:explain
1989
+ npm run template:status
1990
+ npm run template:detach:dry-run
1991
+ npm run template:policy:check
1992
+ npm run template:policy:explain
1993
+ npm run template:update:status
1994
+ npm run template:update:recommend
1995
+ npm run template:update:plan
1996
+ npm run template:update:check
1997
+ npm run template:update:apply
1998
+ npm run trust:status
1999
+ npm run trust:diff
2000
+ \`;
2001
+
2002
+ console.log(message.trimEnd());
2003
+ `;
2004
+ fs.writeFileSync(path.join(scriptDir, "explain.mjs"), script, "utf8");
2005
+ }
2006
+
2007
+ /**
2008
+ * @param {string} projectRoot
2009
+ * @param {Record<string, any>} projectConfig
2010
+ * @returns {void}
2011
+ */
2012
+ function writeProjectReadme(projectRoot, projectConfig) {
2013
+ const template = projectConfig.template || {};
2014
+ const templateName = template.id || "unknown";
2015
+ const workflowCommands = [
2016
+ "npm install",
2017
+ "npm run explain",
2018
+ "npm run doctor",
2019
+ "npm run source:status",
2020
+ "npm run template:explain",
2021
+ "npm run check",
2022
+ "npm run template:policy:check",
2023
+ ...(template.includesExecutableImplementation ? [
2024
+ "npm run template:policy:explain",
2025
+ "npm run trust:status"
2026
+ ] : []),
2027
+ "npm run generate",
2028
+ "npm run verify"
2029
+ ];
2030
+ const provenanceLines = [];
2031
+ provenanceLines.push(`- Template: \`${templateName}@${template.version || "unknown"}\``);
2032
+ provenanceLines.push(`- Source: \`${template.source || "unknown"}\``);
2033
+ if (template.sourceSpec) {
2034
+ provenanceLines.push(`- Source spec: \`${template.sourceSpec}\``);
2035
+ }
2036
+ if (template.catalog) {
2037
+ provenanceLines.push(`- Catalog: \`${template.catalog.id}\` from \`${template.catalog.source}\``);
2038
+ provenanceLines.push(`- Package: \`${template.catalog.packageSpec}\``);
2039
+ }
2040
+ provenanceLines.push(`- Executable implementation: \`${template.includesExecutableImplementation ? "yes" : "no"}\``);
2041
+ const readme = `# ${packageNameFromPath(projectRoot)}
2042
+
2043
+ Generated by \`topogram new\`.
2044
+
2045
+ ## Template
2046
+
2047
+ ${provenanceLines.join("\n")}
2048
+
2049
+ ## Workflow
2050
+
2051
+ \`\`\`bash
2052
+ ${workflowCommands.join("\n")}
2053
+ \`\`\`
2054
+
2055
+ Edit \`topogram/\` and \`topogram.project.json\`, then regenerate with \`npm run generate\`.
2056
+ Generated app code is written to \`app/\`.
2057
+ ${template.includesExecutableImplementation ? "\nThis template copied `implementation/` code. `topogram new` did not execute it; review `implementation/`, `topogram.template-policy.json`, and `.topogram-template-trust.json` before regenerating after edits.\n" : ""}
2058
+ `;
2059
+ fs.writeFileSync(path.join(projectRoot, "README.md"), readme, "utf8");
2060
+ }
2061
+
2062
+ /**
2063
+ * @param {CreateNewProjectOptions} options
2064
+ * @returns {{ projectRoot: string, templateName: string, template: Record<string, any>, topogramPath: string, appPath: string, warnings: string[] }}
2065
+ */
2066
+ export function createNewProject({
2067
+ targetPath,
2068
+ templateName = DEFAULT_TEMPLATE_NAME,
2069
+ engineRoot,
2070
+ templatesRoot,
2071
+ templateProvenance = null
2072
+ }) {
2073
+ if (!targetPath) {
2074
+ throw new Error("topogram new requires <path>.");
2075
+ }
2076
+ const projectRoot = path.resolve(targetPath);
2077
+ assertProjectOutsideEngine(projectRoot, engineRoot);
2078
+ const template = resolveTemplate(templateName, templatesRoot);
2079
+
2080
+ ensureCreatableProjectRoot(projectRoot);
2081
+ copyTopogramWorkspace(template.root, projectRoot);
2082
+ const projectConfig = writeProjectTemplateMetadata(projectRoot, template, templateProvenance);
2083
+ writeProjectPackage(projectRoot, engineRoot, template);
2084
+ writeExplainScript(projectRoot);
2085
+ writeProjectReadme(projectRoot, projectConfig);
2086
+ writeTemplateFilesManifest(projectRoot, projectConfig);
2087
+ writeTemplatePolicy(projectRoot, defaultTemplatePolicyForTemplate(template));
2088
+
2089
+ const warnings = [];
2090
+ if (template.manifest.includesExecutableImplementation) {
2091
+ writeTemplateTrustRecord(projectRoot, projectConfig);
2092
+ warnings.push(
2093
+ `Template '${template.manifest.id}' copied implementation/ code into this project. ` +
2094
+ "topogram new did not execute it, but topogram generate may load it later. " +
2095
+ "Recorded local trust in .topogram-template-trust.json."
2096
+ );
2097
+ }
2098
+
2099
+ return {
2100
+ projectRoot,
2101
+ templateName: template.manifest.id,
2102
+ template: projectConfig.template,
2103
+ topogramPath: path.join(projectRoot, "topogram"),
2104
+ appPath: path.join(projectRoot, "app"),
2105
+ warnings
2106
+ };
2107
+ }