@vincentt-xr/cli 0.17.0 → 0.18.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 (95) hide show
  1. package/MIGRATION.md +24 -0
  2. package/README.md +94 -1
  3. package/dist/cli/index.js +170 -84
  4. package/dist/cli/usage-text.js +32 -10
  5. package/dist/domain/api.d.ts +10 -8
  6. package/dist/domain/api.js +14 -9
  7. package/dist/domain/command.js +25 -16
  8. package/dist/domain/copy.d.ts +6 -3
  9. package/dist/domain/copy.js +10 -6
  10. package/dist/dx/command.d.ts +24 -4
  11. package/dist/dx/command.js +80 -25
  12. package/dist/dx/consent.js +6 -5
  13. package/dist/dx/journal.d.ts +17 -2
  14. package/dist/dx/journal.js +19 -3
  15. package/dist/dx/report.js +4 -3
  16. package/dist/dx/reportIo.js +6 -5
  17. package/dist/dx/send.js +6 -5
  18. package/dist/lib/backend.d.ts +0 -16
  19. package/dist/lib/backend.js +2 -26
  20. package/dist/lib/detect.d.ts +87 -0
  21. package/dist/lib/detect.js +867 -0
  22. package/dist/lib/diagnostics.js +2 -2
  23. package/dist/lib/feedback.js +1 -1
  24. package/dist/lib/feedbackCopy.js +1 -1
  25. package/dist/lib/initFrom.js +4 -4
  26. package/dist/lib/lifecycle.d.ts +2 -0
  27. package/dist/lib/lifecycle.js +9 -3
  28. package/dist/lib/listProjects.js +2 -1
  29. package/dist/lib/projects.d.ts +37 -2
  30. package/dist/lib/projects.js +86 -21
  31. package/dist/lib/settings.d.ts +113 -0
  32. package/dist/lib/settings.js +284 -0
  33. package/dist/lib/settingsCopy.d.ts +6 -0
  34. package/dist/lib/settingsCopy.js +99 -0
  35. package/dist/lib/settle.d.ts +61 -0
  36. package/dist/lib/settle.js +338 -0
  37. package/dist/lib/template.d.ts +19 -0
  38. package/dist/lib/template.js +73 -0
  39. package/dist/lib/terminalText.d.ts +3 -0
  40. package/dist/lib/terminalText.js +10 -0
  41. package/dist/lib/version.js +5 -11
  42. package/dist/lib/whoami.js +10 -3
  43. package/dist/library/assetsCommand.js +1 -1
  44. package/dist/library/client.js +8 -3
  45. package/dist/library/render.js +8 -6
  46. package/dist/login/authBackend.js +3 -2
  47. package/dist/login/ensure.d.ts +8 -2
  48. package/dist/login/ensure.js +21 -10
  49. package/dist/login/guard.js +5 -4
  50. package/dist/login/loginCommand.d.ts +2 -2
  51. package/dist/login/loginCommand.js +29 -5
  52. package/dist/login/pkce.js +1 -1
  53. package/dist/login/pkceLogin.js +1 -1
  54. package/dist/platform/outdated.js +2 -2
  55. package/dist/platform/upgradeReport.js +16 -12
  56. package/dist/preview/api.d.ts +5 -11
  57. package/dist/preview/api.js +13 -15
  58. package/dist/preview/command.d.ts +29 -0
  59. package/dist/preview/command.js +61 -1
  60. package/dist/preview/copy.d.ts +35 -3
  61. package/dist/preview/copy.js +102 -10
  62. package/dist/preview/proxy.d.ts +38 -1
  63. package/dist/preview/proxy.js +151 -2
  64. package/dist/preview/qrPage.js +1 -1
  65. package/dist/preview/runner.d.ts +25 -13
  66. package/dist/preview/runner.js +127 -26
  67. package/dist/preview/viewers.js +1 -1
  68. package/dist/publish/api.d.ts +22 -5
  69. package/dist/publish/api.js +80 -9
  70. package/dist/publish/command.d.ts +25 -0
  71. package/dist/publish/command.js +211 -35
  72. package/dist/publish/copy.d.ts +78 -5
  73. package/dist/publish/copy.js +271 -37
  74. package/dist/publish/fit.d.ts +15 -0
  75. package/dist/publish/fit.js +103 -0
  76. package/dist/publish/manifest.d.ts +44 -3
  77. package/dist/publish/manifest.js +172 -3
  78. package/dist/publish/prompt.d.ts +4 -0
  79. package/dist/publish/prompt.js +15 -1
  80. package/dist/publish/upload.d.ts +6 -0
  81. package/dist/publish/upload.js +11 -1
  82. package/dist/publish/visibility.d.ts +21 -0
  83. package/dist/publish/visibility.js +68 -0
  84. package/dist/relay/annotations.js +9 -4
  85. package/dist/relay/server.d.ts +21 -0
  86. package/dist/relay/server.js +35 -1
  87. package/dist/shared/authError.d.ts +27 -0
  88. package/dist/shared/authError.js +48 -4
  89. package/dist/shared/config.d.ts +21 -1
  90. package/dist/shared/config.js +39 -8
  91. package/dist/shared/countLimit.d.ts +7 -2
  92. package/dist/shared/countLimit.js +12 -5
  93. package/dist/shared/http.d.ts +23 -0
  94. package/dist/shared/http.js +58 -0
  95. package/package.json +7 -3
