@vincentt-xr/cli 0.15.1 → 0.16.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 (84) hide show
  1. package/dist/cli/args.js +1 -0
  2. package/dist/cli/index.js +177 -4
  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/clusters.js +10 -1
  21. package/dist/dx/command.d.ts +13 -4
  22. package/dist/dx/command.js +26 -5
  23. package/dist/dx/report.d.ts +39 -7
  24. package/dist/dx/report.js +71 -20
  25. package/dist/dx/send.d.ts +22 -4
  26. package/dist/dx/send.js +29 -8
  27. package/dist/dx/skeleton.js +19 -0
  28. package/dist/dx/unbiddenVerbs.d.ts +25 -0
  29. package/dist/dx/unbiddenVerbs.js +39 -0
  30. package/dist/lib/exitCodes.d.ts +24 -0
  31. package/dist/lib/exitCodes.js +28 -0
  32. package/dist/lib/initCommand.d.ts +3 -1
  33. package/dist/lib/initCommand.js +10 -0
  34. package/dist/lib/initFrom.js +12 -2
  35. package/dist/lib/lifecycle.d.ts +5 -1
  36. package/dist/lib/projects.d.ts +8 -1
  37. package/dist/lib/projects.js +21 -4
  38. package/dist/platform/allowlist.d.ts +36 -0
  39. package/dist/platform/allowlist.js +34 -0
  40. package/dist/platform/behind.d.ts +41 -0
  41. package/dist/platform/behind.js +82 -0
  42. package/dist/platform/command.d.ts +16 -0
  43. package/dist/platform/command.js +90 -0
  44. package/dist/platform/footer.d.ts +60 -0
  45. package/dist/platform/footer.js +101 -0
  46. package/dist/platform/notice.d.ts +29 -0
  47. package/dist/platform/notice.js +66 -0
  48. package/dist/platform/offSwitch.d.ts +19 -0
  49. package/dist/platform/offSwitch.js +21 -0
  50. package/dist/platform/outdated.d.ts +25 -0
  51. package/dist/platform/outdated.js +246 -0
  52. package/dist/platform/releaseCache.d.ts +69 -0
  53. package/dist/platform/releaseCache.js +140 -0
  54. package/dist/platform/releaseFetch.d.ts +51 -0
  55. package/dist/platform/releaseFetch.js +150 -0
  56. package/dist/platform/resolve.d.ts +30 -0
  57. package/dist/platform/resolve.js +128 -0
  58. package/dist/platform/templateUpgrade.d.ts +56 -0
  59. package/dist/platform/templateUpgrade.js +476 -0
  60. package/dist/platform/upgradeReport.d.ts +132 -0
  61. package/dist/platform/upgradeReport.js +258 -0
  62. package/dist/platform/versionSummary.d.ts +82 -0
  63. package/dist/platform/versionSummary.js +181 -0
  64. package/dist/platform/zonePath.d.ts +154 -0
  65. package/dist/platform/zonePath.js +286 -0
  66. package/dist/platform/zones.d.ts +64 -0
  67. package/dist/platform/zones.js +79 -0
  68. package/dist/publish/api.d.ts +15 -4
  69. package/dist/publish/api.js +19 -5
  70. package/dist/publish/command.d.ts +10 -0
  71. package/dist/publish/command.js +7 -1
  72. package/dist/publish/copy.d.ts +14 -1
  73. package/dist/publish/copy.js +32 -3
  74. package/dist/scaffold/index.d.ts +50 -3
  75. package/dist/scaffold/index.js +61 -1
  76. package/dist/shared/config.d.ts +16 -0
  77. package/dist/shared/config.js +9 -1
  78. package/package.json +1 -1
  79. package/dist/dx/qa.f7.fixtures.d.ts +0 -73
  80. package/dist/dx/qa.f7.fixtures.js +0 -58
  81. package/dist/publish/qa.f4.fixtures.d.ts +0 -25
  82. package/dist/publish/qa.f4.fixtures.js +0 -140
  83. package/dist/relay/qa.f12.fixtures.d.ts +0 -154
  84. package/dist/relay/qa.f12.fixtures.js +0 -358
