@vincentt-xr/cli 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/cli/args.js +1 -0
  2. package/dist/cli/index.js +198 -3
  3. package/dist/cli/usage-text.js +27 -0
  4. package/dist/cli/verb-surface.d.ts +3 -1
  5. package/dist/cli/verb-surface.js +12 -0
  6. package/dist/domain/api.d.ts +51 -0
  7. package/dist/domain/api.js +145 -0
  8. package/dist/domain/command.d.ts +75 -0
  9. package/dist/domain/command.js +467 -0
  10. package/dist/domain/copy.d.ts +334 -0
  11. package/dist/domain/copy.js +952 -0
  12. package/dist/domain/hostname.d.ts +57 -0
  13. package/dist/domain/hostname.js +116 -0
  14. package/dist/domain/names.d.ts +78 -0
  15. package/dist/domain/names.js +113 -0
  16. package/dist/domain/routedVerbs.d.ts +10 -0
  17. package/dist/domain/routedVerbs.js +30 -0
  18. package/dist/domain/wire.d.ts +140 -0
  19. package/dist/domain/wire.js +174 -0
  20. package/dist/dx/command.d.ts +13 -4
  21. package/dist/dx/command.js +26 -5
  22. package/dist/dx/report.d.ts +39 -7
  23. package/dist/dx/report.js +71 -20
  24. package/dist/dx/send.d.ts +22 -4
  25. package/dist/dx/send.js +29 -8
  26. package/dist/dx/skeleton.js +19 -0
  27. package/dist/dx/unbiddenVerbs.d.ts +25 -0
  28. package/dist/dx/unbiddenVerbs.js +39 -0
  29. package/dist/lib/initCommand.d.ts +3 -1
  30. package/dist/lib/initCommand.js +10 -0
  31. package/dist/lib/projects.d.ts +8 -1
  32. package/dist/lib/projects.js +12 -2
  33. package/dist/platform/allowlist.d.ts +36 -0
  34. package/dist/platform/allowlist.js +34 -0
  35. package/dist/platform/behind.d.ts +41 -0
  36. package/dist/platform/behind.js +82 -0
  37. package/dist/platform/command.d.ts +16 -0
  38. package/dist/platform/command.js +90 -0
  39. package/dist/platform/footer.d.ts +60 -0
  40. package/dist/platform/footer.js +101 -0
  41. package/dist/platform/notice.d.ts +29 -0
  42. package/dist/platform/notice.js +66 -0
  43. package/dist/platform/offSwitch.d.ts +19 -0
  44. package/dist/platform/offSwitch.js +21 -0
  45. package/dist/platform/outdated.d.ts +25 -0
  46. package/dist/platform/outdated.js +246 -0
  47. package/dist/platform/releaseCache.d.ts +69 -0
  48. package/dist/platform/releaseCache.js +140 -0
  49. package/dist/platform/releaseFetch.d.ts +51 -0
  50. package/dist/platform/releaseFetch.js +150 -0
  51. package/dist/platform/resolve.d.ts +30 -0
  52. package/dist/platform/resolve.js +128 -0
  53. package/dist/platform/templateUpgrade.d.ts +56 -0
  54. package/dist/platform/templateUpgrade.js +476 -0
  55. package/dist/platform/upgradeReport.d.ts +132 -0
  56. package/dist/platform/upgradeReport.js +258 -0
  57. package/dist/platform/versionSummary.d.ts +82 -0
  58. package/dist/platform/versionSummary.js +181 -0
  59. package/dist/platform/zonePath.d.ts +154 -0
  60. package/dist/platform/zonePath.js +286 -0
  61. package/dist/platform/zones.d.ts +64 -0
  62. package/dist/platform/zones.js +79 -0
  63. package/dist/preview/api.d.ts +19 -0
  64. package/dist/preview/api.js +29 -0
  65. package/dist/publish/api.d.ts +32 -5
  66. package/dist/publish/api.js +19 -5
  67. package/dist/publish/command.d.ts +10 -0
  68. package/dist/publish/command.js +30 -2
  69. package/dist/publish/copy.d.ts +14 -1
  70. package/dist/publish/copy.js +32 -3
  71. package/dist/scaffold/index.d.ts +50 -3
  72. package/dist/scaffold/index.js +61 -1
  73. package/dist/shared/config.d.ts +16 -0
  74. package/dist/shared/config.js +9 -1
  75. package/dist/shared/countLimit.d.ts +109 -0
  76. package/dist/shared/countLimit.js +169 -0
  77. package/package.json +1 -1
  78. package/dist/dx/qa.f7.fixtures.d.ts +0 -73
  79. package/dist/dx/qa.f7.fixtures.js +0 -58
  80. package/dist/publish/qa.f4.fixtures.d.ts +0 -25
  81. package/dist/publish/qa.f4.fixtures.js +0 -140
  82. package/dist/relay/qa.f12.fixtures.d.ts +0 -154
  83. package/dist/relay/qa.f12.fixtures.js +0 -358
