@vincentt-xr/cli 0.16.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 +196 -87
  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 +24 -11
  57. package/dist/preview/api.js +42 -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 +39 -6
  69. package/dist/publish/api.js +80 -9
  70. package/dist/publish/command.d.ts +25 -0
  71. package/dist/publish/command.js +230 -32
  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 +114 -0
  92. package/dist/shared/countLimit.js +176 -0
  93. package/dist/shared/http.d.ts +23 -0
  94. package/dist/shared/http.js +58 -0
  95. package/package.json +7 -3
@@ -10,24 +10,67 @@
10
10
  // branch is assertable without a real API, a real terminal or a real S3.
11
11
  import { promises as fs } from "node:fs";
12
12
  import path from "node:path";
13
- import { PublishApiError, abandonUpload, commitUpload, openUpload, readProject, } from "./api.js";
13
+ import { MalformedCommitResponse, PublishApiError, abandonUpload, commitUpload, openUpload, readProject, } from "./api.js";
14
14
  import { buildManifest, ManifestError } from "./manifest.js";
15
15
  import { checkBounds } from "./bounds.js";
16
16
  import { classifyDist } from "./classify.js";
17
17
  import { createProgress } from "./progress.js";
18
18
  import { UploadFailed, uploadAll } from "./upload.js";
19
+ import { waitUntilServing } from "./visibility.js";
19
20
  import * as copy from "./copy.js";
21
+ import { publishedLimitRefused, resolveCountLimit, storageCeilingRefused, } from "../shared/countLimit.js";
22
+ import { AuthExpiredError, authExpiredLines } from "../shared/authError.js";
23
+ import { EXIT } from "../lib/exitCodes.js";
24
+ import { apiFetch, isTransportError } from "../shared/http.js";
20
25
  import { resolveBuiltWith } from "../platform/resolve.js";
26
+ import { readSettings, resolveSettings, SettingsError, TEMPLATE_DEFAULTS, } from "../lib/settings.js";
27
+ import { settingsRefusal } from "../lib/settingsCopy.js";
28
+ import { rootAbsoluteFindings } from "./fit.js";
21
29
  const COMMIT_RETRIES = 3;
22
30
  const COMMIT_RETRY_DELAY_MS = 2000;
