@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.
- package/package.json +1 -1
- package/src/adoption/plan/index.js +703 -0
- package/src/adoption/plan.js +12 -703
- package/src/agent-ops/query-builders/auth.js +375 -0
- package/src/agent-ops/query-builders/change-risk/change-plan.js +123 -0
- package/src/agent-ops/query-builders/change-risk/import-plan.js +49 -0
- package/src/agent-ops/query-builders/change-risk/maintained.js +286 -0
- package/src/agent-ops/query-builders/change-risk/review-packets.js +123 -0
- package/src/agent-ops/query-builders/change-risk/risk.js +189 -0
- package/src/agent-ops/query-builders/change-risk.js +25 -0
- package/src/agent-ops/query-builders/common.js +149 -0
- package/src/agent-ops/query-builders/maintained-risk.js +539 -0
- package/src/agent-ops/query-builders/maintained-shared.js +120 -0
- package/src/agent-ops/query-builders/multi-agent.js +547 -0
- package/src/agent-ops/query-builders/projection-impacts.js +514 -0
- package/src/agent-ops/query-builders/work-packets.js +417 -0
- package/src/agent-ops/query-builders/workflow-context-shared.js +300 -0
- package/src/agent-ops/query-builders/workflow-context.js +398 -0
- package/src/agent-ops/query-builders/workflow-presets-core.js +676 -0
- package/src/agent-ops/query-builders/workflow-presets.js +341 -0
- package/src/agent-ops/query-builders.d.ts +26 -26
- package/src/agent-ops/query-builders.js +42 -5021
- package/src/catalog/constants.js +10 -0
- package/src/catalog/copy.js +60 -0
- package/src/catalog/diagnostics.js +15 -0
- package/src/catalog/entries.js +42 -0
- package/src/catalog/files.js +67 -0
- package/src/catalog/provenance.js +122 -0
- package/src/catalog/source.js +150 -0
- package/src/catalog/validation.js +252 -0
- package/src/catalog.d.ts +2 -0
- package/src/catalog.js +18 -746
- package/src/cli/commands/catalog/check.js +31 -0
- package/src/cli/commands/catalog/copy.js +59 -0
- package/src/cli/commands/catalog/doctor.js +248 -0
- package/src/cli/commands/catalog/help.js +21 -0
- package/src/cli/commands/catalog/list.js +52 -0
- package/src/cli/commands/catalog/runner.js +92 -0
- package/src/cli/commands/catalog/shared.js +17 -0
- package/src/cli/commands/catalog/show.js +134 -0
- package/src/cli/commands/catalog.js +30 -615
- package/src/cli/commands/generator-policy/package-info.js +162 -0
- package/src/cli/commands/generator-policy/payloads.js +372 -0
- package/src/cli/commands/generator-policy/printers.js +159 -0
- package/src/cli/commands/generator-policy/runner.js +81 -0
- package/src/cli/commands/generator-policy/shared.js +39 -0
- package/src/cli/commands/generator-policy.js +15 -783
- package/src/cli/commands/import/adopt.js +170 -0
- package/src/cli/commands/import/check.js +91 -0
- package/src/cli/commands/import/diff.js +84 -0
- package/src/cli/commands/import/help.js +47 -0
- package/src/cli/commands/import/paths.js +277 -0
- package/src/cli/commands/import/plan.js +284 -0
- package/src/cli/commands/import/refresh.js +470 -0
- package/src/cli/commands/import/status-history.js +196 -0
- package/src/cli/commands/import/workspace.js +230 -0
- package/src/cli/commands/import.js +33 -1732
- package/src/cli/commands/package/constants.js +17 -0
- package/src/cli/commands/package/doctor.js +240 -0
- package/src/cli/commands/package/help.js +27 -0
- package/src/cli/commands/package/lockfile.js +135 -0
- package/src/cli/commands/package/npm.js +97 -0
- package/src/cli/commands/package/reporting.js +35 -0
- package/src/cli/commands/package/runner.js +33 -0
- package/src/cli/commands/package/shared.js +9 -0
- package/src/cli/commands/package/update-cli.js +252 -0
- package/src/cli/commands/package/versions.js +35 -0
- package/src/cli/commands/package.js +29 -813
- package/src/cli/commands/query/change-plan.js +68 -0
- package/src/cli/commands/query/definitions.js +202 -0
- package/src/cli/commands/query/import-adopt.js +121 -0
- package/src/cli/commands/query/runner/artifacts.js +102 -0
- package/src/cli/commands/query/runner/boundaries.js +211 -0
- package/src/cli/commands/query/runner/change.js +182 -0
- package/src/cli/commands/query/runner/import-adopt.js +111 -0
- package/src/cli/commands/query/runner/index.js +31 -0
- package/src/cli/commands/query/runner/output.js +12 -0
- package/src/cli/commands/query/runner/workflow.js +241 -0
- package/src/cli/commands/query/runner.js +3 -0
- package/src/cli/commands/query/workflow-context.js +5 -0
- package/src/cli/commands/query/workspace.js +274 -0
- package/src/cli/commands/query.js +9 -1300
- package/src/cli/commands/template/baseline.js +100 -0
- package/src/cli/commands/template/check.js +466 -0
- package/src/cli/commands/template/constants.js +8 -0
- package/src/cli/commands/template/diagnostics.js +26 -0
- package/src/cli/commands/template/help.js +28 -0
- package/src/cli/commands/template/lifecycle.js +404 -0
- package/src/cli/commands/template/list-show.js +287 -0
- package/src/cli/commands/template/policy.js +422 -0
- package/src/cli/commands/template/shared.js +127 -0
- package/src/cli/commands/template/updates.js +352 -0
- package/src/cli/commands/template.js +41 -2143
- package/src/generator/api/contracts.js +497 -0
- package/src/generator/api/metadata.js +221 -0
- package/src/generator/api/openapi.js +559 -0
- package/src/generator/api/schema.js +124 -0
- package/src/generator/api/types.d.ts +98 -0
- package/src/generator/api.js +3 -1195
- package/src/generator/context/shared/domain-sdlc.js +282 -0
- package/src/generator/context/shared/maintained-boundary.js +665 -0
- package/src/generator/context/shared/metrics.js +85 -0
- package/src/generator/context/shared/primitives.js +64 -0
- package/src/generator/context/shared/relationships.js +453 -0
- package/src/generator/context/shared/summaries.js +263 -0
- package/src/generator/context/shared/types.d.ts +207 -0
- package/src/generator/context/shared.d.ts +42 -0
- package/src/generator/context/shared.js +80 -1390
- package/src/generator/context/slice/core.js +397 -0
- package/src/generator/context/slice/sdlc.js +417 -0
- package/src/generator/context/slice/ui-packets.js +183 -0
- package/src/generator/context/slice.js +2 -859
- package/src/generator/registry/index.js +507 -0
- package/src/generator/registry.js +18 -504
- package/src/generator/runtime/environment/index.js +666 -0
- package/src/generator/runtime/environment.js +4 -666
- package/src/generator/runtime/runtime-check/index.js +554 -0
- package/src/generator/runtime/runtime-check.js +4 -554
- package/src/generator/runtime/shared/index.js +572 -0
- package/src/generator/runtime/shared.js +19 -570
- package/src/generator/shared.d.ts +2 -0
- package/src/generator/surfaces/shared.d.ts +3 -0
- package/src/generator/widget-conformance/behavior-report.js +258 -0
- package/src/generator/widget-conformance/checks.js +371 -0
- package/src/generator/widget-conformance/projection-context.js +200 -0
- package/src/generator/widget-conformance/report.js +166 -0
- package/src/generator/widget-conformance/types.d.ts +121 -0
- package/src/generator/widget-conformance.js +3 -824
- package/src/import/core/context.d.ts +3 -0
- package/src/import/core/contracts.d.ts +1 -0
- package/src/import/core/registry.d.ts +4 -0
- package/src/import/core/runner/candidates.js +217 -0
- package/src/import/core/runner/options.js +22 -0
- package/src/import/core/runner/reports.js +50 -0
- package/src/import/core/runner/run.js +79 -0
- package/src/import/core/runner/tracks.js +150 -0
- package/src/import/core/runner/ui-drafts.js +337 -0
- package/src/import/core/runner.js +3 -698
- package/src/import/core/shared/api-routes.js +221 -0
- package/src/import/core/shared/candidates.js +97 -0
- package/src/import/core/shared/files.js +177 -0
- package/src/import/core/shared/next-app.js +389 -0
- package/src/import/core/shared/types.d.ts +51 -0
- package/src/import/core/shared/ui-routes.js +230 -0
- package/src/import/core/shared.js +60 -861
- package/src/new-project/constants.js +128 -0
- package/src/new-project/create.js +83 -0
- package/src/new-project/json.js +28 -0
- package/src/new-project/metadata.js +96 -0
- package/src/new-project/package-spec.js +161 -0
- package/src/new-project/project-files.js +348 -0
- package/src/new-project/template-policy.js +269 -0
- package/src/new-project/template-resolution.js +368 -0
- package/src/new-project/template-snapshots.js +430 -0
- package/src/new-project/template-updates.js +512 -0
- package/src/new-project/types.d.ts +83 -0
- package/src/new-project.js +6 -2277
- package/src/parser.d.ts +87 -1
- package/src/parser.js +118 -0
- package/src/policy/review-boundaries.d.ts +15 -0
- package/src/project-config/index.js +564 -0
- package/src/project-config.js +19 -561
- package/src/resolver/enrich/acceptance-criterion.js +2 -0
- package/src/resolver/enrich/bug.js +2 -0
- package/src/resolver/enrich/pitch.js +2 -0
- package/src/resolver/enrich/requirement.js +2 -0
- package/src/resolver/enrich/task.js +2 -0
- package/src/resolver/index.js +19 -2089
- package/src/resolver/normalize.js +384 -1
- package/src/resolver/plans.js +168 -0
- package/src/resolver/projections-api.js +494 -0
- package/src/resolver/projections-db.js +133 -0
- package/src/resolver/projections-ui.js +317 -0
- package/src/resolver/shapes.js +251 -0
- package/src/resolver/shared.js +278 -0
- package/src/resolver/widgets.js +132 -0
- package/src/template-trust/constants.js +62 -0
- package/src/template-trust/content.js +258 -0
- package/src/template-trust/diff.js +92 -0
- package/src/template-trust/policy.js +61 -0
- package/src/template-trust/record.js +90 -0
- package/src/template-trust/status.js +182 -0
- package/src/template-trust.js +24 -687
- package/src/text-helpers.d.ts +1 -0
- package/src/topogram-types.d.ts +69 -0
- package/src/validator/common.js +488 -0
- package/src/validator/data-model.js +237 -0
- package/src/validator/docs.js +167 -0
- package/src/validator/expressions.js +146 -1
- package/src/validator/index.d.ts +23 -0
- package/src/validator/index.js +32 -3585
- package/src/validator/kinds.d.ts +41 -0
- package/src/validator/kinds.js +2 -0
- package/src/validator/model-helpers.js +46 -0
- package/src/validator/per-kind/acceptance-criterion.js +5 -0
- package/src/validator/per-kind/bug.js +6 -0
- package/src/validator/per-kind/domain.js +15 -2
- package/src/validator/per-kind/pitch.js +7 -0
- package/src/validator/per-kind/requirement.js +5 -0
- package/src/validator/per-kind/task.js +7 -0
- package/src/validator/per-kind/widget.js +14 -0
- package/src/validator/projections/api-http-async.js +410 -0
- package/src/validator/projections/api-http-authz.js +88 -0
- package/src/validator/projections/api-http-core.js +205 -0
- package/src/validator/projections/api-http-policies.js +339 -0
- package/src/validator/projections/api-http-responses.js +233 -0
- package/src/validator/projections/api-http.js +44 -0
- package/src/validator/projections/db.js +353 -0
- package/src/validator/projections/generator-defaults.js +45 -0
- package/src/validator/projections/helpers.js +87 -0
- package/src/validator/projections/ui-helpers.js +214 -0
- package/src/validator/projections/ui-navigation.js +344 -0
- package/src/validator/projections/ui-structure.js +364 -0
- package/src/validator/projections/ui-widgets.js +493 -0
- package/src/validator/projections/ui.js +46 -0
- package/src/validator/registry.js +48 -1
- package/src/validator/utils.d.ts +20 -0
- package/src/validator/utils.js +115 -12
- package/src/widget-behavior.d.ts +1 -0
- package/src/workflows/import-app/api/collect.js +221 -0
- package/src/workflows/import-app/api/openapi.js +257 -0
- package/src/workflows/import-app/api/routes.js +327 -0
- package/src/workflows/import-app/api/sources.js +22 -0
- package/src/workflows/import-app/api.js +2 -797
- package/src/workflows/reconcile/adoption-plan/build.js +208 -0
- package/src/workflows/reconcile/adoption-plan/dependencies.js +75 -0
- package/src/workflows/reconcile/adoption-plan/outputs.js +143 -0
- package/src/workflows/reconcile/adoption-plan/paths.js +58 -0
- package/src/workflows/reconcile/adoption-plan/projection-patches.js +177 -0
- package/src/workflows/reconcile/adoption-plan/reasons.js +107 -0
- package/src/workflows/reconcile/adoption-plan.js +30 -740
- package/src/workflows/reconcile/auth/closures.js +115 -0
- package/src/workflows/reconcile/auth/formatters.js +142 -0
- package/src/workflows/reconcile/auth/inference.js +330 -0
- package/src/workflows/reconcile/auth/roles.js +122 -0
- package/src/workflows/reconcile/auth.js +35 -690
- package/src/workflows/reconcile/bundle-core/index.js +600 -0
- package/src/workflows/reconcile/bundle-core.js +12 -598
- package/src/workflows/reconcile/canonical-surface.js +1 -1
- package/src/workflows/reconcile/impacts/adoption-plan.js +192 -0
- package/src/workflows/reconcile/impacts/indexes.js +101 -0
- package/src/workflows/reconcile/impacts/patches.js +252 -0
- package/src/workflows/reconcile/impacts/reports.js +80 -0
- package/src/workflows/reconcile/impacts.js +14 -623
- package/src/workspace-docs.d.ts +29 -0
package/src/new-project.js
CHANGED
|
@@ -1,2279 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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";
|