@@ -0,0 +1,476 @@
1
+ /**
2
+ * The template half of an upgrade: fetch two tags, classify, replace the
3
+ * plumbing, and report.
4
+ *
5
+ * THE ORDERING IS THE MOST LIKELY SILENT DEFECT IN THIS FEATURE, so it is stated
6
+ * here rather than left to a caller:
7
+ *
8
+ * 1. The template half runs ONLY AFTER the dependency half has reported
9
+ * completion for every platform package. Replacing plumbing that expects a
10
+ * new SDK, over a project still on the old one, produces a project that
11
+ * compiles against NEITHER — worse than either half alone, and the plumbing
12
+ * is precisely the code that breaks when the SDK contract moves. (The
13
+ * fail-forward rule governs WITHIN the dependency half, package to package.
14
+ * The template half is not a fourth package and does not inherit it.)
15
+ * 2. THE BASELINE IS WRITTEN LAST — after the file operations AND after the
16
+ * report is rendered. If the template half succeeded and a dependency bump
17
+ * then failed, a baseline written early means the NEXT upgrade compares
18
+ * against the new baseline and reports every file the creator had customized
19
+ * as "unchanged by you". The destructive half loses its only trace, it is
20
+ * invisible in a diff, and the symptom appears one upgrade later.
21
+ * 3. Because 1 and 2 are unverifiable at run time — the procedure may not read
22
+ * exit codes — the precondition is ORDERING PLUS AN HONEST REPORT LINE, never
23
+ * a detected gate. The procedure reads its own report.
24
+ *
25
+ * THE FROM REF IS UNTRUSTED INPUT. `.vincentt/project.json` travels with a
26
+ * repository and anyone who can land a commit can set it. Four controls, all
27
+ * here: semver-shaped validation before it reaches git; the remote is a module
28
+ * constant; a fully-qualified tag ref rather than a bare branch argument; and a
29
+ * ref that resolves but whose tree lacks the manifest is NO BASELINE.
30
+ *
31
+ * NO BACKUP COPY, NO PRE-FLIGHT CHECK, NO PLATFORM-SIDE REVERT. All three were
32
+ * ruled out deliberately: the creator's version control is the recovery
33
+ * mechanism and it is better than ours, and a `.bak` is the platform inventing a
34
+ * recovery mechanism in a repository it does not own. Do not add a safety
35
+ * measure the design refused.
36
+ */
37
+ import { execFile } from "node:child_process";
38
+ import { promises as fs } from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+ import { promisify } from "node:util";
42
+ import { PLATFORM_PACKAGES } from "./allowlist.js";
43
+ import { isDeniedByName, validateZonePath } from "./zonePath.js";
44
+ import { parseZoneManifest } from "./zones.js";
45
+ import { renderBlockedReport, renderDegradedReport, renderDependencyIncompleteReport, renderUpgradeReport, } from "./upgradeReport.js";
46
+ import { loadProjectBinding, writeProjectBinding } from "../shared/config.js";
47
+ const execFileAsync = promisify(execFile);
48
+ /** THE REMOTE IS A MODULE CONSTANT. Never read from the binding — see the header. */
49
+ const TEMPLATE_REMOTE = "https://github.com/vincentt-xr/v2-template.git";
50
+ /** The manifest file, read-only: it arrives with the TO tree and is never in its own overwrite set. */
51
+ export const MANIFEST_FILE = ".vincentt-template.json";
52
+ /** Anchored at both ends. A branch named `1.4.3` does not satisfy `refs/tags/1.4.3`. */
53
+ const CONCRETE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+$/;
54
+ /** The first template version carrying a manifest, for the blocked report's copy. */
55
+ export const FIRST_MANIFEST_VERSION = "1.5.0";
56
+ const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
57
+ const realGit = async (args) => {
58
+ const { stdout } = await execFileAsync("git", args);
59
+ return stdout;
60
+ };
61
+ /**
62
+ * Validate a recorded FROM ref BEFORE it reaches git.
63
+ *
64
+ * Anything not exactly `MAJOR.MINOR.PATCH` is NO BASELINE — not an error, and
65
+ * not a bare attempt. `../../etc`, `refs/heads/1.4.3`, `1.4.3; rm -rf` and
66
+ * `latest` all resolve to "no baseline" without git being invoked at all.
67
+ */
68
+ export function validateFromRef(raw) {
69
+ if (raw === undefined)
70
+ return undefined;
71
+ return CONCRETE_VERSION.test(raw) ? raw : undefined;
72
+ }
73
+ /**
74
+ * Run the template half.
75
+ *
76
+ * Every early return renders a report and writes nothing. There is no path
77
+ * through this function that overwrites a file without the report naming it.
78
+ */
79
+ export async function upgradeTemplate(deps) {
80
+ // ORDERING, PART 1. Before any tag is fetched and before any file is read.
81
+ if (deps.dependencyOutcome.outstanding.length > 0) {
82
+ return {
83
+ report: renderDependencyIncompleteReport({
84
+ completed: deps.dependencyOutcome.completed,
85
+ outstanding: deps.dependencyOutcome.outstanding,
86
+ }),
87
+ templateAttempted: false,
88
+ };
89
+ }
90
+ const binding = await loadProjectBinding(deps.projectDir).catch(() => undefined);
91
+ const from = validateFromRef(binding?.template?.version);
92
+ const fetchTag = deps.fetchTag ?? ((ref) => cloneTag(ref, deps.runGit ?? realGit));
93
+ const toDir = await fetchTag(deps.to);
94
+ if (toDir === undefined) {
95
+ return {
96
+ report: renderBlockedReport({
97
+ dependencies: deps.dependencyOutcome.completed,
98
+ from,
99
+ firstManifestVersion: FIRST_MANIFEST_VERSION,
100
+ }),
101
+ templateAttempted: false,
102
+ };
103
+ }
104
+ // THE FLOOR RUNS BEFORE ANY FILE IS READ. One failure refuses the whole
105
+ // manifest and the template half reports not attempted.
106
+ const toZones = await readManifest(toDir);
107
+ if (!toZones.ok) {
108
+ return {
109
+ report: blockedFor(toZones.refusal, deps.dependencyOutcome.completed, from, deps.to),
110
+ templateAttempted: false,
111
+ };
112
+ }
113
+ // A ref that resolves but whose tree lacks the manifest is NO BASELINE — this
114
+ // closes the fallback-to-empty-manifest hole, where an attacker-chosen FROM
115
+ // with no manifest would otherwise degrade to "compare against nothing".
116
+ //
117
+ // The three values are one object rather than three variables, so "we have a
118
+ // baseline" is a single check the type system enforces. Held separately, a
119
+ // later edit can test one and read another, which is precisely how a
120
+ // no-baseline run would slip into the destructive path.
121
+ const baseline = await resolveBaseline(from, fetchTag);
122
+ const trackedStateOf = deps.trackedStateOf ?? gitTrackedState;
123
+ // Every manifest entry is permitted BY CONSTRUCTION: the whole manifest was
124
+ // floor-validated above, and one failure refuses all of it. So splitting the
125
+ // manifest by the floor cannot populate the not-permitted set, and an earlier
126
+ // version of this loop left that report section permanently empty — which is
127
+ // the opposite of what it exists for.
128
+ //
129
+ // The not-permitted set is the template's own plumbing that the manifest CANNOT
130
+ // name because the floor would refuse it: `scripts/*.mjs` execute on the
131
+ // creator's machine, and the template's lint config is a dot-leading root file.
132
+ // Both are therefore never upgraded, and the report NAMES them rather than
133
+ // letting them go silently stale.
134
+ const permitted = [...toZones.zones.platform];
135
+ const notPermitted = await unpermittedTemplatePaths(toDir, toZones.zones);
136
+ if (baseline === undefined) {
137
+ // THE DEGRADED PATH: reports, does not overwrite. Files already matching TO
138
+ // are verified or restored; anything differing is NAMED AND LEFT ALONE.
139
+ // Overwriting while unable to say what was overwritten is the destructive
140
+ // act with its only compensating control removed — and this path is
141
+ // reachable by deleting one gitignored file, which is invisible in a diff.
142
+ const matching = [];
143
+ const differing = [];
144
+ const restored = [];
145
+ for (const rel of permitted) {
146
+ const want = await readTemplateFile(toDir, rel);
147
+ if (want === undefined)
148
+ continue;
149
+ const have = await readProjectFile(deps.projectDir, rel);
150
+ if (have === undefined) {
151
+ await writeProjectFile(deps.projectDir, rel, want);
152
+ restored.push(rel);
153
+ }
154
+ else if (have.equals(want)) {
155
+ matching.push(rel);
156
+ }
157
+ else {
158
+ differing.push(rel);
159
+ }
160
+ }
161
+ const verified = [...matching, ...restored];
162
+ const report = renderDegradedReport({
163
+ dependencies: deps.dependencyOutcome.completed,
164
+ to: deps.to,
165
+ matching: verified.filter((p) => !TEST_FILE.test(p)),
166
+ matchingTestCount: verified.filter((p) => TEST_FILE.test(p)).length,
167
+ differing,
168
+ deletions: [],
169
+ baselineRecorded: true,
170
+ });
171
+ // BASELINE LAST — after the file operations and after the report renders.
172
+ await recordBaseline(deps.projectDir, deps.to);
173
+ return { report, templateAttempted: true, baselineWritten: deps.to };
174
+ }
175
+ // THE DESTRUCTIVE PATH. Classify against FROM's original, then replace.
176
+ const changed = [];
177
+ const unchanged = [];
178
+ let unchangedTestCount = 0;
179
+ for (const rel of permitted) {
180
+ const want = await readTemplateFile(toDir, rel);
181
+ if (want === undefined)
182
+ continue;
183
+ const original = await readTemplateFile(baseline.dir, rel);
184
+ const have = await readProjectFile(deps.projectDir, rel);
185
+ // Tracked-ness is read BEFORE the write, because after it the file is
186
+ // modified and `diff-index` would answer about the new contents.
187
+ const isChanged = have !== undefined && original !== undefined && !have.equals(original);
188
+ const tracked = isChanged ? await trackedStateOf(deps.projectDir, rel) : "tracked";
189
+ await writeProjectFile(deps.projectDir, rel, want);
190
+ if (isChanged) {
191
+ changed.push({ path: rel, tracked });
192
+ }
193
+ else if (TEST_FILE.test(rel)) {
194
+ unchangedTestCount++;
195
+ }
196
+ else {
197
+ unchanged.push(rel);
198
+ }
199
+ }
200
+ // Deletions are REPORTED, NEVER REMOVED. Removal is a step past overwriting,
201
+ // and the ruling that permits overwriting does not reach it.
202
+ const toSet = new Set(toZones.zones.platform);
203
+ const deletions = baseline.zones.platform.filter((p) => !toSet.has(p));
204
+ const report = renderUpgradeReport({
205
+ dependencies: deps.dependencyOutcome.completed,
206
+ comparedAgainst: baseline.ref,
207
+ to: deps.to,
208
+ changed,
209
+ unchanged,
210
+ unchangedTestCount,
211
+ deletions,
212
+ notPermitted,
213
+ });
214
+ // BASELINE LAST. See the header — this ordering is the whole defence against
215
+ // the next upgrade laundering the creator's work.
216
+ await recordBaseline(deps.projectDir, deps.to);
217
+ return { report, templateAttempted: true, baselineWritten: deps.to };
218
+ }
219
+ /**
220
+ * Resolve the baseline, or `undefined` for NO BASELINE.
221
+ *
222
+ * Every failure reduces rather than widens: an unvalidatable ref, an
223
+ * unreachable tag, a tree with no manifest and a manifest the floor refuses all
224
+ * return `undefined`, which routes to the reporting path where nothing is
225
+ * replaced. No fingerprinting and no guessed FROM — inference on a destructive
226
+ * path means confidently reporting "you had not changed this" about a file just
227
+ * destroyed.
228
+ */
229
+ async function resolveBaseline(from, fetchTag) {
230
+ if (from === undefined)
231
+ return undefined;
232
+ const dir = await fetchTag(from);
233
+ if (dir === undefined)
234
+ return undefined;
235
+ const parsed = await readManifest(dir);
236
+ if (!parsed.ok)
237
+ return undefined;
238
+ return { ref: from, dir, zones: parsed.zones };
239
+ }
240
+ /**
241
+ * Every refusal produces the same OUTCOME — nothing read, nothing written — but
242
+ * not the same EXPLANATION.
243
+ *
244
+ * `unparseable` here means the manifest file was absent or unreadable, which is
245
+ * the day-one case: the tag predates the manifest. Every other refusal means a
246
+ * manifest was present and the floor rejected it, which is a fault in the
247
+ * template version rather than in the project's age. The report must not tell
248
+ * someone their old template lacks a file when the real cause is a refused one.
249
+ */
250
+ function blockedFor(refusal, dependencies, from, to) {
251
+ return renderBlockedReport({
252
+ dependencies,
253
+ from,
254
+ to,
255
+ firstManifestVersion: FIRST_MANIFEST_VERSION,
256
+ reason: refusal.kind === "unparseable" ? "missing" : "refused",
257
+ });
258
+ }
259
+ /**
260
+ * `git clone --depth 1` at a fully-qualified TAG ref, read-only, no credential,
261
+ * remote from the module constant. Tags are immutable, so FROM is reproducible
262
+ * forever.
263
+ */
264
+ async function cloneTag(ref, runGit) {
265
+ if (validateFromRef(ref) === undefined)
266
+ return undefined;
267
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "vincentt-tmpl-up-"));
268
+ try {
269
+ await runGit([
270
+ "clone",
271
+ "--depth",
272
+ "1",
273
+ // `refs/tags/<v>`, never a bare branch argument: a branch named `1.4.3`
274
+ // does not satisfy this and resolves to nothing rather than to a tree
275
+ // someone else controls.
276
+ "--branch",
277
+ `refs/tags/${ref}`,
278
+ TEMPLATE_REMOTE,
279
+ tmp,
280
+ ]);
281
+ return tmp;
282
+ }
283
+ catch {
284
+ return undefined;
285
+ }
286
+ }
287
+ /**
288
+ * The template's own plumbing that the manifest CANNOT name, because the floor
289
+ * would refuse the path.
290
+ *
291
+ * This is NOT "entries the floor rejected" — every manifest entry is permitted by
292
+ * construction, since one refusal rejects the whole manifest. It is the opposite
293
+ * direction: files the template genuinely ships, in neither zone array, whose
294
+ * paths the floor does not admit. `scripts/*.mjs` execute on the creator's
295
+ * machine and a dot-leading root file is refused by the segment rule, so neither
296
+ * can ever be in an overwrite set.
297
+ *
298
+ * They are NAMED IN THE REPORT rather than left silently stale, which is the
299
+ * whole point of the not-permitted section. Derived from the tree so a template
300
+ * that adds another such file is reported without a code change here.
301
+ */
302
+ async function unpermittedTemplatePaths(toDir, zones) {
303
+ const listed = new Set([...zones.platform, ...zones.creator]);
304
+ const out = [];
305
+ const walk = async (rel) => {
306
+ const entries = await fs.readdir(path.join(toDir, rel || "."), {
307
+ withFileTypes: true,
308
+ });
309
+ for (const e of entries) {
310
+ const child = rel ? `${rel}/${e.name}` : e.name;
311
+ // `.git` is the clone's own plumbing, not the template's content.
312
+ if (e.name === ".git")
313
+ continue;
314
+ if (e.isDirectory()) {
315
+ if (e.name === "node_modules")
316
+ continue;
317
+ await walk(child);
318
+ continue;
319
+ }
320
+ if (listed.has(child))
321
+ continue;
322
+ if (child === MANIFEST_FILE)
323
+ continue;
324
+ // Denied-by-name files are refused, but they are the CREATOR's or the
325
+ // grounding contract — never "plumbing we could not upgrade".
326
+ if (isDeniedByName(child))
327
+ continue;
328
+ if (!validateZonePath(child).ok)
329
+ out.push(child);
330
+ }
331
+ };
332
+ try {
333
+ await walk("");
334
+ }
335
+ catch {
336
+ // The tree is a temp clone we just read a manifest out of; if it became
337
+ // unreadable the report simply omits this section rather than failing an
338
+ // upgrade that already succeeded.
339
+ return [];
340
+ }
341
+ return out.sort();
342
+ }
343
+ async function readManifest(dir) {
344
+ try {
345
+ return parseZoneManifest(await fs.readFile(path.join(dir, MANIFEST_FILE), "utf8"));
346
+ }
347
+ catch {
348
+ return { ok: false, refusal: { kind: "unparseable" } };
349
+ }
350
+ }
351
+ /**
352
+ * Read one file from a fetched template tree.
353
+ *
354
+ * The path has already passed the floor, and it is re-checked here anyway: this
355
+ * is the last point before a read, and a caller assembling a path from a
356
+ * different source must not be able to reach the filesystem through this
357
+ * function.
358
+ */
359
+ async function readTemplateFile(dir, rel) {
360
+ if (!validateZonePath(rel).ok)
361
+ return undefined;
362
+ try {
363
+ return await fs.readFile(path.join(dir, rel));
364
+ }
365
+ catch {
366
+ return undefined;
367
+ }
368
+ }
369
+ async function readProjectFile(projectDir, rel) {
370
+ if (!validateZonePath(rel).ok)
371
+ return undefined;
372
+ const target = await safeTarget(projectDir, rel);
373
+ if (target === undefined)
374
+ return undefined;
375
+ try {
376
+ return await fs.readFile(target);
377
+ }
378
+ catch {
379
+ return undefined;
380
+ }
381
+ }
382
+ async function writeProjectFile(projectDir, rel, contents) {
383
+ if (!validateZonePath(rel).ok)
384
+ return;
385
+ if (isDeniedByName(rel))
386
+ return;
387
+ const target = await safeTarget(projectDir, rel);
388
+ if (target === undefined)
389
+ return;
390
+ await fs.mkdir(path.dirname(target), { recursive: true });
391
+ await fs.writeFile(target, contents);
392
+ }
393
+ /**
394
+ * Resolve a write destination inside the project root, refusing a symlink.
395
+ *
396
+ * Belt-and-braces against a symlink planted in the creator's tree, which the
397
+ * source-side floor structurally cannot see: the manifest names an innocuous
398
+ * relative path and the creator's own tree redirects it. `realpath` on the
399
+ * PARENT (the file itself may legitimately not exist yet, on the restore path),
400
+ * plus an lstat refusing a symlink at the leaf.
401
+ */
402
+ async function safeTarget(projectDir, rel) {
403
+ const root = await fs.realpath(projectDir).catch(() => undefined);
404
+ if (root === undefined)
405
+ return undefined;
406
+ const target = path.resolve(root, rel);
407
+ const parent = path.dirname(target);
408
+ const realParent = await fs.realpath(parent).catch(() => parent);
409
+ if (realParent !== root && !realParent.startsWith(root + path.sep))
410
+ return undefined;
411
+ const stat = await fs.lstat(target).catch(() => undefined);
412
+ if (stat?.isSymbolicLink())
413
+ return undefined;
414
+ return path.join(realParent, path.basename(target));
415
+ }
416
+ /**
417
+ * How version control saw this file. A statement, NEVER a gate — nothing is
418
+ * refused, delayed, or required on the strength of it, so this does not
419
+ * reintroduce the refused pre-flight check as a courtesy.
420
+ */
421
+ async function gitTrackedState(projectDir, rel) {
422
+ try {
423
+ await execFileAsync("git", ["-C", projectDir, "rev-parse", "--git-dir"]);
424
+ }
425
+ catch {
426
+ return "no_repository";
427
+ }
428
+ try {
429
+ await execFileAsync("git", ["-C", projectDir, "ls-files", "--error-unmatch", rel]);
430
+ return "tracked";
431
+ }
432
+ catch {
433
+ // Untracked: ignored is the narrower cause and a creator can act on the
434
+ // difference, so it is distinguished rather than folded in.
435
+ try {
436
+ const { stdout } = await execFileAsync("git", [
437
+ "-C",
438
+ projectDir,
439
+ "check-ignore",
440
+ rel,
441
+ ]);
442
+ return stdout.trim() === "" ? "untracked" : "ignored";
443
+ }
444
+ catch {
445
+ return "untracked";
446
+ }
447
+ }
448
+ }
449
+ /**
450
+ * THE ONE PLACE the platform writes `.vincentt/project.json` after create.
451
+ *
452
+ * Surfaced rather than absorbed: it is the CLI's own binding record and not the
453
+ * creator's source, it happens only on an explicitly-invoked act, it records a
454
+ * fact the creator's act just made true, and without it the no-baseline case
455
+ * never heals.
456
+ */
457
+ async function recordBaseline(projectDir, version) {
458
+ const binding = await loadProjectBinding(projectDir).catch(() => undefined);
459
+ if (binding === undefined)
460
+ return;
461
+ await writeProjectBinding(projectDir, { ...binding, template: { version } }).catch(() => { });
462
+ }
463
+ /** Which platform packages this project has installed, for the ordering precondition. */
464
+ export async function installedPlatformPackages(projectDir) {
465
+ const found = [];
466
+ for (const pkg of PLATFORM_PACKAGES) {
467
+ try {
468
+ await fs.access(path.join(projectDir, "node_modules", ...pkg.name.split("/"), "package.json"));
469
+ found.push(pkg.name);
470
+ }
471
+ catch {
472
+ // Not installed contributes nothing.
473
+ }
474
+ }
475
+ return found;
476
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The template upgrade report — the ONLY trace of an operation that replaced
3
+ * files in a repository the platform does not own, with no pre-flight check, no
4
+ * backup, and no platform-side revert.
5
+ *
6
+ * READ THIS MODULE AS A SECURITY CONTROL, because that is what it is. Every
7
+ * ordering decision serves one goal: a reader who takes only the first block
8
+ * takes the bad news.
9
+ *
10
+ * Four rules govern every string here, and each exists because a draft broke it:
11
+ *
12
+ * 1. The destructive section is FIRST, above every reassurance and every
13
+ * success framing, and it is NEVER COMPRESSED at any length. Other sections
14
+ * may collapse to middot lists and counts to keep this one above the fold.
15
+ * 2. The unrecoverable caveat PRECEDES the reassurance, with its files named. A
16
+ * reader who takes one sentence of a pair takes the first.
17
+ * 3. `unchanged` is NEVER a bare claim. It always carries the ref it was
18
+ * compared against, in the heading, because the heading is what a skimming
19
+ * reader reads — and the procedure cannot verify that ref, so it must not
20
+ * print a sentence implying it did.
21
+ * 4. NO state-restoring transformation is named anywhere. "Names no command"
22
+ * was proven insufficient: a sentence naming no command can fully specify a
23
+ * hard reset, which is instruction with the imperative removed. On this tree
24
+ * a hard reset or force-clean destroys the project binding, the env file,
25
+ * and every asset the creator dropped in — all gitignored, and a dirty tree
26
+ * is the modal case here.
27
+ *
28
+ * There is no success glyph in this file. A `✓` above a list of destroyed work
29
+ * is the report arguing with itself. And no command appears anywhere — not a
30
+ * build command, not a test command, not a version-control command.
31
+ */
32
+ /** How version control saw a replaced file. The two causes differ and a creator can act on the difference. */
33
+ export type TrackedState = "tracked" | "untracked" | "ignored" | "no_repository";
34
+ export interface ReplacedFile {
35
+ path: string;
36
+ tracked: TrackedState;
37
+ }
38
+ export interface DependencyChange {
39
+ name: string;
40
+ from: string;
41
+ to: string;
42
+ }
43
+ export interface UpgradeReport {
44
+ /** Present when the dependency half reported completion. Stated FIRST when the template half did not run. */
45
+ dependencies: DependencyChange[];
46
+ /** The FROM ref the classification rests on. Printed as a premise, never as a verified fact. */
47
+ comparedAgainst?: string;
48
+ to: string;
49
+ /** Replaced AND differing from FROM — the destructive list. */
50
+ changed: ReplacedFile[];
51
+ /** Replaced and byte-identical to FROM. */
52
+ unchanged: string[];
53
+ /** How many of `unchanged` are shipped test files, folded into a count. */
54
+ unchangedTestCount: number;
55
+ /** In FROM's platform set, absent from TO's. Reported, never removed. */
56
+ deletions: string[];
57
+ /** Named by the manifest but refused by the floor's allowlist. */
58
+ notPermitted: string[];
59
+ }
60
+ /**
61
+ * The full report for a completed upgrade.
62
+ *
63
+ * The header states the transition and nothing else — no duration, no file
64
+ * count, no "upgraded successfully".
65
+ */
66
+ export declare function renderUpgradeReport(r: UpgradeReport): string[];
67
+ /**
68
+ * The template half BLOCKED — a tag at either end carries no manifest, or its
69
+ * schema is unrecognized. THE MODAL OUTCOME for the first weeks, because every
70
+ * existing template tag predates the manifest.
71
+ *
72
+ * The dependency half's success is stated FIRST, because it did work. The reason
73
+ * is two sentences of plain language, it states what was NOT READ as well as not
74
+ * written, and it says what changes the situation without promising a date or
75
+ * naming a command. The word `zone` appears nowhere: it is internal vocabulary
76
+ * and a creator has never heard it.
77
+ */
78
+ export declare function renderBlockedReport(r: {
79
+ dependencies: DependencyChange[];
80
+ from?: string;
81
+ firstManifestVersion: string;
82
+ /**
83
+ * `missing` — the tag predates the manifest (the day-one case).
84
+ * `refused` — a manifest IS present and did not pass the floor.
85
+ *
86
+ * The two must not share copy. Telling a creator their old template "does not
87
+ * carry the file" when a manifest was actually present and REFUSED is a
88
+ * misleading explanation on the one path where the explanation is the whole
89
+ * compensating control — and it would send someone upgrading the template to
90
+ * fix the wrong thing.
91
+ */
92
+ reason?: "missing" | "refused";
93
+ /** The version being upgraded TO, named in the refused-manifest copy. */
94
+ to?: string;
95
+ }): string[];
96
+ /**
97
+ * The DEGRADED report — no recorded baseline, so nothing was replaced.
98
+ *
99
+ * Two lists, and the distinction between them is the entire deliverable: "already
100
+ * matching, byte for byte" is a VERIFIED fact; "differs — left alone" is a fact
101
+ * with an UNKNOWN CAUSE. One combined list of "template files" would collapse
102
+ * them and make the report worthless.
103
+ *
104
+ * No `!` and no `✗`: nothing failed. This is the designed behavior of a project
105
+ * in a known state, and dressing it as a warning teaches creators to fear a safe
106
+ * outcome. The word "revert" does not appear and neither does any restoring
107
+ * phrase — nothing was destroyed here, so there is nothing to say about
108
+ * recovery, and saying it anyway would put the phrase into a transcript an agent
109
+ * may carry into the destructive path's context.
110
+ */
111
+ export declare function renderDegradedReport(r: {
112
+ dependencies: DependencyChange[];
113
+ to: string;
114
+ matching: string[];
115
+ matchingTestCount: number;
116
+ differing: string[];
117
+ deletions: string[];
118
+ baselineRecorded: boolean;
119
+ }): string[];
120
+ /**
121
+ * The report for the case the ORDERING creates: the dependency half did not
122
+ * report completion, so the template half was not attempted.
123
+ *
124
+ * The precondition cannot be verified at run time — the procedure may not read
125
+ * exit codes — so it is expressed as ordering plus an honest line, never as a
126
+ * detected gate. The capability is DEFERRED, not withdrawn, and saying so is
127
+ * what stops a creator reading it as broken.
128
+ */
129
+ export declare function renderDependencyIncompleteReport(r: {
130
+ completed: DependencyChange[];
131
+ outstanding: string[];
132
+ }): string[];