@topogram/cli 0.3.64 → 0.3.65

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