23
31
  export async function publishCommand(deps, opts = {}) {
32
+ let committed;
33
+ const code = await runPublish(deps, opts, (r) => {
34
+ committed = r;
35
+ });
36
+ if (opts.json) {
37
+ const report = code === 0 && committed ? committed : { state: "failed", exitCode: code };
38
+ (deps.emit ?? deps.print)(JSON.stringify(report));
39
+ }
40
+ return code;
41
+ }
42
+ async function runPublish(deps, opts, onCommitted) {
43
+ // The binding lookup is local. Its directory is the project root: the settings
44
+ // file is read there and a relative output or --dist resolves against it,
45
+ // never against the working directory.
46
+ const binding = await deps.binding();
47
+ const root = binding?.dir ?? deps.cwd;
48
+ // Settings resolve first, before the sign-in gate and before any request.
49
+ let settings;
50
+ let hasFile;
51
+ try {
52
+ const file = await (deps.readSettings ?? readSettings)(root);
53
+ hasFile = file !== undefined;
54
+ settings = resolveSettings({ buildOutput: opts.distDir }, file, TEMPLATE_DEFAULTS, [
55
+ "build",
56
+ ]);
57
+ }
58
+ catch (err) {
59
+ if (err instanceof SettingsError) {
60
+ for (const l of settingsRefusal(err, "publish"))
61
+ deps.print(l);
62
+ return 1;
63
+ }
64
+ throw err;
65
+ }
66
+ // `undefined` → the template's own command; `null` → a file records none.
67
+ const buildCommand = settings.buildCommand?.value ?? (hasFile ? null : undefined);
24
68
  // The gate is LOCAL and NOTHING IS ATTEMPTED — no request, no partial upload.
25
69
  if (!(await deps.token())) {
26
70
  for (const l of copy.notSignedIn())
27
71
  deps.print(l);
28
- return 1;
72
+ return EXIT.NOT_AUTHENTICATED;
29
73
  }
30
- const binding = await deps.binding();
31
74
  if (!binding) {
32
75
  deps.print("✗ This folder isn't linked to a project.");
33
76
  deps.print("");
@@ -35,18 +78,34 @@ export async function publishCommand(deps, opts = {}) {
35
78
  deps.print(" vincentt create Register a new project for this folder");
36
79
  return 1;
37
80
  }
38
- const distDir = path.resolve(deps.cwd, opts.distDir ?? "dist");
81
+ const distDir = path.resolve(root, settings.buildOutput.value);
82
+ const output = outputLabelOf(root, distDir, settings.buildOutput.source);
39
83
  // Everything below the manifest build happens before a single request. The
40
84
  // creator learns about a too-large build in the same second they would
41
85
  // otherwise have started a ten-minute upload.
42
86
  let manifest;
43
87
  try {
44
- manifest = await buildManifest(distDir);
88
+ manifest = await buildManifest(distDir, { repoRoot: root });
45
89
  }
46
90
  catch (err) {
47
91
  if (err instanceof ManifestError) {
48
- for (const l of manifestErrorLines(err, distDir))
92
+ if (err.reason === "secret_path" || err.reason === "lfs_pointer") {
93
+ deps.print(copy.headerLine(await projectLabel(deps, binding.slug), await orgOf(deps)));
94
+ deps.print(copy.readingLine(err.stats?.files ?? 0, err.stats?.bytes ?? 0, output));
95
+ const lines = err.reason === "secret_path"
96
+ ? copy.secretFiles(err.paths, output)
97
+ : copy.lfsPointers(err.paths, output, buildCommand ?? undefined);
98
+ for (const l of lines)
99
+ deps.print(l);
100
+ return 1;
101
+ }
102
+ const origin = {
103
+ value: settings.buildOutput.value,
104
+ source: settings.buildOutput.source,
105
+ };
106
+ for (const l of manifestErrorLines(err, distDir, { root, origin, buildCommand })) {
49
107
  deps.print(l);
108
+ }
50
109
  return 1;
51
110
  }
52
111
  throw err;
@@ -54,13 +113,13 @@ export async function publishCommand(deps, opts = {}) {
54
113
  const violation = checkBounds(manifest);
55
114
  if (violation) {
56
115
  deps.print(copy.headerLine(await projectLabel(deps, binding.slug), await orgOf(deps)));
57
- deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes));
116
+ deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes, output));
58
117
  for (const l of copy.tooLarge(violation))
59
118
  deps.print(l);
60
119
  return 1;
61
120
  }
62
121
  const cfg = await deps.config();
63
- const fetchImpl = deps.fetchImpl ?? fetch;
122
+ const fetchImpl = deps.fetchImpl ?? apiFetch;
64
123
  // ONE call before the checklist. liveVersion and livePublishedAt ride it, so
65
124
  // the republish line costs no second round trip.
66
125
  let project;
@@ -68,6 +127,8 @@ export async function publishCommand(deps, opts = {}) {
68
127
  project = await readProject(cfg, binding.projectId, fetchImpl);
69
128
  }