@@ -12,6 +12,7 @@
12
12
  // NEVER PRINTED, at any point, in any state: the project id, the org id, the
13
13
  // uploadId, the PAT, any presigned URL or fragment of one, the bucket, any S3
14
14
  // key, or a per-file upload log.
15
+ import { escapeControl } from "../lib/terminalText.js";
15
16
  /** The only horizontal rules in any f4 output. They exist here and nowhere else. */
16
17
  const RULE = " ─────────────────────────────────────────────────────────────────────";
17
18
  const LABELS = {
@@ -84,7 +85,7 @@ export function relativeTime(iso, now) {
84
85
  * A FINDING IS NEVER A COUNT. No "2 issues found", no summary line, and no exit
85
86
  * code that varies with findings.
86
87
  */
87
- export function findingLines(byFile, withSubtitle) {
88
+ export function findingLines(byFile, withSubtitle, folder = "dist/") {
88
89
  const out = [];
89
90
  out.push(withSubtitle
90
91
  ? " Link preview (what Slack, iMessage and WhatsApp will show)"
@@ -99,7 +100,7 @@ export function findingLines(byFile, withSubtitle) {
99
100
  out.push("");
100
101
  }
101
102
  for (const finding of file.findings) {
102
- out.push(...findingLine(finding, file.file, multi));
103
+ out.push(...findingLine(finding, file.file, multi, folder));
103
104
  }
104
105
  if (multi)
105
106
  out.push("");
@@ -108,7 +109,7 @@ export function findingLines(byFile, withSubtitle) {
108
109
  out.push("");
109
110
  return out;
110
111
  }
111
- function findingLine(f, file, indented) {
112
+ function findingLine(f, file, indented, folder) {
112
113
  const pad = indented ? " " : "";
113
114
  const label = LABELS[f.field].padEnd(LABEL_WIDTH);
114
115
  // The file · tag sits on a continuation line indented to the VALUE column, so
@@ -128,7 +129,7 @@ function findingLine(f, file, indented) {
128
129
  case "likely-default":
129
130
  return [`${pad} ! ${label} looks unset: "${truncate(f.value ?? "")}"`, detail];
130
131
  case "dead-image":
131
- return [`${pad} ✗ ${label} ${f.value} is not in dist/`, detail];
132
+ return [`${pad} ✗ ${label} ${f.value} is not in ${folder}`, detail];
132
133
  }
133
134
  }
134
135
  /**
@@ -140,13 +141,36 @@ function findingLine(f, file, indented) {
140
141
  export function nothingBlocksYou() {
141
142
  return [
142
143
  " 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
+ " build produces. Fix them in your source and publish again, or publish",
144
145
  " now and fix them later. Nothing here blocks you.",
145
146
  "",
146
147
  ];
147
148
  }
148
- export function readingLine(fileCount, totalBytes) {
149
- return `→ Reading dist/ … ${fileCount} files · ${formatBytes(totalBytes)}`;
149
+ const FINDINGS_SHOWN = 10;
150
+ /**
151
+ * The `Paths from the root` section: a second section of the checklist, same
152
+ * geometry as the link preview. Absent when there are no findings, never an
153
+ * empty heading. One explanation for the whole section.
154
+ */
155
+ export function pathsFromRootLines(findings, folder = "dist/") {
156
+ if (!findings || findings.length === 0)
157
+ return [];
158
+ const out = [" Paths from the root", ""];
159
+ for (const f of findings.slice(0, FINDINGS_SHOWN)) {
160
+ out.push(` ! ${f.ref} is not in ${folder}`);
161
+ out.push(`${" ".repeat(21)}${f.file} · ${f.tag}`);
162
+ }
163
+ if (findings.length > FINDINGS_SHOWN) {
164
+ out.push(` and ${findings.length - FINDINGS_SHOWN} more`);
165
+ }
166
+ out.push("", " Vincentt serves this app at the root of its address. A missing path that", " starts with / usually means the build was made for a subpath, such as a", " GitHub Pages project site. Nothing here blocks you.", "");
167
+ return out;
168
+ }
169
+ const DEFAULT_OUTPUT = { folder: "dist/", sourceNote: "" };
170
+ export function readingLine(fileCount, totalBytes, output = DEFAULT_OUTPUT) {
171
+ const note = output.sourceNote ? ` ${output.sourceNote}` : "";
172
+ // The folder is the creator's build.output or --dist, so it is escaped.
173
+ return `→ Reading ${escapeControl(output.folder)}${note} … ${fileCount} files · ${formatBytes(totalBytes)}`;
150
174
  }
151
175
  export function headerLine(projectName, orgName) {
152
176
  return orgName ? `→ ${projectName} · ${orgName}` : `→ ${projectName}`;
@@ -165,7 +189,7 @@ export function firstPublishChecklist(i) {
165
189
  return [
166
190
  "",
167
191
  headerLine(i.projectName, i.orgName),
168
- readingLine(i.fileCount, i.totalBytes),
192
+ readingLine(i.fileCount, i.totalBytes, i.output),
169
193
  "",
170
194
  RULE,
171
195
  " FIRST PUBLISH",
@@ -178,14 +202,15 @@ export function firstPublishChecklist(i) {
178
202
  // visible.
179
203
  ` ${i.address}`,
180
204
  "",
181
- " After this it is yours forever, and can never be changed or reused —",
205
+ " After this it is yours forever, and can never be changed or reused,",
182
206
  " not by you, not by anyone else. Publishing again replaces what it",
183
207
  " serves; it never changes the address.",
184
208
  "",
185
209
  RULE,
186
210
  "",
187
- ...findingLines(i.findings, true),
211
+ ...findingLines(i.findings, true, i.output?.folder),
188
212
  ...nothingBlocksYou(),
213
+ ...pathsFromRootLines(i.rootAbsolute, i.output?.folder),
189
214
  ` Publishing v${i.nextVersion} · ${i.fileCount} files · ${formatBytes(i.totalBytes)}`,
190
215
  "",
191
216
  ];
@@ -223,12 +248,13 @@ export function republishChecklist(i) {
223
248
  return [
224
249
  "",
225
250
  headerLine(i.projectName, i.orgName),
226
- readingLine(i.fileCount, i.totalBytes),
251
+ readingLine(i.fileCount, i.totalBytes, i.output),
227
252
  "",
228
253
  ` Live at ${i.address} ${context}`,
229
254
  ` Publishing v${i.nextVersion} · ${i.fileCount} files · ${formatBytes(i.totalBytes)}`,
230
255
  "",
231
- ...findingLines(i.findings, false),
256
+ ...findingLines(i.findings, false, i.output?.folder),
257
+ ...pathsFromRootLines(i.rootAbsolute, i.output?.folder),
232
258
  ];
233
259
  }
234
260
  /** `[Y/n]` — on a republish an accidental Enter does the thing the creator typed the command to do. */
@@ -245,18 +271,74 @@ export function offlineChecklist(i) {
245
271
  return [
246
272
  "",
247
273
  headerLine(i.projectName, i.orgName),
248
- readingLine(i.fileCount, i.totalBytes),
274
+ readingLine(i.fileCount, i.totalBytes, i.output),
249
275
  "",
250
276
  `! ${i.address} is currently offline.`,
251
277
  "",
252
278
  ` Publishing v${i.nextVersion} will put it back online and make v${i.nextVersion} live.`,
253
279
  "",
254
- ...findingLines(i.findings, false),
280
+ ...findingLines(i.findings, false, i.output?.folder),
281
+ ...pathsFromRootLines(i.rootAbsolute, i.output?.folder),
255
282
  ];
256
283
  }
257
284
  export function offlinePrompt(version) {
258
285
  return ` Publish v${version} and bring the address back online? [y/N] `;
259
286
  }
287
+ /**
288
+ * The address has published before but serves nothing now (`not_serving`), or
289
+ * the api reports a state this CLI does not know. Neither may say "Live at":
290
+ * the creator would share an address that answers with nothing.
291
+ */
292
+ export function notServingChecklist(i, stateKnown) {
293
+ return [
294
+ "",
295
+ headerLine(i.projectName, i.orgName),
296
+ readingLine(i.fileCount, i.totalBytes, i.output),
297
+ "",
298
+ stateKnown
299
+ ? `! ${i.address} is not serving anything right now.`
300
+ : `! This CLI can't tell whether ${i.address} is serving right now.`,
301
+ "",
302
+ ` Publishing v${i.nextVersion} will make v${i.nextVersion} live there.`,
303
+ "",
304
+ ...findingLines(i.findings, false, i.output?.folder),
305
+ ...pathsFromRootLines(i.rootAbsolute, i.output?.folder),
306
+ ];
307
+ }
308
+ /** `[y/N]`: the CLI cannot say what this publish changes, so Enter does nothing. */
309
+ export function unknownStatePrompt(version) {
310
+ return ` Publish v${version} and make it live? [y/N] `;
311
+ }
312
+ // ---- f10, the held address ----------------------------------------------------
313
+ /**
314
+ * The project is `held_by_platform`: Vincentt took the org's addresses offline
315
+ * over its traffic allowance, and the api refuses every publish until they are
316
+ * restored. Refused BEFORE the upload, because uploading a whole build only to
317
+ * be refused at commit is the retry loop this copy exists to stop.
318
+ *
319
+ * The project read carries no figures, so this names none. It shares
320
+ * planLimitRefused's voice: the platform is the actor, the remedy is outside
321
+ * the CLI, and nothing was lost.
322
+ */
323
+ export function heldByPlatform(address, org = {}) {
324
+ return [
325
+ "",
326
+ `✗ Vincentt has taken ${address} offline.`,
327
+ "",
328
+ " This wasn't you. The organization went over its monthly traffic allowance,",
329
+ " and Vincentt took its published addresses offline. Publishing stays refused",
330
+ " until they are restored.",
331
+ "",
332
+ " Taking a paid plan restores them automatically, in the console. It cannot be",
333
+ " done from the CLI.",
334
+ "",
335
+ " Every version is still stored and the address is still yours.",
336
+ "",
337
+ ...remedyLines(org.orgSlug, org.consoleUrl),
338
+ " Nothing was uploaded.",
339
+ "",
340
+ ];
341
+ }
260
342
  // ---- Flow L31, not signed in -------------------------------------------------
261
343
  /**
262
344
  * f1's line verbatim, with f4's reason. Note what is ABSENT, deliberately: f3's
@@ -350,28 +432,136 @@ function largestBlock(v) {
350
432
  return v.largest.map((f) => ` ${f.path.padEnd(width)} ${formatBytes(f.size).padStart(sizeWidth)}`);
351
433
  }
352
434
  /** No dist at all, or no index.html at its root. Zero network calls. */
353
- export function noDist(distDir) {
435
+ /**
436
+ * The build line. The recorded `build.command` when there is one; the template's
437
+ * `npm run build` when there is no vincentt.json; never a guess for an app whose
438
+ * file records no build command.
439
+ */
440
+ function buildItLine(buildCommand) {
441
+ if (buildCommand === null)
442
+ return " Run your build, then publish again.";
443
+ return ` Build it, then publish: ${buildCommand ?? "npm run build"}`;
444
+ }
445
+ // The refusals below carry a creator-chosen path or command, so every line is
446
+ // escaped: none of their fixed copy holds a control character.
447
+ export function noDist(distDir, buildCommand) {
354
448
  return [
355
449
  "",
356
450
  `✗ There's no build to publish at ${distDir}.`,
357
451
  "",
358
- " Publishing uploads what your build produced — did the build run?",
452
+ " Publishing uploads what your build produced. Did the build run?",
359
453
  "",
360
- " Build it, then publish: npm run build",
454
+ buildItLine(buildCommand),
361
455
  "",
362
456
  " Nothing was uploaded.",
363
457
  "",
364
- ];
458
+ ].map(escapeControl);
365
459
  }
366
- export function noIndexHtml(distDir) {
460
+ export function noIndexHtml(distDir, buildCommand) {
367
461
  return [
368
462
  "",
369
463
  "✗ There's no index.html at the root of your build.",
370
464
  "",
371
- ` ${distDir} exists, but nothing there answers the address's own URL —`,
372
- " did the build run?",
465
+ ` ${distDir} exists, but nothing there answers the address's own URL.`,
466
+ " Did the build run?",
467
+ "",
468
+ buildItLine(buildCommand),
469
+ "",
470
+ " Nothing was uploaded.",
471
+ "",
472
+ ].map(escapeControl);
473
+ }
474
+ /** Flow U1 — the output is the project folder itself. */
475
+ export function outputIsRoot(o) {
476
+ const how = o.source === "vincentt.json"
477
+ ? ` vincentt.json sets build.output to "${o.value}", which would publish your source,`
478
+ : o.source === "flag"
479
+ ? ` --dist ${o.value} points at it, which would publish your source,`
480
+ : ` ${o.value}/ resolves to it, which would publish your source,`;
481
+ return [
482
+ "",
483
+ "✗ The build output is this project's own folder, not a build.",
484
+ "",
485
+ how,
486
+ " settings and history. Set it to the folder your build writes.",
487
+ "",
488
+ " Nothing was uploaded.",
489
+ "",
490
+ ].map(escapeControl);
491
+ }
492
+ /** Flow U1 — the output lies outside the project. `shown` is project-relative. */
493
+ export function outputOutside(shown) {
494
+ return [
495
+ "",
496
+ `✗ The build output ${escapeControl(shown)} is outside this project.`,
497
+ "",
498
+ " Publish uploads a folder inside the project. Set --dist, or build.output in",
499
+ " vincentt.json, to the folder your build writes.",
373
500
  "",
374
- " Build it, then publish: npm run build",
501
+ " Nothing was uploaded.",
502
+ "",
503
+ ];
504
+ }
505
+ /** Flow U1 — the output is, or sits inside, `.git` or `.vincentt`. `shown` is project-relative. */
506
+ export function outputInHidden(shown, hidden) {
507
+ return [
508
+ "",
509
+ `✗ The build output ${escapeControl(shown)} is inside ${hidden}/, not a build.`,
510
+ "",
511
+ " Publishing it would put your repository's history or its Vincentt link at a",
512
+ " public address. Set --dist, or build.output in vincentt.json, to the folder",
513
+ " your build writes.",
514
+ "",
515
+ " Nothing was uploaded.",
516
+ "",
517
+ ];
518
+ }
519
+ function offenderList(paths, folder) {
520
+ // File names and the folder prefix come from the build and the creator's
521
+ // settings, and may carry control characters.
522
+ folder = escapeControl(folder);
523
+ const shown = paths.slice(0, FINDINGS_SHOWN).map((p) => ({
524
+ path: escapeControl(p.path),
525
+ target: p.target === undefined ? undefined : escapeControl(p.target),
526
+ }));
527
+ const width = Math.max(...shown.map((p) => folder.length + p.path.length));
528
+ const out = shown.map((p) => p.target !== undefined
529
+ ? ` ${(folder + p.path).padEnd(width)} links to ${p.target}`
530
+ : ` ${folder}${p.path}`);
531
+ if (paths.length > FINDINGS_SHOWN)
532
+ out.push(` and ${paths.length - FINDINGS_SHOWN} more`);
533
+ return out;
534
+ }
535
+ /** Flow U1 — hidden names or symlinks that would leak. After the header and reading line. */
536
+ export function secretFiles(paths, output = DEFAULT_OUTPUT) {
537
+ return [
538
+ "",
539
+ "✗ This build holds files that must never be published.",
540
+ "",
541
+ ...offenderList(paths, output.folder),
542
+ "",
543
+ ` Everything in ${escapeControl(output.folder)} becomes public at your address. Remove these from the build`,
544
+ " output, rebuild, then publish.",
545
+ "",
546
+ " Nothing was uploaded.",
547
+ "",
548
+ ];
549
+ }
550
+ /** Flow U1 — Git LFS pointer files. The recorded build.command, verbatim, when there is one. */
551
+ export function lfsPointers(paths, output = DEFAULT_OUTPUT, buildCommand) {
552
+ return [
553
+ "",
554
+ "✗ This build holds Git LFS pointer files instead of the assets.",
555
+ "",
556
+ ...offenderList(paths, output.folder),
557
+ "",
558
+ " The repo keeps these in Git LFS, and they were not downloaded before the build",
559
+ " ran. Download them, rebuild, then publish:",
560
+ "",
561
+ " git lfs pull",
562
+ buildCommand !== undefined
563
+ ? ` ${escapeControl(buildCommand)}`
564
+ : " then run your build again",
375
565
  "",
376
566
  " Nothing was uploaded.",
377
567
  "",
@@ -391,7 +581,7 @@ export function badPath(reason, offending) {
391
581
  "",
392
582
  `✗ This build ${why[reason] ?? "has a path we can't publish"}.`,
393
583
  "",
394
- ` ${offending}`,
584
+ ` ${escapeControl(offending)}`,
395
585
  "",
396
586
  " Rename it in your source, rebuild, then publish again.",
397
587
  "",
@@ -436,6 +626,22 @@ export function liveAt(liveUrl, version) {
436
626
  "",
437
627
  ];
438
628
  }
629
+ /** Prints once the commit succeeded and the address is still serving the previous bytes. */
630
+ export function propagating(liveUrl, version) {
631
+ return `→ Propagating v${version} to ${liveUrl}…`;
632
+ }
633
+ /**
634
+ * The wait ran out. The publish DID commit, so this is a success, and it says so
635
+ * plainly: a creator told "not yet" without that will publish again.
636
+ */
637
+ export function notYetVisible(liveUrl, version) {
638
+ return [
639
+ `✓ Published v${version}, not yet visible at ${liveUrl}`,
640
+ "",
641
+ " It usually appears within a minute. There is no need to publish again.",
642
+ "",
643
+ ];
644
+ }
439
645
  /** Prints only if a retry is needed. It says nothing about a failure, because nothing has failed yet. */
440
646
  export function takingLonger() {
441
647
  return "! Taking a moment longer than usual…";
@@ -462,7 +668,10 @@ export function planLimitRefused(i) {
462
668
  if (i.orgName || i.orgSlug)
463
669
  rows.push(["Organization", orgLabel(i.orgName, i.orgSlug)]);
464
670
  if (i.planName) {
465
- rows.push(["Plan", `${i.planName} — ${formatBytes(i.allowanceBytes)} traffic per month`]);
671
+ rows.push([
672
+ "Plan",
673
+ `${i.planName}, ${formatBytes(i.allowanceBytes)} traffic per month`,
674
+ ]);
466
675
  }
467
676
  else {
468
677
  rows.push(["Allowance", `${formatBytes(i.allowanceBytes)} traffic per month`]);
@@ -488,7 +697,7 @@ export function planLimitRefused(i) {
488
697
  // minutes, and copy that promises instant is copy that lies on a slow tick.
489
698
  ` Published addresses were taken offline${takenOn} because the organization`,
490
699
  " went over its monthly traffic allowance. Taking a paid plan restores them",
491
- " automatically, in the console — it cannot be done from the CLI.",
700
+ " automatically, in the console. It cannot be done from the CLI.",
492
701
  "",
493
702
  " Every version is still stored and the address is still yours.",
494
703
  "",
@@ -519,15 +728,21 @@ function remedyLines(orgSlug, consoleUrl) {
519
728
  if (!orgSlug)
520
729
  return [` Email ${SUPPORT_EMAIL}.`, ""];
521
730
  const base = (consoleUrl ?? "https://console.vincentt.studio").replace(/\/$/, "");
522
- return [
523
- ` See ${base}/orgs/${orgSlug}/usage, or email`,
524
- ` ${SUPPORT_EMAIL}.`,
525
- "",
526
- ];
731
+ return [` See ${base}/orgs/${orgSlug}/usage, or email`, ` ${SUPPORT_EMAIL}.`, ""];
527
732
  }
528
733
  const MONTHS = [
529
- "Jan", "Feb", "Mar", "Apr", "May", "Jun",
530
- "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
734
+ "Jan",
735
+ "Feb",
736
+ "Mar",
737
+ "Apr",
738
+ "May",
739
+ "Jun",
740
+ "Jul",
741
+ "Aug",
742
+ "Sep",
743
+ "Oct",
744
+ "Nov",
745
+ "Dec",
531
746
  ];
532
747
  /** `29 Aug` — the date the creator reads, in UTC, matching the marker's own day. */
533
748
  function shortDate(iso) {
@@ -568,6 +783,25 @@ export function flipFailed(address, liveVersion) {
568
783
  "",
569
784
  ];
570
785
  }
786
+ /**
787
+ * The commit's answer never arrived (the connection dropped or the api stopped
788
+ * answering), so whether the address switched is unknown. It says so, rather than
789
+ * claiming either outcome, and names the one check that settles it.
790
+ */
791
+ export function commitOutcomeUnknown(address, liveVersion) {
792
+ return [
793
+ "",
794
+ "✗ Your files uploaded, but Vincentt's answer to the switch-over never arrived.",
795
+ "",
796
+ ` It may or may not be live. Open https://${address} to check.`,
797
+ liveVersion === undefined
798
+ ? " If it did not switch, nothing is served there yet."
799
+ : ` If it did not switch, it is still serving v${liveVersion}.`,
800
+ "",
801
+ " Publishing again is safe: vincentt publish",
802
+ "",
803
+ ];
804
+ }
571
805
  /**
572
806
  * Two sentences, no explanation of version allocation. The real case is a
573
807
  * teammate and an agent publishing in the same second, and "someone else
@@ -599,7 +833,7 @@ export function publishIncomplete(address, counts, liveVersion) {
599
833
  : `${missing} file${missing === 1 ? "" : "s"} didn't arrive`;
600
834
  return [
601
835
  "",
602
- `✗ The upload didn't complete — ${what}.`,
836
+ `✗ The upload didn't complete: ${what}.`,
603
837
  "",
604
838
  ...unchangedLines(address, liveVersion),
605
839
  "",
@@ -611,7 +845,7 @@ export function publishIncomplete(address, counts, liveVersion) {
611
845
  export function badDigest(filePath, address, liveVersion) {
612
846
  return [
613
847
  "",
614
- `✗ ${filePath} changed while uploading — rebuild and publish again.`,
848
+ `✗ ${filePath} changed while uploading. Rebuild and publish again.`,
615
849
  "",
616
850
  ...unchangedLines(address, liveVersion),
617
851
  "",
@@ -625,10 +859,10 @@ export function badDigest(filePath, address, liveVersion) {
625
859
  */
626
860
  function unchangedLines(address, liveVersion) {
627
861
  if (liveVersion === undefined) {
628
- return [` Your live address is unchanged — ${address}.`];
862
+ return [` Your live address is unchanged: ${address}.`];
629
863
  }
630
864
  return [
631
- ` Your live address is unchanged — ${address} is still`,
865
+ ` Your live address is unchanged: ${address} is still`,
632
866
  ` serving v${liveVersion}.`,
633
867
  ];
634
868
  }
@@ -682,7 +916,7 @@ export function consoleVerb(verb, versionsUrl) {
682
916
  `✗ There's no \`${verb}\` command.`,
683
917
  "",
684
918
  " Rolling back, taking an address offline, and putting it back online are",
685
- " console actions — they change what a permanent address serves, and that",
919
+ " console actions: they change what a permanent address serves, and that",
686
920
  " belongs on a screen where you can see every version first.",
687
921
  "",
688
922
  ];
@@ -0,0 +1,15 @@
1
+ export interface HtmlFile {
2
+ /** Output-relative POSIX path, as in the manifest. */
3
+ file: string;
4
+ html: string;
5
+ }
6
+ export interface RootAbsoluteFinding {
7
+ file: string;
8
+ /** The reference as written, query and fragment included. */
9
+ ref: string;
10
+ /** `<script src>`, `<link rel=stylesheet>`, `<img src>`, ... */
11
+ tag: string;
12
+ }
13
+ /** Does a `/path` reference resolve under the host's index rules? */
14
+ export declare function resolvesInManifest(ref: string, manifest: ReadonlySet<string>): boolean;
15
+ export declare function rootAbsoluteFindings(htmlFiles: readonly HtmlFile[], manifestPaths: ReadonlySet<string>): RootAbsoluteFinding[];
@@ -0,0 +1,103 @@
1
+ // Root-absolute resource references that do not resolve in the output. The
2
+ // likely cause is a build made for a subpath (a GitHub Pages project site) while
3
+ // Vincentt serves the root. A WARNING only: nothing is rewritten and nothing is
4
+ // blocked, because the creator's build is what fixes it.
5
+ //
6
+ // Resource tags only. `<a href>` is a link to a route, not a missing file, and
7
+ // scanning it would warn on nearly every site's navigation. URLs baked into JS
8
+ // or CSS are not scanned (a stated limit).
9
+ //
10
+ // Regex-based, like classify.ts; every pattern here is linear.
11
+ import { escapeControl } from "../lib/terminalText.js";
12
+ const LINK_RELS = new Set(["stylesheet", "preload", "modulepreload", "icon"]);
13
+ const SRC_TAGS = new Set(["script", "img", "source", "video", "audio"]);
14
+ const TAG_RE = /<(script|link|img|source|video|audio)\b([^>]*)>/gi;
15
+ const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
16
+ function stripComments(html) {
17
+ let out = "";
18
+ let i = 0;
19
+ for (;;) {
20
+ const start = html.indexOf("<!--", i);
21
+ if (start === -1)
22
+ return out + html.slice(i);
23
+ out += html.slice(i, start);
24
+ const end = html.indexOf("-->", start + 4);
25
+ if (end === -1)
26
+ return out;
27
+ i = end + 3;
28
+ }
29
+ }
30
+ function attributes(attrs) {
31
+ const out = new Map();
32
+ for (const m of attrs.matchAll(ATTR_RE)) {
33
+ const name = m[1].toLowerCase();
34
+ if (!out.has(name))
35
+ out.set(name, m[2] ?? m[3] ?? m[4] ?? "");
36
+ }
37
+ return out;
38
+ }
39
+ /** Does a `/path` reference resolve under the host's index rules? */
40
+ export function resolvesInManifest(ref, manifest) {
41
+ const bare = ref.split("#")[0].split("?")[0];
42
+ let decoded = bare;
43
+ try {
44
+ decoded = decodeURI(bare);
45
+ }
46
+ catch {
47
+ // An undecodable reference is judged as written.
48
+ }
49
+ const rel = decoded.slice(1);
50
+ if (manifest.has(rel))
51
+ return true;
52
+ if (decoded.endsWith("/"))
53
+ return manifest.has(`${rel}index.html`);
54
+ const last = rel.split("/").pop() ?? "";
55
+ if (!last.includes("."))
56
+ return manifest.has(`${rel}/index.html`);
57
+ return false;
58
+ }
59
+ function isRootAbsolute(value) {
60
+ return value.startsWith("/") && !value.startsWith("//");
61
+ }
62
+ export function rootAbsoluteFindings(htmlFiles, manifestPaths) {
63
+ const findings = [];
64
+ for (const { file, html } of htmlFiles) {
65
+ for (const m of stripComments(html).matchAll(TAG_RE)) {
66
+ const tag = m[1].toLowerCase();
67
+ const attrs = attributes(m[2] ?? "");
68
+ let value;
69
+ let label;
70
+ if (tag === "link") {
71
+ const rel = (attrs.get("rel") ?? "")
72
+ .toLowerCase()
73
+ .split(/\s+/)
74
+ .find((r) => LINK_RELS.has(r));
75
+ if (rel === undefined)
76
+ continue;
77
+ value = attrs.get("href");
78
+ label = `<link rel=${rel}>`;
79
+ }
80
+ else if (SRC_TAGS.has(tag)) {
81
+ value = attrs.get("src");
82
+ label = `<${tag} src>`;
83
+ }
84
+ else {
85
+ continue;
86
+ }
87
+ if (value === undefined)
88
+ continue;
89
+ const trimmed = value.trim();
90
+ if (!isRootAbsolute(trimmed))
91
+ continue;
92
+ if (resolvesInManifest(trimmed, manifestPaths))
93
+ continue;
94
+ // Both come from the creator's build and reach the terminal verbatim.
95
+ findings.push({
96
+ file: escapeControl(file),
97
+ ref: escapeControl(trimmed),
98
+ tag: label,
99
+ });
100
+ }
101
+ }
102
+ return findings;
103
+ }
@@ -10,11 +10,52 @@ export interface Manifest {
10
10
  files: ManifestEntry[];
11
11
  totalBytes: number;
12
12
  }
13
+ export type ManifestReason = PathRejection | "no_dist" | "output_is_repo_root" | "output_outside_repo" | "output_in_hidden" | "secret_path" | "lfs_pointer";
14
+ /** An output-relative path the leak or LFS check refused. */
15
+ export interface Offender {
16
+ /** Output-relative, POSIX. A refused folder carries a trailing `/`. */
17
+ path: string;
18
+ /** For a symlink: where it resolves, repo-relative inside and absolute outside. */
19
+ target?: string;
20
+ }
21
+ export interface ManifestErrorDetail {
22
+ paths?: Offender[];
23
+ /** What the walk saw, for the reading line above a refusal. */
24
+ stats?: {
25
+ files: number;
26
+ bytes: number;
27
+ };
28
+ }
13
29
  export declare class ManifestError extends Error {
14
- readonly reason: PathRejection | "no_dist";
30
+ readonly reason: ManifestReason;
15
31
  readonly offendingPath: string;
16
- constructor(reason: PathRejection | "no_dist", offendingPath: string, message: string);
32
+ readonly paths: Offender[];
33
+ readonly stats?: {
34
+ files: number;
35
+ bytes: number;
36
+ };
37
+ constructor(reason: ManifestReason, offendingPath: string, message: string, detail?: ManifestErrorDetail);
38
+ }
39
+ export interface BuildManifestOptions {
40
+ /**
41
+ * The directory holding the binding. When given, the output is refused before
42
+ * any hashing if it is the repo root, lies outside it, holds a dotted name
43
+ * outside the two allowed ones, holds a symlink that resolves outside the root
44
+ * or onto a dotted path, or holds a Git LFS pointer. All of it is local.
45
+ */
46
+ repoRoot?: string;
17
47
  }
48
+ /** A Git LFS pointer file: at most 1 KB, the spec's exact first line, and an oid line. */
49
+ export declare function isLfsPointer(absPath: string, size: number): Promise<boolean>;
50
+ /**
51
+ * The leak and LFS checks, in that order, before anything is hashed or sent.
52
+ * Returns the output-relative file list the checks passed, which is exactly the
53
+ * list the manifest is built from: what is checked is what is uploaded.
54
+ */
55
+ export declare function checkOutput(distDir: string, repoRoot: string): Promise<{
56
+ rel: string;
57
+ size: number;
58
+ }[]>;
18
59
  /** Recursively list files under `dir` as dir-relative POSIX paths. Symlinks are not followed. */
19
60
  export declare function walkDist(dir: string, base?: string): Promise<string[]>;
20
61
  /** sha256 of a file, base64 std — the exact form `x-amz-checksum-sha256` wants. */
@@ -24,4 +65,4 @@ export declare function hashFile(absPath: string): Promise<string>;
24
65
  * hashed, so the string this returns is the string the API validates, signs, and
25
66
  * later lists — there is no second form anywhere.
26
67
  */
27
- export declare function buildManifest(distDir: string): Promise<Manifest>;
68
+ export declare function buildManifest(distDir: string, opts?: BuildManifestOptions): Promise<Manifest>;