@vincentt-xr/cli 0.2.0 → 0.3.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.
@@ -0,0 +1,606 @@
1
+ // Every line `vincentt publish` prints (Flows L22-L31). Pure functions returning
2
+ // string arrays, so the transcripts are assertable without a terminal.
3
+ //
4
+ // Five vocabulary rules govern all of it:
5
+ // - "Permanent" is the word for the address, never "locked", "frozen" or
6
+ // "reserved". Locked and frozen imply a keyholder; there isn't one.
7
+ // - A version is "v5", always, everywhere. Never "version 5", never a hash.
8
+ // - "Live" is the word and "latest" is not a word this product has.
9
+ // - The checklist "found" things; it never "failed", "flagged" or "blocked" them.
10
+ // - Unpublish "takes it offline"; it never "removes" or "deletes".
11
+ //
12
+ // NEVER PRINTED, at any point, in any state: the project id, the org id, the
13
+ // uploadId, the PAT, any presigned URL or fragment of one, the bucket, any S3
14
+ // key, or a per-file upload log.
15
+ /** The only horizontal rules in any f4 output. They exist here and nowhere else. */
16
+ const RULE = " ─────────────────────────────────────────────────────────────────────";
17
+ const LABELS = {
18
+ title: "Title",
19
+ description: "Description",
20
+ image: "Social image",
21
+ };
22
+ const VALUE_MAX = 60;
23
+ /** `Social image` is the longest label; the column is sized to it plus a gap. */
24
+ const LABEL_WIDTH = 13;
25
+ export function truncate(value, max = VALUE_MAX) {
26
+ return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
27
+ }
28
+ // ---- units ------------------------------------------------------------------
29
+ /** Bytes as the creator reads them. Decimal MB, matching what a build tool prints. */
30
+ export function formatBytes(bytes) {
31
+ if (bytes < 1000)
32
+ return `${bytes} B`;
33
+ const kb = bytes / 1000;
34
+ if (kb < 1000)
35
+ return `${round1(kb)} kB`;
36
+ const mb = kb / 1000;
37
+ if (mb < 1000)
38
+ return `${round1(mb)} MB`;
39
+ return `${round1(mb / 1000)} GB`;
40
+ }
41
+ function round1(n) {
42
+ const r = Math.round(n * 10) / 10;
43
+ return Number.isInteger(r) ? String(r) : r.toFixed(1);
44
+ }
45
+ /** The limit, printed as a plain number and never as policy. */
46
+ export function formatLimit(bytes) {
47
+ return `${Math.round(bytes / 1_048_576)} MB`;
48
+ }
49
+ export function relativeTime(iso, now) {
50
+ const then = Date.parse(iso);
51
+ if (Number.isNaN(then))
52
+ return "";
53
+ const seconds = Math.max(0, Math.round((now - then) / 1000));
54
+ if (seconds < 60)
55
+ return "just now";
56
+ const minutes = Math.round(seconds / 60);
57
+ if (minutes < 60)
58
+ return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
59
+ const hours = Math.round(minutes / 60);
60
+ if (hours < 24)
61
+ return `${hours} hour${hours === 1 ? "" : "s"} ago`;
62
+ const days = Math.round(hours / 24);
63
+ if (days < 30)
64
+ return `${days} day${days === 1 ? "" : "s"} ago`;
65
+ const months = Math.round(days / 30);
66
+ return `${months} month${months === 1 ? "" : "s"} ago`;
67
+ }
68
+ // ---- the findings block -----------------------------------------------------
69
+ /**
70
+ * Flow L25. Three registers of copy because the platform's confidence genuinely
71
+ * differs:
72
+ * - TEMPLATE-DEFAULT is flat and assertive. The CLI matched a string in a
73
+ * corpus it ships; it knows.
74
+ * - LIKELY-DEFAULT is hedged. The heuristic will be wrong sometimes, and a
75
+ * creator whose experience is genuinely called "AR" must not be told they
76
+ * made a mistake.
77
+ * - The dead og:image is the only ✗ the checklist ever emits, and it earns it:
78
+ * the manifest either contains the path or it does not.
79
+ *
80
+ * BOTH default tiers print the offending value. "Still the template default"
81
+ * without the string is a claim the creator has to go and verify; with it they
82
+ * either recognise it instantly or spot that we are wrong.
83
+ *
84
+ * A FINDING IS NEVER A COUNT. No "2 issues found", no summary line, and no exit
85
+ * code that varies with findings.
86
+ */
87
+ export function findingLines(byFile, withSubtitle) {
88
+ const out = [];
89
+ out.push(withSubtitle
90
+ ? " Link preview (what Slack, iMessage and WhatsApp will show)"
91
+ : " Link preview");
92
+ out.push("");
93
+ const multi = byFile.length > 1;
94
+ for (const file of byFile) {
95
+ // A dist with index.html and gallery.html prints two blocks with the
96
+ // filename as a sub-heading, rather than repeating the path on every line.
97
+ if (multi) {
98
+ out.push(` ${file.file}`);
99
+ out.push("");
100
+ }
101
+ for (const finding of file.findings) {
102
+ out.push(...findingLine(finding, file.file, multi));
103
+ }
104
+ if (multi)
105
+ out.push("");
106
+ }
107
+ if (!multi)
108
+ out.push("");
109
+ return out;
110
+ }
111
+ function findingLine(f, file, indented) {
112
+ const pad = indented ? " " : "";
113
+ const label = LABELS[f.field].padEnd(LABEL_WIDTH);
114
+ // The file · tag sits on a continuation line indented to the VALUE column, so
115
+ // the eye reads three labels and three values and the fix detail stays out of
116
+ // the scan. 4 (gutter) + 3 (glyph) + the label width + 1.
117
+ const detail = `${pad}${" ".repeat(4 + 3 + LABEL_WIDTH + 1)}${file} · ${f.tag}`;
118
+ switch (f.tier) {
119
+ case "present":
120
+ return [`${pad} ✓ ${label} ${truncate(f.value ?? "")}`];
121
+ case "missing":
122
+ return [`${pad} ! ${label} not found`, detail];
123
+ case "template-default":
124
+ return [
125
+ `${pad} ! ${label} still the template default: "${truncate(f.value ?? "")}"`,
126
+ detail,
127
+ ];
128
+ case "likely-default":
129
+ return [`${pad} ! ${label} looks unset: "${truncate(f.value ?? "")}"`, detail];
130
+ case "dead-image":
131
+ return [`${pad} ✗ ${label} ${f.value} is not in dist/`, detail];
132
+ }
133
+ }
134
+ /**
135
+ * Mandatory and always present. A screen with a red ✗ on it reads as a gate
136
+ * unless it explicitly says otherwise, and a creator who believes the platform
137
+ * is gating their publish on our opinion of their <meta> tags has learned
138
+ * something false about who owns the dist.
139
+ */
140
+ export function nothingBlocksYou() {
141
+ return [
142
+ " Scrapers don't run JavaScript, so these have to be in the HTML your",
143
+ " build produces. Fix them in your source and publish again — or publish",
144
+ " now and fix them later. Nothing here blocks you.",
145
+ "",
146
+ ];
147
+ }
148
+ export function readingLine(fileCount, totalBytes) {
149
+ return `→ Reading dist/ … ${fileCount} files · ${formatBytes(totalBytes)}`;
150
+ }
151
+ export function headerLine(projectName, orgName) {
152
+ return orgName ? `→ ${projectName} · ${orgName}` : `→ ${projectName}`;
153
+ }
154
+ /**
155
+ * Flow L22 — the single most consequential screen in the product, and its ORDER
156
+ * is the design.
157
+ *
158
+ * The address block is FIRST and it is fenced: a creator who reads nothing else
159
+ * reads the address and the sentence under it. The findings come AFTER, never
160
+ * before — the address is the irreversible thing and the metadata is fixable
161
+ * forever, so putting a red ✗ above the permanence block would make the eye land
162
+ * on the recoverable problem and skim the unrecoverable one.
163
+ */
164
+ export function firstPublishChecklist(i) {
165
+ return [
166
+ "",
167
+ headerLine(i.projectName, i.orgName),
168
+ readingLine(i.fileCount, i.totalBytes),
169
+ "",
170
+ RULE,
171
+ " FIRST PUBLISH",
172
+ "",
173
+ " This address becomes permanent:",
174
+ "",
175
+ // Indented, alone, unadorned. No label, no backticks, no https:// — this is
176
+ // not a link to copy, it is a string to READ, and the creator is about to
177
+ // type `y` on it. Bare and surrounded by whitespace is what makes a typo
178
+ // visible.
179
+ ` ${i.address}`,
180
+ "",
181
+ " After this it is yours forever, and can never be changed or reused —",
182
+ " not by you, not by anyone else. Publishing again replaces what it",
183
+ " serves; it never changes the address.",
184
+ "",
185
+ RULE,
186
+ "",
187
+ ...findingLines(i.findings, true),
188
+ ...nothingBlocksYou(),
189
+ ` Publishing v${i.nextVersion} · ${i.fileCount} files · ${formatBytes(i.totalBytes)}`,
190
+ "",
191
+ ];
192
+ }
193
+ /** The prompt line restates the address AND the version. `[y/N]` — an accidental Enter does nothing. */
194
+ export function firstPublishPrompt(address, version) {
195
+ return ` Make ${address} permanent and publish v${version}? [y/N] `;
196
+ }
197
+ /**
198
+ * Declining. Glyphless, exit 0, zero network calls beyond the project read.
199
+ * Declining is not a failure and is not an error; it is the prompt working.
200
+ *
201
+ * The second line is the one that matters — a creator who backed out because the
202
+ * address was wrong needs to know the address is still theirs to change.
203
+ */
204
+ export function declined() {
205
+ return [
206
+ "",
207
+ " Nothing was published. The address is still yours to change:",
208
+ " vincentt publish when you're ready.",
209
+ "",
210
+ ];
211
+ }
212
+ /**
213
+ * Flow L24. No fence, no permanence block, no disclosure: the address is already
214
+ * permanent and the creator already knows, and presenting a frozen fact as a
215
+ * choice is worse than not presenting it at all.
216
+ *
217
+ * `(v5, published 3 days ago)` is rendered from the project read ALONE —
218
+ * liveVersion and livePublishedAt both ride GET /projects/{id}.
219
+ */
220
+ export function republishChecklist(i) {
221
+ const when = i.livePublishedAt ? relativeTime(i.livePublishedAt, i.now) : "";
222
+ const context = when ? `(v${i.liveVersion}, published ${when})` : `(v${i.liveVersion})`;
223
+ return [
224
+ "",
225
+ headerLine(i.projectName, i.orgName),
226
+ readingLine(i.fileCount, i.totalBytes),
227
+ "",
228
+ ` Live at ${i.address} ${context}`,
229
+ ` Publishing v${i.nextVersion} · ${i.fileCount} files · ${formatBytes(i.totalBytes)}`,
230
+ "",
231
+ ...findingLines(i.findings, false),
232
+ ];
233
+ }
234
+ /** `[Y/n]` — on a republish an accidental Enter does the thing the creator typed the command to do. */
235
+ export function republishPrompt(version) {
236
+ return ` Publish v${version} and make it live? [Y/n] `;
237
+ }
238
+ // ---- Flow L31, the offline address -------------------------------------------
239
+ /**
240
+ * A publish onto an offline address brings it online, because publish always
241
+ * goes live. The creator must be told: they took it offline on purpose and might
242
+ * not intend to reverse that, so the default reverts to [y/N].
243
+ */
244
+ export function offlineChecklist(i) {
245
+ return [
246
+ "",
247
+ headerLine(i.projectName, i.orgName),
248
+ readingLine(i.fileCount, i.totalBytes),
249
+ "",
250
+ `! ${i.address} is currently offline.`,
251
+ "",
252
+ ` Publishing v${i.nextVersion} will put it back online and make v${i.nextVersion} live.`,
253
+ "",
254
+ ...findingLines(i.findings, false),
255
+ ];
256
+ }
257
+ export function offlinePrompt(version) {
258
+ return ` Publish v${version} and bring the address back online? [y/N] `;
259
+ }
260
+ // ---- Flow L31, not signed in -------------------------------------------------
261
+ /**
262
+ * f1's line verbatim, with f4's reason. Note what is ABSENT, deliberately: f3's
263
+ * "your local dev loop doesn't need one" paragraph. It is the right copy for
264
+ * preview and the wrong copy here — a creator trying to publish is not looking
265
+ * for a smaller room, and offering one at this gate reads as a deflection.
266
+ */
267
+ export function notSignedIn() {
268
+ return [
269
+ "",
270
+ "✗ You're not signed in.",
271
+ "",
272
+ " Publishing puts your build at a permanent address, and that needs an account.",
273
+ "",
274
+ " Sign in: vincentt login",
275
+ "",
276
+ ];
277
+ }
278
+ // ---- Flow L28, the bounds ----------------------------------------------------
279
+ /**
280
+ * Loud, local, instant, and it carries the real number. This fires before a
281
+ * single byte moves and before a single request is made.
282
+ *
283
+ * The two numbers are STACKED AND ALIGNED — the judgement the creator must make
284
+ * is a comparison, and prose defeats it. The three largest files are named
285
+ * because that is almost always the entire diagnosis: one uncompressed asset is
286
+ * nearly always the cause.
287
+ *
288
+ * The limit is printed as a number, never as policy. No "on your plan", no
289
+ * "upgrade" — it is a correctness limit and not a quota, and copy implying
290
+ * otherwise would be the first sentence of a billing model this feature does not
291
+ * have.
292
+ */
293
+ export function tooLarge(v) {
294
+ if (v.kind === "file") {
295
+ return [
296
+ "",
297
+ "✗ One file is too large to publish.",
298
+ "",
299
+ ...alignedPair([v.path ?? "", formatBytes(v.observed)], ["the per-file limit", formatLimit(v.limit)]),
300
+ "",
301
+ " Compress or externalise it, rebuild, then publish again.",
302
+ "",
303
+ " Nothing was uploaded.",
304
+ "",
305
+ ];
306
+ }
307
+ if (v.kind === "count") {
308
+ return [
309
+ "",
310
+ "✗ This build has too many files to publish.",
311
+ "",
312
+ ...alignedPair(["your dist/", `${v.observed} files`], ["the limit", `${v.limit} files`]),
313
+ "",
314
+ " Bundle or externalise the extras, rebuild, then publish again.",
315
+ "",
316
+ " Nothing was uploaded.",
317
+ "",
318
+ ];
319
+ }
320
+ return [
321
+ "",
322
+ "✗ This build is too large to publish.",
323
+ "",
324
+ ...alignedPair(["your dist/", formatBytes(v.observed)], ["the limit", formatLimit(v.limit)]),
325
+ "",
326
+ " The largest files are:",
327
+ "",
328
+ ...largestBlock(v),
329
+ "",
330
+ // The second verb matters: a 214 MB scan may belong on the creator's own CDN
331
+ // rather than inside the bundle, and a creator told only to compress will
332
+ // spend an hour on the wrong fix.
333
+ " Compress or externalise the big ones, rebuild, then publish again.",
334
+ "",
335
+ " Nothing was uploaded.",
336
+ "",
337
+ ];
338
+ }
339
+ function alignedPair(a, b) {
340
+ const labelWidth = Math.max(a[0].length, b[0].length);
341
+ const valueWidth = Math.max(a[1].length, b[1].length);
342
+ return [
343
+ ` ${a[0].padEnd(labelWidth)} ${a[1].padStart(valueWidth)}`,
344
+ ` ${b[0].padEnd(labelWidth)} ${b[1].padStart(valueWidth)}`,
345
+ ];
346
+ }
347
+ function largestBlock(v) {
348
+ const width = Math.max(0, ...v.largest.map((f) => f.path.length));
349
+ const sizeWidth = Math.max(0, ...v.largest.map((f) => formatBytes(f.size).length));
350
+ return v.largest.map((f) => ` ${f.path.padEnd(width)} ${formatBytes(f.size).padStart(sizeWidth)}`);
351
+ }
352
+ /** No dist at all, or no index.html at its root. Zero network calls. */
353
+ export function noDist(distDir) {
354
+ return [
355
+ "",
356
+ `✗ There's no build to publish at ${distDir}.`,
357
+ "",
358
+ " Publishing uploads what your build produced — did the build run?",
359
+ "",
360
+ " Build it, then publish: npm run build",
361
+ "",
362
+ " Nothing was uploaded.",
363
+ "",
364
+ ];
365
+ }
366
+ export function noIndexHtml(distDir) {
367
+ return [
368
+ "",
369
+ "✗ There's no index.html at the root of your build.",
370
+ "",
371
+ ` ${distDir} exists, but nothing there answers the address's own URL —`,
372
+ " did the build run?",
373
+ "",
374
+ " Build it, then publish: npm run build",
375
+ "",
376
+ " Nothing was uploaded.",
377
+ "",
378
+ ];
379
+ }
380
+ /** A local path rule the creator's own build broke. Local, before any request. */
381
+ export function badPath(reason, offending) {
382
+ const why = {
383
+ path_traversal: "has a path that points outside your build",
384
+ absolute_path: "has an absolute path",
385
+ duplicate_path: "has the same path twice",
386
+ case_duplicate: "has two files whose names differ only by capitalisation, which your machine treats as one file and the web does not",
387
+ bad_encoding: "has a path we can't publish safely",
388
+ reserved_path: "has a top-level folder starting with `_`, which is reserved",
389
+ };
390
+ return [
391
+ "",
392
+ `✗ This build ${why[reason] ?? "has a path we can't publish"}.`,
393
+ "",
394
+ ` ${offending}`,
395
+ "",
396
+ " Rename it in your source, rebuild, then publish again.",
397
+ "",
398
+ " Nothing was uploaded.",
399
+ "",
400
+ ];
401
+ }
402
+ // ---- Flow L27, the upload ----------------------------------------------------
403
+ const BAR_WIDTH = 28;
404
+ /** The one redrawn line in f4. A bounded progress bar with a known total is not a stream. */
405
+ export function progressBar(done, total, bytesDone, bytesTotal) {
406
+ const filled = total === 0 ? BAR_WIDTH : Math.round((done / total) * BAR_WIDTH);
407
+ const bar = "█".repeat(filled) + "░".repeat(Math.max(0, BAR_WIDTH - filled));
408
+ return ` ${bar} ${done}/${total} · ${formatBytes(bytesDone)} / ${formatBytes(bytesTotal)}`;
409
+ }
410
+ /** The non-TTY degrade: periodic plain lines, at most one per 2s. */
411
+ export function progressPlain(done, total, bytesDone) {
412
+ return `→ Uploaded ${done}/${total} · ${formatBytes(bytesDone)}`;
413
+ }
414
+ export function uploadStart(fileCount) {
415
+ return `→ Uploading ${fileCount} files…`;
416
+ }
417
+ export function uploadDone(fileCount, totalBytes, ms) {
418
+ const seconds = Math.max(1, Math.round(ms / 1000));
419
+ return `✓ Uploaded ${fileCount} files · ${formatBytes(totalBytes)} in ${seconds}s`;
420
+ }
421
+ /** Covers the commit. The pause between the last byte and the live URL is real. */
422
+ export function finishing() {
423
+ return "→ Finishing…";
424
+ }
425
+ /**
426
+ * The payload of the entire command: the live URL, full and clickable, with the
427
+ * version in parentheses. The caching sentence is one line and ALWAYS present —
428
+ * a creator refreshing a phone that has the old page in memory will see the old
429
+ * page, decide publish is broken, and publish again.
430
+ */
431
+ export function liveAt(liveUrl, version) {
432
+ return [
433
+ `✓ Live at ${liveUrl} (v${version})`,
434
+ "",
435
+ " It may take a few seconds to appear on a device that has it cached.",
436
+ "",
437
+ ];
438
+ }
439
+ /** Prints only if a retry is needed. It says nothing about a failure, because nothing has failed yet. */
440
+ export function takingLonger() {
441
+ return "! Taking a moment longer than usual…";
442
+ }
443
+ // ---- Flow L29, the server-side failures ---------------------------------------
444
+ /**
445
+ * The cause is NOT named. Every non-healing arm of the commit state machine
446
+ * returns this identically by design, and the CLI must not invent a distinction
447
+ * the wire does not carry. "Had to be restarted" is honest about what happened
448
+ * and is correct for all three arms.
449
+ */
450
+ export function publishExpired(address, liveVersion) {
451
+ return [
452
+ "",
453
+ "✗ This publish took too long and had to be restarted.",
454
+ "",
455
+ ...unchangedLines(address, liveVersion),
456
+ "",
457
+ " Publish again: vincentt publish",
458
+ "",
459
+ ];
460
+ }
461
+ /** All three retries failed. "Nothing was lost" is the true and load-bearing sentence. */
462
+ export function flipFailed(address, liveVersion) {
463
+ return [
464
+ "",
465
+ "✗ Your files uploaded, but the address didn't switch over.",
466
+ "",
467
+ liveVersion === undefined
468
+ ? ` ${address} is unchanged. Nothing was lost.`
469
+ : ` ${address} is still serving v${liveVersion}. Nothing was lost.`,
470
+ "",
471
+ " Try again: vincentt publish",
472
+ "",
473
+ ];
474
+ }
475
+ /**
476
+ * Two sentences, no explanation of version allocation. The real case is a
477
+ * teammate and an agent publishing in the same second, and "someone else
478
+ * published this project" tells the creator to check with a person before
479
+ * re-running — the correct next action, and not something a retry loop can do
480
+ * for them.
481
+ */
482
+ export function publishContended() {
483
+ return [
484
+ "",
485
+ "✗ Someone else published this project at the same moment.",
486
+ "",
487
+ " Your live address is unchanged. Publish again: vincentt publish",
488
+ "",
489
+ ];
490
+ }
491
+ /**
492
+ * The commit found fewer objects than the manifest declared. The response
493
+ * carries COUNTS, never paths, so the CLI diffs against its OWN manifest and
494
+ * prints its own strings.
495
+ */
496
+ export function publishIncomplete(address, counts, liveVersion) {
497
+ const missing = counts.missing ?? 0;
498
+ const mismatched = counts.mismatched ?? 0;
499
+ const what = mismatched > 0 && missing > 0
500
+ ? `${missing} file${missing === 1 ? "" : "s"} didn't arrive and ${mismatched} changed while uploading`
501
+ : mismatched > 0
502
+ ? `${mismatched} file${mismatched === 1 ? "" : "s"} changed while uploading`
503
+ : `${missing} file${missing === 1 ? "" : "s"} didn't arrive`;
504
+ return [
505
+ "",
506
+ `✗ The upload didn't complete — ${what}.`,
507
+ "",
508
+ ...unchangedLines(address, liveVersion),
509
+ "",
510
+ " Rebuild if anything changed, then publish again: vincentt publish",
511
+ "",
512
+ ];
513
+ }
514
+ /** A file's bytes changed under the CLI between hashing and PUT. Names the file. */
515
+ export function badDigest(filePath, address, liveVersion) {
516
+ return [
517
+ "",
518
+ `✗ ${filePath} changed while uploading — rebuild and publish again.`,
519
+ "",
520
+ ...unchangedLines(address, liveVersion),
521
+ "",
522
+ " Rebuild, then publish again: npm run build",
523
+ "",
524
+ ];
525
+ }
526
+ /**
527
+ * Every failure states the live address is unchanged, because a creator whose
528
+ * publish just failed is asking exactly one question first.
529
+ */
530
+ function unchangedLines(address, liveVersion) {
531
+ if (liveVersion === undefined) {
532
+ return [` Your live address is unchanged — ${address}.`];
533
+ }
534
+ return [
535
+ ` Your live address is unchanged — ${address} is still`,
536
+ ` serving v${liveVersion}.`,
537
+ ];
538
+ }
539
+ export function tooLargeFromServer(observed, limit) {
540
+ return [
541
+ "",
542
+ "✗ This build is too large to publish.",
543
+ "",
544
+ ...alignedPair(["your dist/", formatBytes(observed)], ["the limit", formatLimit(limit)]),
545
+ "",
546
+ " Compress or externalise the big ones, rebuild, then publish again.",
547
+ "",
548
+ " Nothing was uploaded.",
549
+ "",
550
+ ];
551
+ }
552
+ export function rateLimited(retryAfterSec) {
553
+ return [
554
+ "",
555
+ `✗ Too many publishes recently. Try again in ${approxWait(retryAfterSec)}.`,
556
+ "",
557
+ " Publish again then: vincentt publish",
558
+ "",
559
+ ];
560
+ }
561
+ function approxWait(seconds) {
562
+ if (seconds < 90)
563
+ return "about a minute";
564
+ const minutes = Math.round(seconds / 60);
565
+ if (minutes < 60)
566
+ return `about ${minutes} minutes`;
567
+ const hours = Math.round(minutes / 60);
568
+ return `about ${hours} hour${hours === 1 ? "" : "s"}`;
569
+ }
570
+ // ---- Flow L30, the console-only verbs ----------------------------------------
571
+ /**
572
+ * The same block serves rollback, unpublish, republish, versions, revert and
573
+ * take-offline. These are the words a creator will actually type, and every one
574
+ * of them lands on one screen that names the real home. A bare "unknown command"
575
+ * here would be the product failing to answer a question it knows the answer to.
576
+ *
577
+ * The reason is given in one clause and it is the honest one. Not "not
578
+ * supported", not "coming soon" — neither is true.
579
+ *
580
+ * IF THE FOLDER IS NOT LINKED THE LAST TWO LINES ARE DROPPED. The CLI does not
581
+ * know which project they meant, and guessing a console URL would be worse than
582
+ * none.
583
+ */
584
+ export function consoleVerb(verb, versionsUrl) {
585
+ const out = [
586
+ `✗ There's no \`${verb}\` command.`,
587
+ "",
588
+ " Rolling back, taking an address offline, and putting it back online are",
589
+ " console actions — they change what a permanent address serves, and that",
590
+ " belongs on a screen where you can see every version first.",
591
+ "",
592
+ ];
593
+ if (versionsUrl) {
594
+ out.push(" Open the project's Versions tab:", ` ${versionsUrl}`, "");
595
+ }
596
+ return out;
597
+ }
598
+ /** The six verbs that land on that block. */
599
+ export const CONSOLE_ONLY_VERBS = [
600
+ "rollback",
601
+ "unpublish",
602
+ "republish",
603
+ "versions",
604
+ "revert",
605
+ "take-offline",
606
+ ];
@@ -0,0 +1,16 @@
1
+ /** One entry per known v2-template default, across template versions. */
2
+ export declare const TEMPLATE_DEFAULT_TITLES: readonly string[];
3
+ export declare const TEMPLATE_DEFAULT_DESCRIPTIONS: readonly string[];
4
+ /**
5
+ * Only paths that are distinctively the TEMPLATE'S. `/og.png` is deliberately
6
+ * absent: it is the most common filename a creator picks for their own social
7
+ * image, so listing it would tell a creator who did the work that they did not.
8
+ * A placeholder still sitting at `/og.png` is indistinguishable from a real one
9
+ * without reading the bytes, which the CLI does not do — the dead-image check
10
+ * carries the real failure instead.
11
+ */
12
+ export declare const TEMPLATE_DEFAULT_IMAGES: readonly string[];
13
+ /** The framework names a `<title>` should never be. Feeds the generic-shape heuristic. */
14
+ export declare const FRAMEWORK_NAMES: readonly string[];
15
+ /** Corpus membership is exact after trimming and case-folding. */
16
+ export declare function inCorpus(value: string, corpus: readonly string[]): boolean;
@@ -0,0 +1,52 @@
1
+ // The CLI-bundled corpus of known v2-template default strings — the
2
+ // high-confidence half of D-Template-default-detection-is-both.
3
+ //
4
+ // It ships WITH the CLI, versioned with it, and extending it is a two-line diff.
5
+ // It rots as the template changes; the structural heuristics in classify.ts are
6
+ // what carry the design when it does.
7
+ /** One entry per known v2-template default, across template versions. */
8
+ export const TEMPLATE_DEFAULT_TITLES = [
9
+ "Vite + React",
10
+ "Vite + React + TS",
11
+ "Vite App",
12
+ "React App",
13
+ "Vincentt XR App",
14
+ "Vincentt AR Experience",
15
+ "My AR Experience",
16
+ ];
17
+ export const TEMPLATE_DEFAULT_DESCRIPTIONS = [
18
+ "An AR experience built with Vincentt",
19
+ "An AR experience built with Vincentt.",
20
+ "A Vincentt XR experience",
21
+ "Web site created using create-react-app",
22
+ ];
23
+ /**
24
+ * Only paths that are distinctively the TEMPLATE'S. `/og.png` is deliberately
25
+ * absent: it is the most common filename a creator picks for their own social
26
+ * image, so listing it would tell a creator who did the work that they did not.
27
+ * A placeholder still sitting at `/og.png` is indistinguishable from a real one
28
+ * without reading the bytes, which the CLI does not do — the dead-image check
29
+ * carries the real failure instead.
30
+ */
31
+ export const TEMPLATE_DEFAULT_IMAGES = [
32
+ "/og-placeholder.png",
33
+ "/vite.svg",
34
+ "/placeholder-social.png",
35
+ ];
36
+ /** The framework names a `<title>` should never be. Feeds the generic-shape heuristic. */
37
+ export const FRAMEWORK_NAMES = [
38
+ "vite",
39
+ "react",
40
+ "vite + react",
41
+ "next.js",
42
+ "nextjs",
43
+ "app",
44
+ "index",
45
+ "document",
46
+ "untitled",
47
+ ];
48
+ /** Corpus membership is exact after trimming and case-folding. */
49
+ export function inCorpus(value, corpus) {
50
+ const v = value.trim().toLowerCase();
51
+ return corpus.some((entry) => entry.trim().toLowerCase() === v);
52
+ }