@@ -0,0 +1,258 @@
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
+ const NOT_VERIFIED = [
33
+ " NOT VERIFIED",
34
+ "",
35
+ " The build has not been run. Whether this project still builds, and whether",
36
+ " the app still behaves the way you expect, is not something this procedure",
37
+ " established. That is the next thing to check.",
38
+ ];
39
+ /**
40
+ * The full report for a completed upgrade.
41
+ *
42
+ * The header states the transition and nothing else — no duration, no file
43
+ * count, no "upgraded successfully".
44
+ */
45
+ export function renderUpgradeReport(r) {
46
+ const lines = [
47
+ ` Template upgraded: ${r.comparedAgainst ?? "unknown"} → ${r.to}`,
48
+ ` Compared against template ${r.comparedAgainst ?? "unknown"}, as recorded in this project.`,
49
+ ];
50
+ if (r.changed.length > 0) {
51
+ lines.push("", " REPLACED — AND YOU HAD CHANGED THESE", "");
52
+ // NEVER COMPRESSED, at any length: one file per line, always.
53
+ const width = Math.max(...r.changed.map((f) => f.path.length));
54
+ for (const f of r.changed) {
55
+ lines.push(` ${f.path.padEnd(width)} differs from ${r.comparedAgainst ?? "the recorded baseline"}`);
56
+ }
57
+ lines.push(...unrecoverableBlock(r.changed));
58
+ }
59
+ if (r.unchanged.length > 0 || r.unchangedTestCount > 0) {
60
+ lines.push("", ` REPLACED — UNCHANGED BY YOU, compared against ${r.comparedAgainst ?? "the recorded baseline"}`, "", ...middotList(r.unchanged, r.unchangedTestCount));
61
+ }
62
+ lines.push("", " LEFT ALONE — YOURS", "",
63
+ // Three claims, not one. Not-written is the weak claim; NOT-READ is the
64
+ // strong one, and it is true — the procedure never has the creator's scene
65
+ // content in its context.
66
+ " src/Scene.tsx, and every file you added. None of them was read, compared,", " or written by this upgrade.");
67
+ if (r.deletions.length > 0) {
68
+ lines.push("", ` NOT PART OF ${r.to} — STILL IN YOUR PROJECT`, "");
69
+ const width = Math.max(...r.deletions.map((p) => p.length));
70
+ for (const p of r.deletions) {
71
+ lines.push(` ${p.padEnd(width)} ${r.to} does not carry this file. It has not been`, ` ${" ".repeat(width)} removed. Nothing in ${r.to} imports it.`);
72
+ }
73
+ }
74
+ if (r.notPermitted.length > 0) {
75
+ lines.push("", " NOT UPGRADED — NOT PERMITTED TO BE", "", ` ${r.notPermitted.join(" · ")}`, " These run on your machine, so they are never replaced by this procedure.", ` ${r.to}'s versions differ from the ones in this project.`);
76
+ }
77
+ lines.push("", ...NOT_VERIFIED);
78
+ return lines;
79
+ }
80
+ /**
81
+ * The unrecoverable caveat, ABOVE the reassurance, with the files named.
82
+ *
83
+ * "Their previous contents are gone." stands alone, unqualified. The reassurance
84
+ * that follows names WHERE BYTES ARE and no transformation that would retrieve
85
+ * them: a fact about a location, with no target state and nothing an agent can
86
+ * operationalise.
87
+ */
88
+ function unrecoverableBlock(changed) {
89
+ const lost = changed.filter((f) => f.tracked === "untracked" || f.tracked === "ignored" || f.tracked === "no_repository");
90
+ if (lost.length === 0) {
91
+ return [
92
+ "",
93
+ " For a replaced file that was tracked and committed, the previous contents",
94
+ " are in this repository's version control history.",
95
+ ];
96
+ }
97
+ // For a project with NO REPOSITORY AT ALL, every replaced file is here and the
98
+ // sub-heading says why — the honest report and the loudest signal available
99
+ // without being a gate.
100
+ const noRepo = lost.every((f) => f.tracked === "no_repository");
101
+ const heading = noRepo
102
+ ? " None of these was tracked, because this project has no version control:"
103
+ : ` ${countWord(lost.length)} of these ${lost.length === 1 ? "was" : "were"} not tracked or ${lost.length === 1 ? "was" : "were"} ignored by version control, so no`;
104
+ const lines = noRepo
105
+ ? ["", heading, ""]
106
+ : ["", heading, " previous copy of them exists anywhere:", ""];
107
+ const width = Math.max(...lost.map((f) => f.path.length));
108
+ for (const f of lost) {
109
+ lines.push(` ${f.path.padEnd(width)} ${trackedLabel(f.tracked)}`);
110
+ }
111
+ lines.push("", " Their previous contents are gone.", "", " For a replaced file that was tracked and committed, the previous contents", " are in this repository's version control history.");
112
+ return lines;
113
+ }
114
+ function trackedLabel(state) {
115
+ switch (state) {
116
+ case "ignored":
117
+ return "ignored by version control";
118
+ case "untracked":
119
+ return "not tracked";
120
+ case "no_repository":
121
+ return "no version control in this project";
122
+ case "tracked":
123
+ return "tracked and committed";
124
+ }
125
+ }
126
+ /**
127
+ * The template half BLOCKED — a tag at either end carries no manifest, or its
128
+ * schema is unrecognized. THE MODAL OUTCOME for the first weeks, because every
129
+ * existing template tag predates the manifest.
130
+ *
131
+ * The dependency half's success is stated FIRST, because it did work. The reason
132
+ * is two sentences of plain language, it states what was NOT READ as well as not
133
+ * written, and it says what changes the situation without promising a date or
134
+ * naming a command. The word `zone` appears nowhere: it is internal vocabulary
135
+ * and a creator has never heard it.
136
+ */
137
+ export function renderBlockedReport(r) {
138
+ const lines = [...dependencyBlock(r.dependencies)];
139
+ lines.push("", " TEMPLATE — NOT ATTEMPTED", "");
140
+ if (r.reason === "refused") {
141
+ lines.push(` Template ${r.to ?? r.firstManifestVersion} states which parts of the template are platform`, " plumbing and which are yours, and that statement did not pass the checks this", " procedure applies to it before reading anything. Nothing can say which files", " would be safe to replace, so no template file was read or written.", "", " This is not something this project can resolve. It is a fault in the template", " version itself, and a later template version is what changes it.", "", " NOT VERIFIED", "", " The build has not been run. Whether this project still builds after the", " dependency changes is not something this procedure established.");
142
+ return lines;
143
+ }
144
+ if (r.from !== undefined) {
145
+ lines.push(` This project came from template ${r.from}, and ${r.from} does not carry the file`, " that states which parts of the template are platform plumbing and which are", " yours. Without it, nothing can say which files would be safe to replace, so", " no template file was read or written.", "", ` Template versions from ${r.firstManifestVersion} onward carry it. A project that reaches one of`, " those can be upgraded this way; this project cannot yet.");
146
+ }
147
+ else {
148
+ lines.push(" The template version this project would be compared against does not carry", " the file that states which parts of the template are platform plumbing and", " which are yours. Without it, nothing can say which files would be safe to", " replace, so no template file was read or written.");
149
+ }
150
+ lines.push("", " NOT VERIFIED", "", " The build has not been run. Whether this project still builds after the", " dependency changes is not something this procedure established.");
151
+ return lines;
152
+ }
153
+ /**
154
+ * The DEGRADED report — no recorded baseline, so nothing was replaced.
155
+ *
156
+ * Two lists, and the distinction between them is the entire deliverable: "already
157
+ * matching, byte for byte" is a VERIFIED fact; "differs — left alone" is a fact
158
+ * with an UNKNOWN CAUSE. One combined list of "template files" would collapse
159
+ * them and make the report worthless.
160
+ *
161
+ * No `!` and no `✗`: nothing failed. This is the designed behavior of a project
162
+ * in a known state, and dressing it as a warning teaches creators to fear a safe
163
+ * outcome. The word "revert" does not appear and neither does any restoring
164
+ * phrase — nothing was destroyed here, so there is nothing to say about
165
+ * recovery, and saying it anyway would put the phrase into a transcript an agent
166
+ * may carry into the destructive path's context.
167
+ */
168
+ export function renderDegradedReport(r) {
169
+ const lines = [...dependencyBlock(r.dependencies)];
170
+ lines.push("", " TEMPLATE — NOT UPGRADED", "", " This project has no recorded template version, so there is nothing to", " compare your files against. No file was replaced.");
171
+ if (r.matching.length > 0 || r.matchingTestCount > 0) {
172
+ lines.push("", ` ALREADY MATCHING ${r.to}, byte for byte`, "", ...middotList(r.matching, r.matchingTestCount));
173
+ }
174
+ if (r.differing.length > 0) {
175
+ lines.push("", ` DIFFERS FROM ${r.to} — LEFT ALONE`, "", ...middotList(r.differing, 0), "", " Whether these differ because you changed them, or because this project came", " from an older template, cannot be established without a recorded template", " version. This procedure will not guess, and it did not replace them.");
176
+ }
177
+ if (r.deletions.length > 0) {
178
+ lines.push("", ` NOT PART OF ${r.to} — STILL IN YOUR PROJECT`, "");
179
+ for (const p of r.deletions) {
180
+ lines.push(` ${p} ${r.to} does not carry this file.`);
181
+ }
182
+ }
183
+ if (r.baselineRecorded) {
184
+ // The baseline write is ANNOUNCED with its consequence: that is what makes
185
+ // this a one-time degradation rather than a permanent one, and a creator who
186
+ // is not told will read the report as a dead end.
187
+ //
188
+ // The "for the files that already match" clause is dropped when NOTHING
189
+ // matched, because it would then be a claim about an empty set sitting next
190
+ // to an absent list. The consequence sentence is the load-bearing half and
191
+ // is true either way.
192
+ const matched = r.matching.length > 0 || r.matchingTestCount > 0;
193
+ lines.push("", matched
194
+ ? ` This project's template version is now recorded as ${r.to} for the files that`
195
+ : ` This project's template version is now recorded as ${r.to}.`, ...(matched
196
+ ? [" already match. A future upgrade from here can tell your changes apart from the"]
197
+ : [" A future upgrade from here can tell your changes apart from the"]), " platform's, and will be able to report what it replaced.");
198
+ }
199
+ lines.push("", " NOT VERIFIED", "", " The build has not been run.");
200
+ return lines;
201
+ }
202
+ /**
203
+ * The report for the case the ORDERING creates: the dependency half did not
204
+ * report completion, so the template half was not attempted.
205
+ *
206
+ * The precondition cannot be verified at run time — the procedure may not read
207
+ * exit codes — so it is expressed as ordering plus an honest line, never as a
208
+ * detected gate. The capability is DEFERRED, not withdrawn, and saying so is
209
+ * what stops a creator reading it as broken.
210
+ */
211
+ export function renderDependencyIncompleteReport(r) {
212
+ const lines = [];
213
+ if (r.completed.length > 0) {
214
+ lines.push(...dependencyBlock(r.completed));
215
+ lines.push("");
216
+ }
217
+ lines.push(" TEMPLATE — NOT ATTEMPTED", "", " The dependency changes are not all in place yet, and the template's platform", " plumbing expects the versions this upgrade names. Replacing that plumbing over", " a project still on the old versions would leave a project matching neither, so", " no template file was read or written.", "", " Still outstanding:", "", ...r.outstanding.map((name) => ` ${name}`), "", " A later run of this procedure, once those are in place, performs the template", " half.", "", " NOT VERIFIED", "", " The build has not been run.");
218
+ return lines;
219
+ }
220
+ function dependencyBlock(deps) {
221
+ if (deps.length === 0)
222
+ return [];
223
+ const width = Math.max(...deps.map((d) => d.name.length));
224
+ return [
225
+ " Dependencies upgraded.",
226
+ "",
227
+ ...deps.map((d) => ` ${d.name.padEnd(width)} ${d.from} → ${d.to}`),
228
+ ];
229
+ }
230
+ /**
231
+ * A middot list wrapped to a readable width, with the shipped test files folded
232
+ * into a count so sixteen filenames do not push the destructive section off a
233
+ * short terminal.
234
+ */
235
+ function middotList(paths, testCount) {
236
+ const lines = [];
237
+ let current = [];
238
+ for (const p of paths) {
239
+ const candidate = [...current, p].join(" · ");
240
+ if (current.length > 0 && candidate.length > 68) {
241
+ lines.push(` ${current.join(" · ")}`);
242
+ current = [p];
243
+ }
244
+ else {
245
+ current.push(p);
246
+ }
247
+ }
248
+ if (current.length > 0)
249
+ lines.push(` ${current.join(" · ")}`);
250
+ if (testCount > 0) {
251
+ lines.push(` and ${testCount} shipped test ${testCount === 1 ? "file" : "files"}`);
252
+ }
253
+ return lines;
254
+ }
255
+ const COUNT_WORDS = ["", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"];
256
+ function countWord(n) {
257
+ return COUNT_WORDS[n] ?? String(n);
258
+ }
@@ -0,0 +1,82 @@
1
+ import type { BuiltWith } from "./resolve.js";
2
+ /**
3
+ * `versions: sdk 2.0.0-alpha.3, harness 1.4.0, template 1.4.3`, or `undefined`.
4
+ *
5
+ * ALLOWLIST ORDER, not object order — the same order the wire carries, so the
6
+ * line a creator reads and the payload they consent to agree on sequence.
7
+ *
8
+ * Nothing resolved → `undefined`, and the caller omits the field, this line, AND
9
+ * the disclosure sentence together. No `versions:` header, no `(none)`, no
10
+ * `unknown`: a line implying a version set was sent when none was is a false
11
+ * statement in the one place on that screen that must be exact. A failed read
12
+ * prints no diagnostic either — an error string carrying an absolute path,
13
+ * folded into a consent screen, is the one real path leak in this feature.
14
+ */
15
+ export declare function versionSummaryLine(bw: BuiltWith | undefined): string | undefined;
16
+ /**
17
+ * The disclosure sentence naming what the summary stands for. Absent whenever
18
+ * the summary is absent — a sentence explaining a field that is not there is a
19
+ * sentence about nothing.
20
+ *
21
+ * "and nothing else" is the creator-facing form of the allowlist: no dependency
22
+ * list, no lockfile, no third-party names. The resolver iterates the allowlist
23
+ * and opens only allowlisted paths, so a third-party name is never in memory —
24
+ * this is where that guarantee reaches the person it protects.
25
+ */
26
+ export declare function versionDisclosureLines(bw: BuiltWith | undefined): string[];
27
+ /**
28
+ * Pretty-print a consent payload with `builtWith` REPLACED IN PLACE by its
29
+ * one-line summary — the whole of the exemption, in one place, so the two
30
+ * consent screens cannot drift about how the one summarized field renders.
31
+ *
32
+ * The summary lands at the key's OWN position inside the braces (before or after
33
+ * the block would read as commentary ABOUT the payload, which is the paraphrase
34
+ * these screens refuse everywhere else) and at the SAME indentation as its
35
+ * sibling keys, because a line that is a sibling on the wire and indented
36
+ * differently on screen reads as a nested value or as an aside, and it is
37
+ * neither.
38
+ *
39
+ * NO TRAILING COMMA, even when `builtWith` is not the last key. The line is not
40
+ * valid JSON either way, so this is a legibility call: a comma would borrow
41
+ * JSON's punctuation to dress up the one line that is deliberately not JSON,
42
+ * weakening the "visibly a summary" property for no gain. Its absence is a
43
+ * second, quieter signal that this line differs in kind from its neighbours.
44
+ *
45
+ * THE CALLER MUST HAVE ALREADY OMITTED `builtWith` when the summary is
46
+ * undefined — see `summarizeBuiltWith`. This function only formats; it is not
47
+ * the place that decides what is sent.
48
+ */
49
+ export declare function renderPayloadWithVersionSummary(payload: unknown, bw: BuiltWith | undefined): string[];
50
+ /**
51
+ * THE SUMMARY IS THE AUTHORITY ON WHETHER THE FIELD IS SENT: the value to put on
52
+ * the wire, or `undefined` to omit the field entirely.
53
+ *
54
+ * `versionSummaryLine` renders only allowlisted names, so a `builtWith` holding a
55
+ * NON-ALLOWLISTED name summarizes to `undefined` while the field itself would
56
+ * still ship — a version set TRANSMITTED WITHOUT EVER BEING SHOWN. That breaks
57
+ * the payload-shown-before-sent promise these screens exist to keep, and it is
58
+ * INVISIBLE, because an omitted field is exactly what a legitimate empty project
59
+ * also produces. Reachable with no bug at all: a platform package renamed under a
60
+ * cached value, or the allowlist shrinking under an existing one.
61
+ *
62
+ * So the renderer, already the sole authority on what is SHOWN, is made the sole
63
+ * authority on what is SENT. The screen and the wire can never disagree about
64
+ * whether a version set exists.
65
+ *
66
+ * Rendering the unrecognised name instead would put a name the allowlist rejected
67
+ * onto the consent screen, which is the opposite of the guarantee; dropping it at
68
+ * the resolver cannot help, because the resolver structurally cannot produce one
69
+ * — this guard is for the states the resolver did not produce, which is why it
70
+ * belongs at the boundary that renders.
71
+ *
72
+ * IT PROJECTS RATHER THAN PASSES THROUGH, and that is the whole guard. Returning
73
+ * `bw` unchanged the moment ONE allowlisted name renders would ship every
74
+ * non-allowlisted SIBLING in the same map, unshown — the leak is entry-scoped, so
75
+ * an object-scoped guard closes only "no allowlisted names at all" and leaves
76
+ * "some" wide open. That is the LIKELIER instance of the scenario cited above: a
77
+ * renamed package or a shrunken allowlist produces a MIXED map whenever more than
78
+ * one platform package is installed, never an empty one. The disclosure sentence
79
+ * says "and nothing else", so passing a sibling through makes the platform's own
80
+ * copy vouch for a false statement.
81
+ */
82
+ export declare function summarizeBuiltWith(bw: BuiltWith | undefined): BuiltWith | undefined;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The consent preview's one summarized field.
3
+ *
4
+ * The default everywhere else on that screen is that the preview IS the payload,
5
+ * formatted — a preview that paraphrased what would be sent is exactly the wrong
6
+ * thing to show someone deciding whether to send it. This narrows that for ONE
7
+ * field, on that rule's own reasoning: four version rows plus punctuation is
8
+ * eight lines of JSON for three facts, and a preview that pushes the creator's
9
+ * own note off a short terminal is a consent step in name only.
10
+ *
11
+ * Three properties make the exemption unable to grow, and all three are here:
12
+ * the line renders at the field's OWN position inside the payload block (before
13
+ * or after would read as commentary ABOUT the payload); it is DELIBERATELY NOT
14
+ * VALID JSON, so a reader cannot mistake it for this field's exact bytes; and
15
+ * nothing resolved omits the line entirely.
16
+ *
17
+ * SHORT NAMES here and package identities on the wire. The reader is judging
18
+ * what leaves their machine and three short names is what a person reads in one
19
+ * pass; the disclosure sentence below the payload says the wire carries the full
20
+ * identities.
21
+ */
22
+ import { PLATFORM_PACKAGES, TEMPLATE_COMPONENT } from "./allowlist.js";
23
+ /**
24
+ * `versions: sdk 2.0.0-alpha.3, harness 1.4.0, template 1.4.3`, or `undefined`.
25
+ *
26
+ * ALLOWLIST ORDER, not object order — the same order the wire carries, so the
27
+ * line a creator reads and the payload they consent to agree on sequence.
28
+ *
29
+ * Nothing resolved → `undefined`, and the caller omits the field, this line, AND
30
+ * the disclosure sentence together. No `versions:` header, no `(none)`, no
31
+ * `unknown`: a line implying a version set was sent when none was is a false
32
+ * statement in the one place on that screen that must be exact. A failed read
33
+ * prints no diagnostic either — an error string carrying an absolute path,
34
+ * folded into a consent screen, is the one real path leak in this feature.
35
+ */
36
+ export function versionSummaryLine(bw) {
37
+ if (bw === undefined)
38
+ return undefined;
39
+ const parts = [];
40
+ for (const pkg of PLATFORM_PACKAGES) {
41
+ const version = bw.packages[pkg.name];
42
+ if (version !== undefined)
43
+ parts.push(`${pkg.short} ${version}`);
44
+ }
45
+ if (bw.template !== undefined) {
46
+ parts.push(`${TEMPLATE_COMPONENT} ${bw.template}`);
47
+ }
48
+ if (parts.length === 0)
49
+ return undefined;
50
+ return `versions: ${parts.join(", ")}`;
51
+ }
52
+ /**
53
+ * The disclosure sentence naming what the summary stands for. Absent whenever
54
+ * the summary is absent — a sentence explaining a field that is not there is a
55
+ * sentence about nothing.
56
+ *
57
+ * "and nothing else" is the creator-facing form of the allowlist: no dependency
58
+ * list, no lockfile, no third-party names. The resolver iterates the allowlist
59
+ * and opens only allowlisted paths, so a third-party name is never in memory —
60
+ * this is where that guarantee reaches the person it protects.
61
+ */
62
+ export function versionDisclosureLines(bw) {
63
+ if (versionSummaryLine(bw) === undefined)
64
+ return [];
65
+ const n = namedCount(bw);
66
+ // The count is the ACTUAL number named, so it pluralises. A one-component
67
+ // project (a creator using only the SDK) is an ordinary case, and "one named
68
+ // versions" is wrong on a screen this design requires to be exact.
69
+ const noun = n === 1 ? "version" : "versions";
70
+ return [
71
+ " The versions line is the one field shown as a summary. It is sent as",
72
+ ` ${countWord(n)} named ${noun} of Vincentt's own packages, and nothing else.`,
73
+ ];
74
+ }
75
+ /**
76
+ * Pretty-print a consent payload with `builtWith` REPLACED IN PLACE by its
77
+ * one-line summary — the whole of the exemption, in one place, so the two
78
+ * consent screens cannot drift about how the one summarized field renders.
79
+ *
80
+ * The summary lands at the key's OWN position inside the braces (before or after
81
+ * the block would read as commentary ABOUT the payload, which is the paraphrase
82
+ * these screens refuse everywhere else) and at the SAME indentation as its
83
+ * sibling keys, because a line that is a sibling on the wire and indented
84
+ * differently on screen reads as a nested value or as an aside, and it is
85
+ * neither.
86
+ *
87
+ * NO TRAILING COMMA, even when `builtWith` is not the last key. The line is not
88
+ * valid JSON either way, so this is a legibility call: a comma would borrow
89
+ * JSON's punctuation to dress up the one line that is deliberately not JSON,
90
+ * weakening the "visibly a summary" property for no gain. Its absence is a
91
+ * second, quieter signal that this line differs in kind from its neighbours.
92
+ *
93
+ * THE CALLER MUST HAVE ALREADY OMITTED `builtWith` when the summary is
94
+ * undefined — see `summarizeBuiltWith`. This function only formats; it is not
95
+ * the place that decides what is sent.
96
+ */
97
+ export function renderPayloadWithVersionSummary(payload, bw) {
98
+ const summary = versionSummaryLine(bw);
99
+ const lines = JSON.stringify(payload, null, 2).split("\n");
100
+ if (summary === undefined)
101
+ return lines;
102
+ const start = lines.findIndex((l) => /^\s*"builtWith":/.test(l));
103
+ if (start === -1)
104
+ return lines;
105
+ // The WHOLE nested object is replaced, not just its opening line. Replacing
106
+ // the key line alone leaves the `packages` body and its closing brace stranded
107
+ // under the summary — the screen then shows the field twice, once summarized
108
+ // and once as orphaned JSON that no longer parses. Found by rendering the
109
+ // screen rather than by reading the code.
110
+ //
111
+ // Brace/bracket depth over the object's own text, so it ends at the MATCHING
112
+ // close rather than at the first `}` encountered.
113
+ const indent = /^\s*/.exec(lines[start])[0];
114
+ let depth = 0;
115
+ let end = start;
116
+ for (let i = start; i < lines.length; i++) {
117
+ for (const ch of lines[i]) {
118
+ if (ch === "{" || ch === "[")
119
+ depth++;
120
+ else if (ch === "}" || ch === "]")
121
+ depth--;
122
+ }
123
+ end = i;
124
+ if (depth <= 0)
125
+ break;
126
+ }
127
+ return [...lines.slice(0, start), `${indent}${summary}`, ...lines.slice(end + 1)];
128
+ }
129
+ /**
130
+ * THE SUMMARY IS THE AUTHORITY ON WHETHER THE FIELD IS SENT: the value to put on
131
+ * the wire, or `undefined` to omit the field entirely.
132
+ *
133
+ * `versionSummaryLine` renders only allowlisted names, so a `builtWith` holding a
134
+ * NON-ALLOWLISTED name summarizes to `undefined` while the field itself would
135
+ * still ship — a version set TRANSMITTED WITHOUT EVER BEING SHOWN. That breaks
136
+ * the payload-shown-before-sent promise these screens exist to keep, and it is
137
+ * INVISIBLE, because an omitted field is exactly what a legitimate empty project
138
+ * also produces. Reachable with no bug at all: a platform package renamed under a
139
+ * cached value, or the allowlist shrinking under an existing one.
140
+ *
141
+ * So the renderer, already the sole authority on what is SHOWN, is made the sole
142
+ * authority on what is SENT. The screen and the wire can never disagree about
143
+ * whether a version set exists.
144
+ *
145
+ * Rendering the unrecognised name instead would put a name the allowlist rejected
146
+ * onto the consent screen, which is the opposite of the guarantee; dropping it at
147
+ * the resolver cannot help, because the resolver structurally cannot produce one
148
+ * — this guard is for the states the resolver did not produce, which is why it
149
+ * belongs at the boundary that renders.
150
+ *
151
+ * IT PROJECTS RATHER THAN PASSES THROUGH, and that is the whole guard. Returning
152
+ * `bw` unchanged the moment ONE allowlisted name renders would ship every
153
+ * non-allowlisted SIBLING in the same map, unshown — the leak is entry-scoped, so
154
+ * an object-scoped guard closes only "no allowlisted names at all" and leaves
155
+ * "some" wide open. That is the LIKELIER instance of the scenario cited above: a
156
+ * renamed package or a shrunken allowlist produces a MIXED map whenever more than
157
+ * one platform package is installed, never an empty one. The disclosure sentence
158
+ * says "and nothing else", so passing a sibling through makes the platform's own
159
+ * copy vouch for a false statement.
160
+ */
161
+ export function summarizeBuiltWith(bw) {
162
+ if (versionSummaryLine(bw) === undefined)
163
+ return undefined;
164
+ const packages = {};
165
+ for (const pkg of PLATFORM_PACKAGES) {
166
+ const v = bw.packages[pkg.name];
167
+ if (v !== undefined)
168
+ packages[pkg.name] = v;
169
+ }
170
+ return bw.template === undefined ? { packages } : { packages, template: bw.template };
171
+ }
172
+ function namedCount(bw) {
173
+ if (bw === undefined)
174
+ return 0;
175
+ return (PLATFORM_PACKAGES.filter((p) => bw.packages[p.name] !== undefined).length +
176
+ (bw.template === undefined ? 0 : 1));
177
+ }
178
+ const COUNT_WORDS = ["zero", "one", "two", "three", "four", "five"];
179
+ function countWord(n) {
180
+ return COUNT_WORDS[n] ?? String(n);
181
+ }