@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,952 @@
1
+ // Every line `vincentt domain` prints (how-it-works.md Part 2B, Flows C1-C7).
2
+ // Pure functions returning string arrays, so the transcripts are assertable
3
+ // without a terminal.
4
+ //
5
+ // THE VOCABULARY, fixed before anything else (Part 2B § The vocabulary). Eight
6
+ // words, each refusing a plausible alternative:
7
+ // - "Attach" / "Detach". `add`/`remove` are SILENT aliases. *Remove* reads as
8
+ // delete the row, and the row is NEVER deleted — its survival across a detach
9
+ // IS the takeover defence.
10
+ // - "Your Vincentt address", never "the default", "the fallback", "the free
11
+ // address". *Fallback* inverts B-F2-7 in a single word.
12
+ // - "Serving" / "not serving", never active/inactive, enabled/disabled,
13
+ // up/down. Serving is the only verb true in every state that blames nobody.
14
+ // - "DNS changed", never misconfigured/broken/invalid/error. The creator
15
+ // changed a record they own, which they are entitled to do (B-F1-7).
16
+ // - "Not connected", never removed/deleted/expired/suspended/offline.
17
+ // *Offline* is f4's word for a creator's deliberate choice.
18
+ // - "The value", never token/secret/key. Public in DNS by construction — the
19
+ // wrong noun makes creators hide it and reviewers think we leaked it.
20
+ // - "Whoever manages DNS for acme.com", not "your registrar", wherever the
21
+ // reader may not control the zone.
22
+ // - "Keep this one", on exactly one record.
23
+ //
24
+ // NEVER PRINTED, in any state: the org id, project id, PAT, distribution id,
25
+ // certificate ARN, KVS key, any resolver hostname of ours, a raw DNS library
26
+ // error, or a stack trace. THE VERIFICATION VALUE IS PRINTED — public by
27
+ // construction — and is never called a token, secret or key.
28
+ import { VALIDATION_RECORD_GONE, } from "./wire.js";
29
+ import { recordNamePairs } from "./names.js";
30
+ /** The section rule. Not f4's fenced `─── FIRST PUBLISH ───`: nothing here is irreversible. */
31
+ const RULE_WIDTH = 74;
32
+ function sectionRule(label) {
33
+ const head = ` ─── ${label} `;
34
+ const dashes = Math.max(3, RULE_WIDTH - head.length);
35
+ return `${head}${"─".repeat(dashes)}`;
36
+ }
37
+ function closingRule() {
38
+ return ` ${"─".repeat(RULE_WIDTH - 2)}`;
39
+ }
40
+ // ---- the state vocabulary ----------------------------------------------------
41
+ /**
42
+ * The state words are the console's badge words, LOWERCASED (transcript L43
43
+ * decision 3). One word set across both surfaces — if the console badge and this
44
+ * column disagree, the console label is wrong.
45
+ *
46
+ * An unrecognized state renders as ITSELF rather than as a plausible neighbour.
47
+ * A newer API sending an eighth state must not read as `not verified`, which
48
+ * would tell a creator nothing is wrong when something might be.
49
+ */
50
+ export function stateWord(view) {
51
+ switch (view.state) {
52
+ case "pending":
53
+ return "not verified";
54
+ case "verified":
55
+ return "setting up";
56
+ case "live":
57
+ return "live";
58
+ case "failing":
59
+ return "dns changed";
60
+ case "held":
61
+ return "not connected";
62
+ case "cert_failed":
63
+ return "no certificate";
64
+ case "detached":
65
+ return "detached";
66
+ default:
67
+ return view.rawState || "unknown";
68
+ }
69
+ }
70
+ /** Is the domain serving right now? Drives the alias-stance line. */
71
+ export function isServing(view) {
72
+ return view.state === "live" || view.state === "failing";
73
+ }
74
+ // ---- the record block, BOTH NAME FORMS ---------------------------------------
75
+ /**
76
+ * The label for each name form. It names THE BOX ON THEIR SCREEN, never the DNS
77
+ * concept (Flow C1 rule 4): *relative* and *fully-qualified* are correct and
78
+ * useless — a creator who knew those words would not make this mistake.
79
+ */
80
+ const SHORT_TELL = "← most registrars want this";
81
+ const LONG_TELL_1 = "← only if the field already";
82
+ const LONG_TELL_2 = "shows your full domain";
83
+ /**
84
+ * The rule label for one record, naming it by WHAT IT IS FOR.
85
+ *
86
+ * `CNAME · certificate` is what distinguishes the validation record at a glance
87
+ * from the pointer CNAME, which is the same type. The label is derived from the
88
+ * record's ROLE when the API names one and falls back to the bare type — it is
89
+ * never invented from the record's name, because a name-sniffing heuristic
90
+ * (`starts with _`) would mislabel a future record.
91
+ */
92
+ export function recordLabel(record, role) {
93
+ if (role === "certificate")
94
+ return `${record.type} · certificate`;
95
+ return record.type;
96
+ }
97
+ /**
98
+ * Which record carries the keep-this-one caption?
99
+ *
100
+ * The certificate validation record, and ONLY it (Flow C1). Its deletion is the
101
+ * only fault in this feature with a delayed consequence — every other record
102
+ * fails immediately and visibly. `"keep this"` beside three records reads as
103
+ * boilerplate, which is why exactly one carries it.
104
+ *
105
+ * Decided from the ROLE the API names, never sniffed from the value. A heuristic
106
+ * on `acm-validations.aws` would break the day the CA changes, and it would break
107
+ * silently — into the arm where the caption is ABSENT, which is the expensive
108
+ * direction.
109
+ */
110
+ export function carriesKeepCaption(role) {
111
+ return role === "certificate";
112
+ }
113
+ /**
114
+ * The keep-this-one caption. The caption NAMES THE DELAY explicitly, because a
115
+ * creator who reads only the first clause must not conclude they can ignore it.
116
+ *
117
+ * It is NOT an alert and carries no tint — a warning colour on a record someone
118
+ * is being asked to CREATE reads as an incident at the moment of creation.
119
+ */
120
+ export function keepCaption() {
121
+ return [
122
+ " Keep this one. It is what renews the certificate, on its own, for as long",
123
+ " as it is published. Deleting it doesn't break anything today - it stops",
124
+ " the renewal months later.",
125
+ ];
126
+ }
127
+ const NAME_COLUMN = 12;
128
+ const VALUE_COLUMN = 44;
129
+ /**
130
+ * One record's block, with BOTH NAME FORMS, SHORT FIRST.
131
+ *
132
+ * Four rules from Flow C1, and every one of them is load-bearing:
133
+ * 1. NEVER ONE FORM. Printing the FQDN alone is what every researched platform
134
+ * does, and it is why this failure is universal.
135
+ * 2. NEVER "or". That hands the creator a decision with no basis for making it;
136
+ * two labelled lines give them a test they can run against the screen in
137
+ * front of them.
138
+ * 3. THE SHORT FORM IS FIRST — the majority want it, and the first line is what
139
+ * a hurrying creator copies. (QA-F16-C1 asserts this.)
140
+ * 4. Labels name the box on their screen, never the DNS concept.
141
+ *
142
+ * When the relative form cannot be derived (see names.ts) only the FQDN prints,
143
+ * WITHOUT the short-form tell. Printing an underived relative name would be the
144
+ * wrong-form paste this block exists to prevent, and printing the tell beside a
145
+ * lone FQDN would claim most registrars want the value on that line.
146
+ */
147
+ export function recordBlock(record, pair, role) {
148
+ const out = ["", sectionRule(recordLabel(record, role)), ""];
149
+ const label = (s) => ` ${s.padEnd(NAME_COLUMN - 4)}`;
150
+ if (pair.relative !== undefined) {
151
+ // The tell column is computed from the ACTUAL widths of the two names, never
152
+ // from a fixed constant. A name longer than the constant used to run into
153
+ // the `←`, which reads as one token and is exactly the ambiguity this block
154
+ // exists to remove. The minimum keeps the common case aligned with the value
155
+ // rows below it.
156
+ // TWO spaces minimum, not one: at one the longest name and the `←` read as a
157
+ // single token, which is the ambiguity this block exists to remove.
158
+ const nameWidth = Math.max(VALUE_COLUMN - NAME_COLUMN, pair.relative.length + 2, pair.fqdn.length + 2);
159
+ const gutter = " ".repeat(NAME_COLUMN - 4);
160
+ out.push(`${label("Name")}${pair.relative.padEnd(nameWidth)}${SHORT_TELL}`);
161
+ out.push(` ${gutter}${pair.fqdn.padEnd(nameWidth)}${LONG_TELL_1}`);
162
+ // The continuation of the long tell hangs under its first line, indented to
163
+ // the tell's own column rather than to the name column.
164
+ out.push(` ${gutter}${" ".repeat(nameWidth)} ${LONG_TELL_2}`);
165
+ }
166
+ else {
167
+ out.push(`${label("Name")}${pair.fqdn}`);
168
+ }
169
+ out.push(`${label("Value")}${record.value}`);
170
+ // The TTL line names no number of ours. A TTL the platform printed becomes a
171
+ // value a creator tries to match exactly, and registrars offer different sets.
172
+ out.push(`${label("TTL")}whatever your registrar offers; the lowest is fine`);
173
+ if (carriesKeepCaption(role)) {
174
+ out.push("", ...keepCaption());
175
+ }
176
+ return out;
177
+ }
178
+ /**
179
+ * The disambiguating paragraph. PROSE, not a footnote, and it names the VISIBLE
180
+ * TELL — *"if yours already shows `.acme.com` beside the box"*. That is the one
181
+ * observation that resolves the ambiguity without teaching DNS, and it is the
182
+ * sentence that has to survive a skim.
183
+ */
184
+ export function twoFormParagraph(zone) {
185
+ return [
186
+ ` Registrars disagree about this field. If yours already shows ".${zone}"`,
187
+ " beside the box, use the short name. If it wants the whole thing, use the",
188
+ " long one. Picking the wrong one creates a record that never verifies.",
189
+ ];
190
+ }
191
+ /**
192
+ * The whole three-record block, fenced together ONCE.
193
+ *
194
+ * `roles` is parallel to `records` and comes from the API. The CLI does not
195
+ * decide which record is the certificate one.
196
+ */
197
+ export function allRecordBlocks(records, domain, roles = []) {
198
+ const pairs = recordNamePairs(records, domain);
199
+ const out = [];
200
+ records.forEach((r, i) => out.push(...recordBlock(r, pairs[i], roles[i])));
201
+ out.push("", closingRule());
202
+ return out;
203
+ }
204
+ // ---- the alias stance --------------------------------------------------------
205
+ /**
206
+ * The permanent address, named UNPROMPTED, in every state where the custom domain
207
+ * is not working (CLI convention 4).
208
+ *
209
+ * "Your Vincentt address" — never "the default", "the fallback", "the free
210
+ * address". The line costs one line and it is the single most valuable thing this
211
+ * feature does.
212
+ *
213
+ * Returns NOTHING when the API did not send a permanent address. A line naming an
214
+ * address the CLI assembled itself would print the wrong host on staging and dev,
215
+ * for the reason `liveUrl` is server-computed everywhere else in this CLI.
216
+ */
217
+ export function aliasStance(domain, permanentAddress, serving) {
218
+ if (!permanentAddress)
219
+ return [];
220
+ if (serving) {
221
+ return [` It serves the same version as ${permanentAddress}, which is unchanged.`];
222
+ }
223
+ return [
224
+ ` ${domain} is not serving yet. ${permanentAddress} is, and is`,
225
+ " not affected by anything here.",
226
+ ];
227
+ }
228
+ /** The same fact for a domain that WAS serving and stopped. */
229
+ export function aliasStanceStopped(permanentAddress) {
230
+ if (!permanentAddress)
231
+ return [];
232
+ return [` ${permanentAddress} is serving, and is not affected.`];
233
+ }
234
+ // ---- the findings, the feature's differentiation -----------------------------
235
+ /**
236
+ * Severity per finding code. THE GLYPH CARRIES THE DISTINCTION AND THE EXIT CODE
237
+ * FOLLOWS THE GLYPH, NOT THE COUNT (CLI convention 3).
238
+ *
239
+ * `✗` blocking = this is why it does not work.
240
+ * `!` advisory = observed, and it blocks nothing.
241
+ *
242
+ * PROXY_IN_FRONT is advisory and NEVER blocking: a creator who wants a proxy in
243
+ * front is making a choice we do not own, the detection is heuristic, and the
244
+ * register must survive being wrong.
245
+ *
246
+ * VALIDATION_RECORD_GONE is advisory (D-44) — the row works and the horizon
247
+ * is months. Escalating its glyph would teach a creator that `✗` sometimes means
248
+ * *working fine*, which is how the severity ladder stops carrying information.
249
+ *
250
+ * An UNRECOGNIZED code defaults to ADVISORY. That direction is deliberate: a
251
+ * newer API's unknown finding must not fail a verify that the server considered
252
+ * successful, because the exit code is what an agent branches on. The finding
253
+ * still PRINTS in full — nothing is hidden, only its effect on the exit code is
254
+ * conservative.
255
+ */
256
+ const BLOCKING_CODES = new Set([
257
+ "TOKEN_MISSING",
258
+ "TOKEN_MISMATCH",
259
+ "WRONG_RECORD_TYPE",
260
+ "CNAME_WRONG_TARGET",
261
+ "CONFLICTING_RECORDS",
262
+ "CAA_BLOCKS_ISSUANCE",
263
+ "MALFORMED_ZONE",
264
+ ]);
265
+ /**
266
+ * The severity of one finding.
267
+ *
268
+ * `wireSeverity` is the API's own classification when it sends one, and it WINS:
269
+ * the exit code the CLI returns must not disagree with the state the server put
270
+ * the domain in, and the server is the party that performed the observation.
271
+ *
272
+ * The local table is the fallback for an API that sends no severity, and it is
273
+ * derived from the design record rather than from the producer:
274
+ * - PROXY_IN_FRONT is advisory and NEVER blocking (Scenario 4's accepted
275
+ * constraint: heuristic, worded as an observation, "never blocking").
276
+ * - STALE_ACME_CHALLENGE is advisory ("it blocks nothing; you can delete it").
277
+ * - UNEXPECTED_AAAA and TOKEN_STALE_PRESENT are advisory: each is an
278
+ * observation beside a row whose failure, if any, is named by another finding.
279
+ *
280
+ * KNOWN DIVERGENCE, reported rather than papered over: the producer's
281
+ * `DomainFinding.Severity()` currently classes ONLY VALIDATION_RECORD_GONE as
282
+ * advisory, which would make a proxy observation alone exit non-zero and
283
+ * contradict "an all-advisory report exits 0". Honoring a wire severity means
284
+ * the CLI tracks the server once that is settled instead of holding a second
285
+ * opinion in a second language.
286
+ */
287
+ export function findingSeverity(code, wireSeverity) {
288
+ if (wireSeverity === "advisory")
289
+ return "advisory";
290
+ if (wireSeverity === "fault" || wireSeverity === "blocking")
291
+ return "blocking";
292
+ return BLOCKING_CODES.has(code) ? "blocking" : "advisory";
293
+ }
294
+ export function severityGlyph(s) {
295
+ return s === "blocking" ? "✗" : "!";
296
+ }
297
+ /**
298
+ * The title and remedy body for each finding code. COPY IS FIXED IN ONE FILE,
299
+ * KEYED BY THE CLOSED ENUM, so neither surface invents a string.
300
+ *
301
+ * Every entry has a title naming THE SPECIFIC FAULT and a body naming the remedy
302
+ * IN THE CREATOR'S TERMS. Neither ever says "contact support" — that implies a
303
+ * queue, a state machine and an SLA, none of which exist.
304
+ */
305
+ const FINDING_COPY = {
306
+ TOKEN_MISSING: {
307
+ title: () => "We can't find the TXT record",
308
+ body: [
309
+ "Create it exactly as shown above. If you just added it, DNS can take a",
310
+ "few minutes to publish.",
311
+ ],
312
+ },
313
+ TOKEN_MISMATCH: {
314
+ title: () => "The TXT record holds a different value",
315
+ body: ["Usually means it was copied from another workspace or another domain."],
316
+ },
317
+ TOKEN_STALE_PRESENT: {
318
+ title: () => "An older verification value is still published",
319
+ body: [
320
+ "There is more than one value at that name. Delete the ones that don't",
321
+ "match what's shown above.",
322
+ ],
323
+ },
324
+ WRONG_RECORD_TYPE: {
325
+ title: (d) => `${d} is an A record, not a CNAME`,
326
+ body: [
327
+ "An A record points at a fixed address; ours moves. Delete the A record",
328
+ "and create the CNAME. Most registrars won't let both exist.",
329
+ ],
330
+ },
331
+ CNAME_WRONG_TARGET: {
332
+ title: () => "The CNAME points somewhere else",
333
+ body: ["Change it to the value shown above."],
334
+ },
335
+ CONFLICTING_RECORDS: {
336
+ title: (d) => `${d} has both an A record and a CNAME`,
337
+ body: [
338
+ "Only one can apply. Delete the A record and keep the CNAME shown above.",
339
+ ],
340
+ },
341
+ UNEXPECTED_AAAA: {
342
+ title: () => "There's also an AAAA record",
343
+ body: [
344
+ "IPv6 visitors will go to that address instead of ours. Delete it unless",
345
+ "you meant it.",
346
+ ],
347
+ },
348
+ PROXY_IN_FRONT: {
349
+ // Worded as an OBSERVATION, never a verdict. The mechanism names it heuristic
350
+ // and never blocking, and the register must survive being wrong. It reports a
351
+ // proxy when it sees one and NEVER asserts absence.
352
+ title: () => "The address resolves into another provider's network",
353
+ body: [
354
+ "Something is proxying this domain in front of us. That may be",
355
+ "deliberate and nothing here blocks it - but while it's on, the",
356
+ "certificate we issue may not be the one your visitors receive.",
357
+ ],
358
+ },
359
+ CAA_BLOCKS_ISSUANCE: {
360
+ title: () => "A CAA record stops us issuing",
361
+ body: [
362
+ "This is often set on the parent domain rather than on this name, and",
363
+ "often by whoever manages the company's DNS rather than by you.",
364
+ ],
365
+ },
366
+ STALE_ACME_CHALLENGE: {
367
+ title: () => "An old certificate-validation record is still published",
368
+ body: ["Left over from a previous host. It blocks nothing; you can delete it."],
369
+ },
370
+ VALIDATION_RECORD_GONE: {
371
+ // D-44. NO DATE, NO DAY COUNT — the remaining-days figure IS on the wire, for
372
+ // the operator, and the finding shape carries NO NUMERIC SLOT AT ALL, so it
373
+ // cannot reach this surface. A number converts an act-now into a
374
+ // schedule-later, and the honest remedy is *put it back now*.
375
+ //
376
+ // The reassurance clause is INVERTED here: elsewhere it reassures about the
377
+ // Vincentt address; here the domain itself is fine, so it reassures about
378
+ // TODAY and names the delay — in that order.
379
+ title: () => "The certificate's validation record is gone",
380
+ body: [
381
+ "is serving normally and nothing stops today. But that",
382
+ "record is what renews the certificate, so put it back before the",
383
+ "current one runs out.",
384
+ ],
385
+ },
386
+ MALFORMED_ZONE: {
387
+ title: () => "We couldn't read the DNS for this name",
388
+ body: [
389
+ "The zone returned more records than we can read, or returned them",
390
+ "malformed. Whoever manages DNS for this domain can see more than we can.",
391
+ ],
392
+ },
393
+ };
394
+ const OBSERVED_LABEL_WIDTH = 10;
395
+ /**
396
+ * One finding, rendered.
397
+ *
398
+ * `expected` / `observed` are STACKED AND COLUMN-ALIGNED, never inline — f2's
399
+ * rule for two values a creator must judge, and the shape that makes the
400
+ * difference visible at a glance. This is why f16 beats every researched
401
+ * competitor, all of whom print only the expected record.
402
+ *
403
+ * `observed` IS PRINTED AS THE STRING THE WIRE SENT. It is not parsed, not
404
+ * re-rendered, and not reformatted. The wire says it is a string and never
405
+ * structured precisely so a client cannot lose the fidelity that is the point of
406
+ * the ruling.
407
+ *
408
+ * `expected` is omitted when the API sent none — the pair degrades to the
409
+ * observed line alone rather than printing an invented expectation.
410
+ */
411
+ export function findingLines(f, domain) {
412
+ const severity = findingSeverity(f.code, f.severity);
413
+ const copy = FINDING_COPY[f.code];
414
+ const glyph = severityGlyph(severity);
415
+ // An unrecognized code still prints, with the code itself as the title. A
416
+ // finding the CLI cannot name is strictly better rendered as an opaque title
417
+ // plus its observed value than dropped — the observed value is the diagnosis.
418
+ const title = copy ? copy.title(domain) : f.code;
419
+ const out = [` ${glyph} ${title}`];
420
+ const label = (l) => ` ${l.padEnd(OBSERVED_LABEL_WIDTH)}`;
421
+ if (f.expected !== undefined)
422
+ out.push(`${label("expected")}${f.expected}`);
423
+ out.push(`${label("observed")}${f.observed}`);
424
+ if (copy) {
425
+ const indent = " ";
426
+ // The certificate advisory's body opens mid-sentence so the domain leads it.
427
+ const body = f.code === VALIDATION_RECORD_GONE
428
+ ? [`${domain} ${copy.body[0]}`, ...copy.body.slice(1)]
429
+ : copy.body;
430
+ for (const line of body)
431
+ out.push(`${indent}${line}`);
432
+ }
433
+ return out;
434
+ }
435
+ /**
436
+ * The whole findings report, ORDERED `✗` BEFORE `!`.
437
+ *
438
+ * A creator who reads only the first block reads the thing that is actually
439
+ * stopping them. Within each severity the API's own order is preserved — it
440
+ * observed them and it orders cause before consequence.
441
+ */
442
+ export function findingsReport(findings, domain) {
443
+ const blocking = findings.filter((f) => findingSeverity(f.code, f.severity) === "blocking");
444
+ const advisory = findings.filter((f) => findingSeverity(f.code, f.severity) === "advisory");
445
+ const out = [];
446
+ for (const f of [...blocking, ...advisory]) {
447
+ out.push("", ...findingLines(f, domain));
448
+ }
449
+ return out;
450
+ }
451
+ /**
452
+ * THE EXIT CODE FOLLOWS THE GLYPH, NEVER THE COUNT (CLI convention 3,
453
+ * QA-F16-C2).
454
+ *
455
+ * An all-advisory report exits 0. A report with ten advisories and no blocking
456
+ * finding exits 0. A report with one blocking finding exits 1 regardless of how
457
+ * many advisories sit beside it.
458
+ *
459
+ * This is the whole of the mapping and it is deliberately a function of the
460
+ * findings alone, so it cannot be reached through a code path that also consults
461
+ * a count.
462
+ */
463
+ export function exitCodeForFindings(findings) {
464
+ return findings.some((f) => findingSeverity(f.code, f.severity) === "blocking")
465
+ ? 1
466
+ : 0;
467
+ }
468
+ /** f4's after-a-non-zero-exit rule. An agent's most expensive failure is retrying against a half-applied state. */
469
+ export const NOTHING_WAS_CHANGED = " Nothing was changed.";
470
+ export const NOTHING_WAS_CLAIMED = " Nothing was claimed.";
471
+ // ---- the refusals ------------------------------------------------------------
472
+ /**
473
+ * At the ceiling. A LIMIT IS PRINTED AS A NUMBER, NEVER AS A POLICY OR A PLAN
474
+ * (f4's CLI rule), and the remedy names a HUMAN ROLE per f12's rule that unbidden
475
+ * output names a fact, never an instruction to buy.
476
+ *
477
+ * No price, no plan name other than the one they are on, no URL. Clause 4 reaches
478
+ * the CLI too.
479
+ */
480
+ export function refusedAtCeiling(limit) {
481
+ return [
482
+ "",
483
+ `✗ This workspace is using all ${limit} of the custom domains its plan carries.`,
484
+ "",
485
+ " Detach one to attach this:",
486
+ " vincentt domain list",
487
+ " vincentt domain detach <domain>",
488
+ "",
489
+ " An owner can change the workspace's plan in the console.",
490
+ "",
491
+ NOTHING_WAS_CLAIMED,
492
+ "",
493
+ ];
494
+ }
495
+ /**
496
+ * Already attached — THE ANTI-ORACLE ANSWER.
497
+ *
498
+ * BYTE-IDENTICAL whether the name is held by this workspace or by a stranger's.
499
+ * It never says *another workspace*, never says *yours*, never names a project.
500
+ * f2's slug-collision discipline applied to the second global host namespace.
501
+ *
502
+ * The `list` line is safe precisely because `list` only ever shows the caller's
503
+ * own domains — a creator who genuinely holds the name finds it there.
504
+ *
505
+ * IT TAKES NO ARGUMENT BEYOND THE HOSTNAME. A "you already have this"
506
+ * convenience message is the exact shape that breaks this, and it is the first
507
+ * thing a tidying PR adds.
508
+ */
509
+ export function refusedAlreadyAttached(domain) {
510
+ return [
511
+ "",
512
+ `✗ ${domain} is already attached to a project.`,
513
+ "",
514
+ " If it's yours, find it with:",
515
+ " vincentt domain list",
516
+ "",
517
+ NOTHING_WAS_CLAIMED,
518
+ "",
519
+ ];
520
+ }
521
+ /** A platform address. */
522
+ export function refusedPlatformHost() {
523
+ return [
524
+ "",
525
+ "✗ That's already a Vincentt address.",
526
+ "",
527
+ " Custom domains are for names you own at your own registrar.",
528
+ "",
529
+ NOTHING_WAS_CLAIMED,
530
+ "",
531
+ ];
532
+ }
533
+ /**
534
+ * A root domain. The reason is one clause and it is ABOUT THE CREATOR'S
535
+ * REGISTRAR, not about our infrastructure.
536
+ *
537
+ * A refusal with no reason gets retried; a refusal whose reason is *"our CDN
538
+ * doesn't support it"* sounds like something we might fix on request.
539
+ */
540
+ export function refusedRootDomain(domain) {
541
+ const zone = domain || "acme.com";
542
+ return [
543
+ "",
544
+ `✗ Root domains aren't supported - use a subdomain like ar.${zone}.`,
545
+ "",
546
+ " A root domain needs a record type that works differently at every",
547
+ " registrar. A subdomain uses a plain CNAME, which is the same everywhere.",
548
+ "",
549
+ NOTHING_WAS_CLAIMED,
550
+ "",
551
+ ];
552
+ }
553
+ /** Not on a plan that carries custom domains. Names the plan they are on and nothing else. */
554
+ export function refusedNoEntitlement(planName) {
555
+ const plan = planName ? ` This workspace is on ${planName}.` : "";
556
+ return [
557
+ "",
558
+ `✗ Custom domains are part of the paid plans.${plan}`,
559
+ "",
560
+ " An owner can change the workspace's plan in the console.",
561
+ "",
562
+ NOTHING_WAS_CLAIMED,
563
+ "",
564
+ ];
565
+ }
566
+ /**
567
+ * A verify refused by the rate limit.
568
+ *
569
+ * `Retry-After` says HOW LONG, never WHICH LIMIT. All refusal bodies are
570
+ * byte-identical across causes, so this copy names no limit family.
571
+ */
572
+ export function refusedTooSoon(retryAfterSec) {
573
+ const when = retryAfterSec ? ` Try again in ${approxWait(retryAfterSec)}.` : "";
574
+ return [
575
+ "",
576
+ `✗ That was checked very recently.${when}`,
577
+ "",
578
+ " We also check on our own every few hours.",
579
+ "",
580
+ NOTHING_WAS_CHANGED,
581
+ "",
582
+ ];
583
+ }
584
+ function approxWait(seconds) {
585
+ if (seconds < 90)
586
+ return "about a minute";
587
+ const minutes = Math.round(seconds / 60);
588
+ if (minutes < 60)
589
+ return `about ${minutes} minutes`;
590
+ const hours = Math.round(minutes / 60);
591
+ return `about ${hours} hour${hours === 1 ? "" : "s"}`;
592
+ }
593
+ /**
594
+ * The uniform not-found. A hostname not attached to THIS project returns the same
595
+ * 404 as one that does not exist, one held by a stranger, and a version that does
596
+ * not exist — so the CLI's copy for it must be equally uniform.
597
+ */
598
+ export function notAttached(domain) {
599
+ return [
600
+ "",
601
+ `✗ ${domain} isn't attached to this project.`,
602
+ "",
603
+ " See what is:",
604
+ " vincentt domain list",
605
+ "",
606
+ NOTHING_WAS_CHANGED,
607
+ "",
608
+ ];
609
+ }
610
+ export function attachSuccess(i) {
611
+ const zone = i.domain.slice(i.domain.indexOf(".") + 1);
612
+ const header = i.projectName
613
+ ? [i.orgName ? `→ ${i.projectName} · ${i.orgName}` : `→ ${i.projectName}`]
614
+ : [];
615
+ return [
616
+ "",
617
+ ...header,
618
+ `✓ Claimed ${i.domain}`,
619
+ "",
620
+ ` Create these three records at whoever manages DNS for ${zone}.`,
621
+ " All three are required - the CNAME sends visitors here, the TXT proves the",
622
+ " domain is yours, and the third keeps the certificate renewing.",
623
+ ...allRecordBlocks(i.records, i.domain, i.roles ?? []),
624
+ "",
625
+ ...twoFormParagraph(zone),
626
+ "",
627
+ ...aliasStance(i.domain, i.permanentAddress, false),
628
+ "",
629
+ " Check when the records are in:",
630
+ ` vincentt domain verify ${i.domain}`,
631
+ "",
632
+ " We also check on our own every few hours.",
633
+ "",
634
+ ];
635
+ }
636
+ // ---- verify ------------------------------------------------------------------
637
+ export function verifyReading(domain) {
638
+ return `→ Reading DNS for ${domain}…`;
639
+ }
640
+ /**
641
+ * Not ready. Every finding names what was actually observed.
642
+ *
643
+ * The remedy is THE EXACT COMMAND, last. Not "try again", not a URL.
644
+ */
645
+ export function verifyNotReady(domain, findings, permanentAddress) {
646
+ return [
647
+ "",
648
+ `✗ ${domain} isn't ready yet.`,
649
+ ...findingsReport(findings, domain),
650
+ "",
651
+ ...aliasStanceStopped(permanentAddress),
652
+ "",
653
+ " Fix what you can, then:",
654
+ ` vincentt domain verify ${domain}`,
655
+ "",
656
+ NOTHING_WAS_CHANGED,
657
+ "",
658
+ ];
659
+ }
660
+ /**
661
+ * Ready, and live.
662
+ *
663
+ * The `!` about hard-coded canonical/og:url sits BELOW the success because the
664
+ * creator's next act is to share the address, and the advisory is about what
665
+ * happens when they do. IT IS AN ADVISORY AND NEVER AN EDIT — B-F4-2.
666
+ */
667
+ export function verifyLive(domain, permanentAddress, liveVersion) {
668
+ const version = liveVersion === undefined ? "" : ` (v${liveVersion})`;
669
+ const same = permanentAddress
670
+ ? [
671
+ "",
672
+ ` It serves the same version as ${permanentAddress}${version},`,
673
+ " which is unchanged.",
674
+ ]
675
+ : [];
676
+ return [
677
+ `✓ ${domain} is yours - the TXT record matches.`,
678
+ "✓ The CNAME points at us.",
679
+ `✓ Live at https://${domain}`,
680
+ ...same,
681
+ "",
682
+ "! If your build hard-codes its Vincentt address in a canonical or og:url",
683
+ " tag, link previews on the new address will still point at the old one.",
684
+ " We don't change your files - edit the source and publish again.",
685
+ "",
686
+ ];
687
+ }
688
+ /**
689
+ * Ready, serving, but with advisory findings (D-44's case among them).
690
+ *
691
+ * EXIT 0, because every finding is `!` and the domain is serving. The success
692
+ * lines above are UNCHANGED — escalating the glyph would teach a creator that `✗`
693
+ * sometimes means *working fine*.
694
+ *
695
+ * When the certificate validation record is the missing one, THE RECORD IS
696
+ * RE-PRINTED: the entire cause of this state is a record the creator tidied away,
697
+ * and the remedy has to be on the screen rather than a command away. This is the
698
+ * one place a `verify` success prints a record block.
699
+ */
700
+ export function verifyServingWithAdvisories(domain, findings, reprint, roles) {
701
+ const out = [
702
+ `✓ ${domain} is yours - the TXT record matches.`,
703
+ "✓ The CNAME points at us.",
704
+ `✓ Serving at https://${domain}`,
705
+ ...findingsReport(findings, domain),
706
+ ];
707
+ if (reprint.length > 0) {
708
+ out.push(...allRecordBlocks(reprint, domain, roles));
709
+ }
710
+ out.push("");
711
+ return out;
712
+ }
713
+ /**
714
+ * Flow C5 — `failing`: STILL SERVING, and the reader can still prevent the stop.
715
+ *
716
+ * This arm exists because the serving-with-advisories one is FALSE here. That
717
+ * one opens *"✓ ar.acme.com is yours — the TXT record matches"*, which sat
718
+ * directly above *"✗ We can't find the TXT record"* — the screen contradicting
719
+ * itself in two adjacent lines. A `failing` domain is serving on a record we can
720
+ * no longer see, so nothing about the DNS may be reported as confirmed.
721
+ *
722
+ * The date is ABSOLUTE, never a countdown, and the stop is described BY WHAT A
723
+ * VISITOR SEES rather than by an internal state — the fact that makes a creator
724
+ * act. The reassurance clause is mandatory: their first fear on reading this is
725
+ * that they are losing their work.
726
+ */
727
+ export function verifyFailing(domain, findings, holdsAt, permanentAddress) {
728
+ const when = shortDate(holdsAt);
729
+ const deadline = when
730
+ ? [
731
+ ` ${domain} is still serving, and keeps serving until ${when}.`,
732
+ ` If the record is back before then, nothing happens and this goes away`,
733
+ " on its own. If it isn't, visitors see a Vincentt page under your name.",
734
+ ]
735
+ : [
736
+ ` ${domain} is still serving. If the record stays missing it stops, and`,
737
+ " visitors see a Vincentt page under your name.",
738
+ ];
739
+ return [
740
+ "",
741
+ `✗ We can't find ${domain}'s verification record.`,
742
+ ...findingsReport(findings, domain),
743
+ "",
744
+ ...deadline,
745
+ "",
746
+ ...aliasStanceStopped(permanentAddress),
747
+ "",
748
+ " Once the record is back:",
749
+ ` vincentt domain verify ${domain}`,
750
+ "",
751
+ NOTHING_WAS_CHANGED,
752
+ "",
753
+ ];
754
+ }
755
+ /**
756
+ * The certificate did not come.
757
+ *
758
+ * The one thing this state does that no other does: IT NAMES A DIFFERENT ACTOR.
759
+ * A CAA record usually lives on the parent zone and is usually somebody else's,
760
+ * and copy that assumes the reader controls it sends them looking in the wrong
761
+ * place. It never says "contact support" — that implies a queue this product does
762
+ * not have.
763
+ */
764
+ export function verifyCertFailed(domain, findings, permanentAddress) {
765
+ return [
766
+ `✓ ${domain} is yours - the TXT record matches.`,
767
+ "✓ The CNAME points at us.",
768
+ `✗ We couldn't get a certificate for ${domain}.`,
769
+ ...findingsReport(findings, domain),
770
+ "",
771
+ " The domain is proven yours; a browser just won't trust it until there",
772
+ " is a certificate. We keep retrying on our own.",
773
+ "",
774
+ ...aliasStanceStopped(permanentAddress),
775
+ "",
776
+ ` vincentt domain verify ${domain}`,
777
+ "",
778
+ NOTHING_WAS_CHANGED,
779
+ "",
780
+ ];
781
+ }
782
+ // ---- list --------------------------------------------------------------------
783
+ /** The absolute date a creator reads. NEVER "in 13 days", which drifts while a tab is open. */
784
+ const MONTHS = [
785
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
786
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
787
+ ];
788
+ export function shortDate(iso) {
789
+ if (!iso)
790
+ return "";
791
+ const d = new Date(iso);
792
+ if (Number.isNaN(d.getTime()))
793
+ return "";
794
+ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
795
+ }
796
+ /**
797
+ * Flow L43 — a listing verb REPORTS; it never asks and never picks.
798
+ *
799
+ * A fact longer than a column goes on an INDENTED CONTINUATION LINE at a constant
800
+ * indent (f2's rule, verbatim). The stop-date and the resume sentence are the two
801
+ * facts that do not fit a column and must not become a fifth one.
802
+ *
803
+ * `list` shows ONLY THIS WORKSPACE'S domains — which is what makes the
804
+ * anti-oracle collision message safe.
805
+ */
806
+ const LIST_CONTINUATION = " ";
807
+ export function listRows(domains) {
808
+ if (domains.length === 0)
809
+ return [];
810
+ const dw = Math.max(6, ...domains.map((d) => d.domain.length));
811
+ const pw = Math.max(7, ...domains.map((d) => (d.projectName ?? "").length));
812
+ const sw = Math.max(5, ...domains.map((d) => stateWord(d).length));
813
+ const out = [
814
+ ` ${"DOMAIN".padEnd(dw)} ${"PROJECT".padEnd(pw)} ${"STATE".padEnd(sw)} SINCE`,
815
+ ];
816
+ for (const d of domains) {
817
+ out.push(` ${d.domain.padEnd(dw)} ${(d.projectName ?? "").padEnd(pw)} ${stateWord(d).padEnd(sw)} ${shortDate(d.attachedAt)}`);
818
+ if (d.state === "failing" && d.holdsAt) {
819
+ out.push(`${LIST_CONTINUATION}stops serving on ${shortDate(d.holdsAt)} unless the TXT record is back`);
820
+ }
821
+ if (d.state === "held") {
822
+ out.push(`${LIST_CONTINUATION}put the TXT record back and it resumes on its own`);
823
+ }
824
+ }
825
+ return out;
826
+ }
827
+ /**
828
+ * The entitlement line — A BARE COUNT AND THE PLAN NAME. No price, no ladder, no
829
+ * *"upgrade for more"*. Clause 4 reaches the CLI too.
830
+ *
831
+ * A zero denominator is never rendered, and an absent limit renders the count
832
+ * alone rather than inventing one.
833
+ */
834
+ export function entitlementLine(count, limit, planName) {
835
+ if (count === undefined)
836
+ return [];
837
+ const plan = planName ? ` · ${planName} plan` : "";
838
+ if (limit === undefined || limit <= 0) {
839
+ return [` ${count} custom domain${count === 1 ? "" : "s"}${plan}`];
840
+ }
841
+ return [` ${count} of ${limit} custom domains${plan}`];
842
+ }
843
+ export function listEmpty(planName) {
844
+ const plan = planName ? ` · ${planName} plan` : "";
845
+ return [
846
+ "",
847
+ ` No custom domains${plan}`,
848
+ "",
849
+ " Attach one to a published project:",
850
+ " vincentt domain attach ar.example.com",
851
+ "",
852
+ ];
853
+ }
854
+ // ---- detach ------------------------------------------------------------------
855
+ /**
856
+ * Flow C7 — the confirmation.
857
+ *
858
+ * `[y/N]`, AND IT IS NOT f4's IRREVERSIBLE CASING BEING MISAPPLIED. Detaching IS
859
+ * undoable — but the default is No because the act is IMMEDIATELY VISIBLE TO
860
+ * STRANGERS, and an accidental Enter would take a client's advertised address dark
861
+ * mid-campaign. The bracket casing tracks *would an accidental Enter be
862
+ * expensive*, and this one would (shared.md § the refined convention).
863
+ *
864
+ * THE COPY DELIBERATELY CONTRADICTS f4's PERMANENCE SENTENCE. A detached custom
865
+ * domain is NOT burned — the platform has no authority over the creator's
866
+ * registrar, and copy that "consistently" applied f4's permanence language would
867
+ * be a false claim about someone else's property. Stated positively: the name
868
+ * stays yours, and you can delete our three records.
869
+ *
870
+ * No fenced block: f4 reserves fencing for the irreversible.
871
+ */
872
+ export function detachDisclosure(domain, permanentAddress) {
873
+ const alias = permanentAddress
874
+ ? [` ${permanentAddress} is unaffected and keeps serving.`]
875
+ : [];
876
+ return [
877
+ "",
878
+ ` ${domain} stops serving immediately.`,
879
+ ...alias,
880
+ "",
881
+ " The name stays yours - point it wherever you like, or attach it again",
882
+ " later. You can delete the three Vincentt records at your registrar.",
883
+ "",
884
+ ];
885
+ }
886
+ export function detachPrompt(domain) {
887
+ return ` Detach ${domain}? [y/N] `;
888
+ }
889
+ export function detachDone(domain) {
890
+ return ["", `✓ Detached ${domain}`, "", " It no longer serves this project.", ""];
891
+ }
892
+ /** Declined. Exit 0, ZERO network calls. Declining is not a failure; it is the prompt working. */
893
+ export function detachDeclined(domain) {
894
+ return ["", ` ${domain} is unchanged and still serving.`, ""];
895
+ }
896
+ /**
897
+ * Already detached — a SILENT RESOLVE, not an error. The creator asked for a
898
+ * state the world is already in.
899
+ */
900
+ export function detachAlready(domain) {
901
+ return ["", `✓ ${domain} is not attached to this project.`, ""];
902
+ }
903
+ /**
904
+ * A non-TTY detach with no explicit flag. REFUSE; NEVER ASSUME YES.
905
+ *
906
+ * A pipe cannot consent. The remedy names the flag, because an agent that reaches
907
+ * this needs to know the act is available rather than concluding detach is
908
+ * console-only.
909
+ */
910
+ export function detachNeedsConsent(domain) {
911
+ return [
912
+ "",
913
+ "✗ Detaching takes a confirmation, and there's no terminal to ask on.",
914
+ "",
915
+ ` Re-run with --yes to detach ${domain} without being asked.`,
916
+ "",
917
+ NOTHING_WAS_CHANGED,
918
+ "",
919
+ ];
920
+ }
921
+ // ---- the routed non-verbs -----------------------------------------------------
922
+ /**
923
+ * `renew` gets a FACT rather than a redirect, because the honest answer is that
924
+ * the act does not exist. f4's rule: a creator typing a plausible verb has told us
925
+ * exactly what they want.
926
+ */
927
+ export function noRenewVerb() {
928
+ return [
929
+ "",
930
+ "✗ There's no `domain renew` command.",
931
+ "",
932
+ " Certificates renew on their own. There's nothing to run.",
933
+ "",
934
+ ];
935
+ }
936
+ export function unknownDomainSub(sub) {
937
+ return [
938
+ "",
939
+ `✗ There's no \`domain ${sub}\` command. You want:`,
940
+ " vincentt domain list",
941
+ "",
942
+ ];
943
+ }
944
+ /** `vincentt domains` / `custom-domain` / `dns` — plausible verbs, answered. */
945
+ export function domainVerbRedirect(typed) {
946
+ return [
947
+ "",
948
+ `✗ There's no \`${typed}\` command. You want:`,
949
+ " vincentt domain list",
950
+ "",
951
+ ];
952
+ }