70
129
  catch (err) {
130
+ if (err instanceof AuthExpiredError)
131
+ return authExpired(deps);
71
132
  if (err instanceof PublishApiError && err.status === 404) {
72
133
  deps.print(`✗ ${err.message}`);
73
134
  deps.print("");
@@ -81,6 +142,20 @@ export async function publishCommand(deps, opts = {}) {
81
142
  const isFirstPublish = project.firstPublishedAt === null;
82
143
  const nextVersion = (project.highestVersion ?? 0) + 1;
83
144
  const orgName = await orgOf(deps);
145
+ // Refused here, before the prompt and before a byte moves: the api refuses
146
+ // every publish of a held project, and it used to do so only at commit.
147
+ if (project.publishState === "held_by_platform") {
148
+ deps.print("");
149
+ deps.print(copy.headerLine(project.name, orgName));
150
+ deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes, output));
151
+ for (const l of copy.heldByPlatform(address, {
152
+ orgSlug: (await deps.orgSlug?.()) || undefined,
153
+ consoleUrl: deps.consoleUrl,
154
+ })) {
155
+ deps.print(l);
156
+ }
157
+ return 1;
158
+ }
84
159
  // A non-interactive stdin with no --yes must REFUSE rather than assume yes.
85
160
  if (!opts.yes && !deps.isTTY) {
86
161
  deps.print("");
@@ -92,7 +167,9 @@ export async function publishCommand(deps, opts = {}) {
92
167
  deps.print("");
93
168
  return 1;
94
169
  }
95
- const findings = await classifyDist(distDir, new Set(manifest.files.map((f) => f.path)), (abs) => fs.readFile(abs, "utf8"), (dir, rel) => path.join(dir, ...rel.split("/")), { expectedHost: address });
170
+ const manifestPaths = new Set(manifest.files.map((f) => f.path));
171
+ const findings = await classifyDist(distDir, manifestPaths, (abs) => fs.readFile(abs, "utf8"), (dir, rel) => path.join(dir, ...rel.split("/")), { expectedHost: address });
172
+ const rootAbsolute = rootAbsoluteFindings(await readHtmlFiles(distDir, manifest), manifestPaths);
96
173
  const checklistInput = {
97
174
  projectName: project.name,
98
175
  orgName,
@@ -101,6 +178,8 @@ export async function publishCommand(deps, opts = {}) {
101
178
  totalBytes: manifest.totalBytes,
102
179
  nextVersion,
103
180
  findings,
181
+ output,
182
+ rootAbsolute,
104
183
  };
105
184
  let promptLine;
106
185
  let defaultYes;
@@ -118,6 +197,16 @@ export async function publishCommand(deps, opts = {}) {
118
197
  // accidental Enter must do nothing.
119
198
  defaultYes = false;
120
199
  }
200
+ else if (project.publishState !== "live") {
201
+ // not_serving, or a state newer than this CLI. Only `live` may say "Live at".
202
+ const known = project.publishState === "not_serving";
203
+ for (const l of copy.notServingChecklist(checklistInput, known))
204
+ deps.print(l);
205
+ promptLine = known
206
+ ? copy.republishPrompt(nextVersion)
207
+ : copy.unknownStatePrompt(nextVersion);
208
+ defaultYes = known;
209
+ }
121
210
  else {
122
211
  for (const l of copy.republishChecklist({
123
212
  ...checklistInput,
@@ -153,12 +242,18 @@ export async function publishCommand(deps, opts = {}) {
153
242
  address,
154
243
  liveVersion: project.liveVersion ?? undefined,
155
244
  fetchImpl,
245
+ // The S3 leg sets its own per-file deadline, sized to the file.
246
+ uploadFetch: deps.fetchImpl ?? ((input, init) => globalThis.fetch(input, init)),
156
247
  builtWith,
157
248
  org: {
158
249
  name: orgName || undefined,
159
250
  slug: (await deps.orgSlug?.()) || undefined,
160
251
  consoleUrl: deps.consoleUrl,
161
252
  },
253
+ probeFetch: deps.probeFetch ??
254
+ deps.fetchImpl ??
255
+ ((input, init) => globalThis.fetch(input, init)),
256
+ onCommitted,
162
257
  });
163
258
  }
164
259
  async function runUpload(deps, cfg, i) {
@@ -167,6 +262,8 @@ async function runUpload(deps, cfg, i) {
167
262
  opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
168
263
  }
169
264
  catch (err) {
265
+ if (err instanceof AuthExpiredError)
266
+ return authExpired(deps);
170
267
  for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
171
268
  deps.print(l);
172
269
  return 1;
@@ -185,7 +282,7 @@ async function runUpload(deps, cfg, i) {
185
282
  targets: opened.uploads,
186
283
  manifest: i.manifest.files,
187
284
  onProgress: (p) => progress.update(p),
188
- fetchImpl: i.fetchImpl,
285
+ fetchImpl: i.uploadFetch,
189
286
  });
190
287
  }
191
288
  catch (err) {
@@ -201,8 +298,8 @@ async function runUpload(deps, cfg, i) {
201
298
  deps.print(`✗ Uploading ${failedPath} failed.`);
202
299
  deps.print("");
203
300
  deps.print(i.liveVersion === undefined
204
- ? ` Your live address is unchanged — ${i.address}.`
205
- : ` Your live address is unchanged — ${i.address} is still serving v${i.liveVersion}.`);
301
+ ? ` Your live address is unchanged: ${i.address}.`
302
+ : ` Your live address is unchanged: ${i.address} is still serving v${i.liveVersion}.`);
206
303
  deps.print("");
207
304
  deps.print(" Publish again: vincentt publish");
208
305
  deps.print("");
@@ -214,30 +311,41 @@ async function runUpload(deps, cfg, i) {
214
311
  return commitWithHealing(deps, cfg, i, opened.uploadId);
215
312
  }
216
313
  /**
217
- * The commit, with the healing retry. A 502 publish_flip_failed is retried
218
- * automatically — up to 3 attempts, 2s apart — because commit is idempotent on
219
- * uploadId and the retry is exactly what the healing path was built for.
220
- * Surfacing a 502 to a creator whose bytes are all safely stored and whose
221
- * publish will succeed on the next call would be manufacturing an incident.
314
+ * The commit, with the healing retry. Commit is idempotent on uploadId, so two
315
+ * failures are retried automatically, up to 3 attempts with the wait doubling
316
+ * from 2s: a 502 publish_flip_failed (what the healing path was built for), and
317
+ * a request whose answer never arrived (a dropped connection, a deadline, a 2xx
318
+ * body that is not a commit response). Surfacing either to a creator whose bytes
319
+ * are all safely stored would be manufacturing an incident.
320
+ *
321
+ * When the last attempt's answer never arrived, the outcome is unknown and the
322
+ * copy says so: the version may already be live.
222
323
  */
223
324
  async function commitWithHealing(deps, cfg, i, uploadId) {
224
325
  let announcedSlow = false;
225
326
  for (let attempt = 1; attempt <= COMMIT_RETRIES; attempt++) {
226
327
  try {
227
328
  const result = await commitUpload(cfg, i.projectId, uploadId, i.fetchImpl);
228
- for (const l of copy.liveAt(result.liveUrl, result.version))
229
- deps.print(l);
329
+ await reportVisibility(deps, i, result);
230
330
  return 0;
231
331
  }
232
332
  catch (err) {
233
- if (!(err instanceof PublishApiError))
234
- throw err;
333
+ // The token can lapse during a long upload. The files are stored and the
334
+ // live address is unchanged, so signing in and publishing again is safe.
335
+ if (err instanceof AuthExpiredError)
336
+ return authExpired(deps);
337
+ const unanswered = err instanceof MalformedCommitResponse || isTransportError(err);
235
338
  // The plan limit is a 409 and falls past this branch deliberately. It is
236
339
  // the interlock refusing, not the flip failing, and retrying it three
237
340
  // times would be the retry loop the refusal exists to stop.
238
- if (err.code === "publish_flip_failed" || err.status === 502) {
341
+ const flipFailed = err instanceof PublishApiError &&
342
+ (err.code === "publish_flip_failed" || err.status === 502);
343
+ if (unanswered || flipFailed) {
239
344
  if (attempt === COMMIT_RETRIES) {
240
- for (const l of copy.flipFailed(i.address, i.liveVersion))
345
+ const lines = unanswered
346
+ ? copy.commitOutcomeUnknown(i.address, i.liveVersion)
347
+ : copy.flipFailed(i.address, i.liveVersion);
348
+ for (const l of lines)
241
349
  deps.print(l);
242
350
  return 1;
243
351
  }
@@ -247,9 +355,11 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
247
355
  deps.print(copy.takingLonger());
248
356
  announcedSlow = true;
249
357
  }
250
- await deps.sleep(COMMIT_RETRY_DELAY_MS);
358
+ await deps.sleep(COMMIT_RETRY_DELAY_MS * 2 ** (attempt - 1));
251
359
  continue;
252
360
  }
361
+ if (!(err instanceof PublishApiError))
362
+ throw err;
253
363
  for (const l of commitErrorLines(err, i.address, i.liveVersion, i.org))
254
364
  deps.print(l);
255
365
  return 1;
@@ -257,6 +367,32 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
257
367
  }
258
368
  return 1;
259
369
  }
370
+ /**
371
+ * "Live" is printed only once the public address serves this commit's index.html.
372
+ * Running out the wait is still exit 0: the commit happened, only the edge lags.
373
+ */
374
+ async function reportVisibility(deps, i, result) {
375
+ const index = i.manifest.files.find((f) => f.path === "index.html");
376
+ const { visible, waitedMs } = index
377
+ ? await waitUntilServing(result.liveUrl, index.sha256, {
378
+ fetchImpl: i.probeFetch,
379
+ now: deps.now,
380
+ sleep: deps.sleep,
381
+ onWaiting: () => deps.print(copy.propagating(result.liveUrl, result.version)),
382
+ })
383
+ : { visible: true, waitedMs: 0 };
384
+ const lines = visible
385
+ ? copy.liveAt(result.liveUrl, result.version)
386
+ : copy.notYetVisible(result.liveUrl, result.version);
387
+ for (const l of lines)
388
+ deps.print(l);
389
+ i.onCommitted({
390
+ state: visible ? "live" : "committed_not_visible",
391
+ version: result.version,
392
+ liveUrl: result.liveUrl,
393
+ waitedMs,
394
+ });
395
+ }
260
396
  /**
261
397
  * The plan-limit refusal, from the detail the API sends. It is reachable from
262
398
  * open AND from commit, because the interlock lives at the port and every flip
@@ -283,6 +419,10 @@ function commitErrorLines(err, address, liveVersion, org) {
283
419
  switch (err.code) {
284
420
  case "unavailable_plan_limit":
285
421
  return planLimitLines(err, org);
422
+ case "published_limit":
423
+ // Two opens admitted in the same org race to go live; commit re-checks and
424
+ // refuses the loser, whose version the api keeps stored.
425
+ return publishedLimitRefused(resolveCountLimit(err.detail, org), "commit");
286
426
  case "publish_expired":
287
427
  return copy.publishExpired(address, liveVersion);
288
428
  case "publish_contended":
@@ -297,8 +437,8 @@ function commitErrorLines(err, address, liveVersion, org) {
297
437
  `✗ ${err.message}`,
298
438
  "",
299
439
  liveVersion === undefined
300
- ? ` Your live address is unchanged — ${address}.`
301
- : ` Your live address is unchanged — ${address} is still serving v${liveVersion}.`,
440
+ ? ` Your live address is unchanged: ${address}.`
441
+ : ` Your live address is unchanged: ${address} is still serving v${liveVersion}.`,
302
442
  "",
303
443
  " Publish again: vincentt publish",
304
444
  "",
@@ -318,8 +458,18 @@ function openErrorLines(err, address, liveVersion, org) {
318
458
  if (err.code === "unavailable_plan_limit") {
319
459
  return planLimitLines(err, org);
320
460
  }
461
+ if (err.code === "published_limit") {
462
+ return publishedLimitRefused(resolveCountLimit(err.detail, org), "open");
463
+ }
464
+ // Refused only at open, before a presigned URL is minted.
465
+ if (err.code === "storage_ceiling") {
466
+ // It takes no detail. The body is `{"limit":"storage"}` and names nothing
467
+ // else by design — the ceiling is an abuse ceiling, not a plan limit.
468
+ return storageCeilingRefused();
469
+ }
321
470
  if (err.code === "publish_too_large") {
322
- return copy.tooLargeFromServer(err.detail.observed ?? 0, err.detail.limit ?? 0);
471
+ // `limit` is the MAGNITUDE on this code. See ApiErrorDetail.limit.
472
+ return copy.tooLargeFromServer(err.detail.observed ?? 0, numericLimit(err.detail.limit));
323
473
  }
324
474
  if (err.code === "rate_limited") {
325
475
  return copy.rateLimited(err.retryAfterSec ?? 60);
@@ -332,20 +482,62 @@ function openErrorLines(err, address, liveVersion, org) {
332
482
  `✗ ${err.message}`,
333
483
  "",
334
484
  liveVersion === undefined
335
- ? ` Your live address is unchanged — ${address}.`
336
- : ` Your live address is unchanged — ${address} is still serving v${liveVersion}.`,
485
+ ? ` Your live address is unchanged: ${address}.`
486
+ : ` Your live address is unchanged: ${address} is still serving v${liveVersion}.`,
337
487
  "",
338
488
  " Nothing was uploaded.",
339
489
  "",
340
490
  ];
341
491
  }
342
- function manifestErrorLines(err, distDir) {
492
+ /**
493
+ * `detail.limit` on a code whose `limit` is a MAGNITUDE. A string there is the
494
+ * axis-name meaning arriving on a body that should not carry it, and it reads as
495
+ * 0 rather than as `NaN` — a bound printed as "NaN MB" is worse than one printed
496
+ * as a figure the copy already handles.
497
+ */
498
+ function numericLimit(limit) {
499
+ return typeof limit === "number" ? limit : 0;
500
+ }
501
+ function manifestErrorLines(err, distDir, ctx) {
343
502
  if (err.reason === "no_dist")
344
- return copy.noDist(distDir);
503
+ return copy.noDist(distDir, ctx.buildCommand);
345
504
  if (err.reason === "no_index_html")
346
- return copy.noIndexHtml(distDir);
505
+ return copy.noIndexHtml(distDir, ctx.buildCommand);
506
+ if (err.reason === "output_is_repo_root")
507
+ return copy.outputIsRoot(ctx.origin);
508
+ if (err.reason === "output_in_hidden") {
509
+ return copy.outputInHidden(path.relative(ctx.root, distDir).split(path.sep).join("/"), err.offendingPath);
510
+ }
511
+ if (err.reason === "output_outside_repo") {
512
+ return copy.outputOutside(path.relative(ctx.root, distDir).split(path.sep).join("/"));
513
+ }
347
514
  return copy.badPath(err.reason, err.offendingPath);
348
515
  }
516
+ /** `dist/` for the default; the resolved folder and its source otherwise. */
517
+ function outputLabelOf(root, distDir, source) {
518
+ const rel = path.relative(root, distDir).split(path.sep).join("/");
519
+ const folder = `${rel || "."}/`;
520
+ const sourceNote = source === "vincentt.json"
521
+ ? "(from vincentt.json)"
522
+ : source === "flag"
523
+ ? "(from --dist)"
524
+ : "";
525
+ return { folder, sourceNote };
526
+ }
527
+ /** Every `.html` in the manifest, for the root-absolute reference check. */
528
+ async function readHtmlFiles(distDir, manifest) {
529
+ const out = [];
530
+ for (const f of manifest.files) {
531
+ if (!f.path.toLowerCase().endsWith(".html"))
532
+ continue;
533
+ const html = await fs
534
+ .readFile(path.join(distDir, ...f.path.split("/")), "utf8")
535
+ .catch(() => undefined);
536
+ if (html !== undefined)
537
+ out.push({ file: f.path, html });
538
+ }
539
+ return out;
540
+ }
349
541
  /**
350
542
  * The address as a creator reads it: the host, never the scheme. `liveUrl` is
351
543
  * server-computed (the apex differs per environment), so it is the authority and
@@ -361,6 +553,12 @@ function addressOf(project) {
361
553
  return project.liveUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
362
554
  }
363
555
  }
556
+ /** The api refused the token: the sign-in remedy, and the not-signed-in exit code. */
557
+ function authExpired(deps) {
558
+ for (const l of authExpiredLines())
559
+ deps.print(l);
560
+ return EXIT.NOT_AUTHENTICATED;
561
+ }
364
562
  async function orgOf(deps) {
365
563
  return (await deps.orgName?.()) ?? "";
366
564
  }
@@ -1,5 +1,7 @@
1
1
  import type { FileFindings } from "./classify.js";
2
2
  import type { BoundViolation } from "./bounds.js";
3
+ import type { RootAbsoluteFinding } from "./fit.js";
4
+ import type { Offender } from "./manifest.js";
3
5
  export declare function truncate(value: string, max?: number): string;
4
6
  /** Bytes as the creator reads them. Decimal MB, matching what a build tool prints. */
5
7
  export declare function formatBytes(bytes: number): string;
@@ -24,7 +26,7 @@ export declare function relativeTime(iso: string, now: number): string;
24
26
  * A FINDING IS NEVER A COUNT. No "2 issues found", no summary line, and no exit
25
27
  * code that varies with findings.
26
28
  */
27
- export declare function findingLines(byFile: FileFindings[], withSubtitle: boolean): string[];
29
+ export declare function findingLines(byFile: FileFindings[], withSubtitle: boolean, folder?: string): string[];
28
30
  /**
29
31
  * Mandatory and always present. A screen with a red ✗ on it reads as a gate
30
32
  * unless it explicitly says otherwise, and a creator who believes the platform
@@ -40,8 +42,29 @@ export interface ChecklistInput {
40
42
  totalBytes: number;
41
43
  nextVersion: number;
42
44
  findings: FileFindings[];
45
+ /** Absent for the template's default `dist/`. */
46
+ output?: OutputLabel;
47
+ /** Root-absolute references that do not resolve. The section is absent when empty. */
48
+ rootAbsolute?: RootAbsoluteFinding[];
43
49
  }
44
- export declare function readingLine(fileCount: number, totalBytes: number): string;
50
+ /**
51
+ * The `Paths from the root` section: a second section of the checklist, same
52
+ * geometry as the link preview. Absent when there are no findings, never an
53
+ * empty heading. One explanation for the whole section.
54
+ */
55
+ export declare function pathsFromRootLines(findings: RootAbsoluteFinding[] | undefined, folder?: string): string[];
56
+ /**
57
+ * The output folder as the creator reads it, `dist/` for the template. The
58
+ * source is appended only when it is not the default, so the template's
59
+ * transcript is unchanged.
60
+ */
61
+ export interface OutputLabel {
62
+ /** Project-relative, with a trailing slash: `dist/`, `build/`. */
63
+ folder: string;
64
+ /** `(from vincentt.json)` / `(from --dist)`, or empty for the default. */
65
+ sourceNote: string;
66
+ }
67
+ export declare function readingLine(fileCount: number, totalBytes: number, output?: OutputLabel): string;
45
68
  export declare function headerLine(projectName: string, orgName: string): string;
46
69
  /**
47
70
  * Flow L22 — the single most consequential screen in the product, and its ORDER
@@ -88,6 +111,28 @@ export declare function republishPrompt(version: number): string;
88
111
  */
89
112
  export declare function offlineChecklist(i: ChecklistInput): string[];
90
113
  export declare function offlinePrompt(version: number): string;
114
+ /**
115
+ * The address has published before but serves nothing now (`not_serving`), or
116
+ * the api reports a state this CLI does not know. Neither may say "Live at":
117
+ * the creator would share an address that answers with nothing.
118
+ */
119
+ export declare function notServingChecklist(i: ChecklistInput, stateKnown: boolean): string[];
120
+ /** `[y/N]`: the CLI cannot say what this publish changes, so Enter does nothing. */
121
+ export declare function unknownStatePrompt(version: number): string;
122
+ /**
123
+ * The project is `held_by_platform`: Vincentt took the org's addresses offline
124
+ * over its traffic allowance, and the api refuses every publish until they are
125
+ * restored. Refused BEFORE the upload, because uploading a whole build only to
126
+ * be refused at commit is the retry loop this copy exists to stop.
127
+ *
128
+ * The project read carries no figures, so this names none. It shares
129
+ * planLimitRefused's voice: the platform is the actor, the remedy is outside
130
+ * the CLI, and nothing was lost.
131
+ */
132
+ export declare function heldByPlatform(address: string, org?: {
133
+ orgSlug?: string;
134
+ consoleUrl?: string;
135
+ }): string[];
91
136
  /**
92
137
  * f1's line verbatim, with f4's reason. Note what is ABSENT, deliberately: f3's
93
138
  * "your local dev loop doesn't need one" paragraph. It is the right copy for
@@ -110,9 +155,24 @@ export declare function notSignedIn(): string[];
110
155
  * have.
111
156
  */
112
157
  export declare function tooLarge(v: BoundViolation): string[];
113
- /** No dist at all, or no index.html at its root. Zero network calls. */
114
- export declare function noDist(distDir: string): string[];
115
- export declare function noIndexHtml(distDir: string): string[];
158
+ export declare function noDist(distDir: string, buildCommand?: string | null): string[];
159
+ export declare function noIndexHtml(distDir: string, buildCommand?: string | null): string[];
160
+ /** How the output was named, for the leak refusals' "why" sentence. */
161
+ export interface OutputOrigin {
162
+ /** The value as given: `build.output` or `--dist`. */
163
+ value: string;
164
+ source: "vincentt.json" | "flag" | "default";
165
+ }
166
+ /** Flow U1 — the output is the project folder itself. */
167
+ export declare function outputIsRoot(o: OutputOrigin): string[];
168
+ /** Flow U1 — the output lies outside the project. `shown` is project-relative. */
169
+ export declare function outputOutside(shown: string): string[];
170
+ /** Flow U1 — the output is, or sits inside, `.git` or `.vincentt`. `shown` is project-relative. */
171
+ export declare function outputInHidden(shown: string, hidden: string): string[];
172
+ /** Flow U1 — hidden names or symlinks that would leak. After the header and reading line. */
173
+ export declare function secretFiles(paths: Offender[], output?: OutputLabel): string[];
174
+ /** Flow U1 — Git LFS pointer files. The recorded build.command, verbatim, when there is one. */
175
+ export declare function lfsPointers(paths: Offender[], output?: OutputLabel, buildCommand?: string): string[];
116
176
  /** A local path rule the creator's own build broke. Local, before any request. */
117
177
  export declare function badPath(reason: string, offending: string): string[];
118
178
  /** The one redrawn line in f4. A bounded progress bar with a known total is not a stream. */
@@ -130,6 +190,13 @@ export declare function finishing(): string;
130
190
  * page, decide publish is broken, and publish again.
131
191
  */
132
192
  export declare function liveAt(liveUrl: string, version: number): string[];
193
+ /** Prints once the commit succeeded and the address is still serving the previous bytes. */
194
+ export declare function propagating(liveUrl: string, version: number): string;
195
+ /**
196
+ * The wait ran out. The publish DID commit, so this is a success, and it says so
197
+ * plainly: a creator told "not yet" without that will publish again.
198
+ */
199
+ export declare function notYetVisible(liveUrl: string, version: number): string[];
133
200
  /** Prints only if a retry is needed. It says nothing about a failure, because nothing has failed yet. */
134
201
  export declare function takingLonger(): string;
135
202
  export interface PlanLimitInput {
@@ -170,6 +237,12 @@ export declare function planLimitRefused(i: PlanLimitInput): string[];
170
237
  export declare function publishExpired(address: string, liveVersion?: number): string[];
171
238
  /** All three retries failed. "Nothing was lost" is the true and load-bearing sentence. */
172
239
  export declare function flipFailed(address: string, liveVersion?: number): string[];
240
+ /**
241
+ * The commit's answer never arrived (the connection dropped or the api stopped
242
+ * answering), so whether the address switched is unknown. It says so, rather than
243
+ * claiming either outcome, and names the one check that settles it.
244
+ */
245
+ export declare function commitOutcomeUnknown(address: string, liveVersion?: number): string[];
173
246
  /**
174
247
  * Two sentences, no explanation of version allocation. The real case is a
175
248
  * teammate and an agent publishing in the same second, and "someone else