@vincentt-xr/cli 0.17.0 → 0.18.1

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 +31 -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 +27 -0
  71. package/dist/publish/command.js +261 -50
  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 +59 -5
  81. package/dist/publish/upload.js +138 -30
  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
@@ -10,25 +10,69 @@
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
- import { UploadFailed, uploadAll } from "./upload.js";
18
+ import { UploadExpired, UploadFailed, uploadAll } from "./upload.js";
19
+ /** Re-opens after an expired upload link, per publish, before giving up. */
20
+ export const MAX_REOPENS = 2;
21
+ import { waitUntilServing } from "./visibility.js";
19
22
  import * as copy from "./copy.js";
20
23
  import { publishedLimitRefused, resolveCountLimit, storageCeilingRefused, } from "../shared/countLimit.js";
24
+ import { AuthExpiredError, authExpiredLines } from "../shared/authError.js";
25
+ import { EXIT } from "../lib/exitCodes.js";
26
+ import { apiFetch, isTransportError } from "../shared/http.js";
21
27
  import { resolveBuiltWith } from "../platform/resolve.js";
28
+ import { readSettings, resolveSettings, SettingsError, TEMPLATE_DEFAULTS, } from "../lib/settings.js";
29
+ import { settingsRefusal } from "../lib/settingsCopy.js";
30
+ import { rootAbsoluteFindings } from "./fit.js";
22
31
  const COMMIT_RETRIES = 3;
23
32
  const COMMIT_RETRY_DELAY_MS = 2000;
24
33
  export async function publishCommand(deps, opts = {}) {
34
+ let committed;
35
+ const code = await runPublish(deps, opts, (r) => {
36
+ committed = r;
37
+ });
38
+ if (opts.json) {
39
+ const report = code === 0 && committed ? committed : { state: "failed", exitCode: code };
40
+ (deps.emit ?? deps.print)(JSON.stringify(report));
41
+ }
42
+ return code;
43
+ }
44
+ async function runPublish(deps, opts, onCommitted) {
45
+ // The binding lookup is local. Its directory is the project root: the settings
46
+ // file is read there and a relative output or --dist resolves against it,
47
+ // never against the working directory.
48
+ const binding = await deps.binding();
49
+ const root = binding?.dir ?? deps.cwd;
50
+ // Settings resolve first, before the sign-in gate and before any request.
51
+ let settings;
52
+ let hasFile;
53
+ try {
54
+ const file = await (deps.readSettings ?? readSettings)(root);
55
+ hasFile = file !== undefined;
56
+ settings = resolveSettings({ buildOutput: opts.distDir }, file, TEMPLATE_DEFAULTS, [
57
+ "build",
58
+ ]);
59
+ }
60
+ catch (err) {
61
+ if (err instanceof SettingsError) {
62
+ for (const l of settingsRefusal(err, "publish"))
63
+ deps.print(l);
64
+ return 1;
65
+ }
66
+ throw err;
67
+ }
68
+ // `undefined` → the template's own command; `null` → a file records none.
69
+ const buildCommand = settings.buildCommand?.value ?? (hasFile ? null : undefined);
25
70
  // The gate is LOCAL and NOTHING IS ATTEMPTED — no request, no partial upload.
26
71
  if (!(await deps.token())) {
27
72
  for (const l of copy.notSignedIn())
28
73
  deps.print(l);
29
- return 1;
74
+ return EXIT.NOT_AUTHENTICATED;
30
75
  }
31
- const binding = await deps.binding();
32
76
  if (!binding) {
33
77
  deps.print("✗ This folder isn't linked to a project.");
34
78
  deps.print("");
@@ -36,18 +80,34 @@ export async function publishCommand(deps, opts = {}) {
36
80
  deps.print(" vincentt create Register a new project for this folder");
37
81
  return 1;
38
82
  }
39
- const distDir = path.resolve(deps.cwd, opts.distDir ?? "dist");
83
+ const distDir = path.resolve(root, settings.buildOutput.value);
84
+ const output = outputLabelOf(root, distDir, settings.buildOutput.source);
40
85
  // Everything below the manifest build happens before a single request. The
41
86
  // creator learns about a too-large build in the same second they would
42
87
  // otherwise have started a ten-minute upload.
43
88
  let manifest;
44
89
  try {
45
- manifest = await buildManifest(distDir);
90
+ manifest = await buildManifest(distDir, { repoRoot: root });
46
91
  }
47
92
  catch (err) {
48
93
  if (err instanceof ManifestError) {
49
- for (const l of manifestErrorLines(err, distDir))
94
+ if (err.reason === "secret_path" || err.reason === "lfs_pointer") {
95
+ deps.print(copy.headerLine(await projectLabel(deps, binding.slug), await orgOf(deps)));
96
+ deps.print(copy.readingLine(err.stats?.files ?? 0, err.stats?.bytes ?? 0, output));
97
+ const lines = err.reason === "secret_path"
98
+ ? copy.secretFiles(err.paths, output)
99
+ : copy.lfsPointers(err.paths, output, buildCommand ?? undefined);
100
+ for (const l of lines)
101
+ deps.print(l);
102
+ return 1;
103
+ }
104
+ const origin = {
105
+ value: settings.buildOutput.value,
106
+ source: settings.buildOutput.source,
107
+ };
108
+ for (const l of manifestErrorLines(err, distDir, { root, origin, buildCommand })) {
50
109
  deps.print(l);
110
+ }
51
111
  return 1;
52
112
  }
53
113
  throw err;
@@ -55,13 +115,13 @@ export async function publishCommand(deps, opts = {}) {
55
115
  const violation = checkBounds(manifest);
56
116
  if (violation) {
57
117
  deps.print(copy.headerLine(await projectLabel(deps, binding.slug), await orgOf(deps)));
58
- deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes));
118
+ deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes, output));
59
119
  for (const l of copy.tooLarge(violation))
60
120
  deps.print(l);
61
121
  return 1;
62
122
  }
63
123
  const cfg = await deps.config();
64
- const fetchImpl = deps.fetchImpl ?? fetch;
124
+ const fetchImpl = deps.fetchImpl ?? apiFetch;
65
125
  // ONE call before the checklist. liveVersion and livePublishedAt ride it, so
66
126
  // the republish line costs no second round trip.
67
127
  let project;
@@ -69,6 +129,8 @@ export async function publishCommand(deps, opts = {}) {
69
129
  project = await readProject(cfg, binding.projectId, fetchImpl);
70
130
  }
71
131
  catch (err) {
132
+ if (err instanceof AuthExpiredError)
133
+ return authExpired(deps);
72
134
  if (err instanceof PublishApiError && err.status === 404) {
73
135
  deps.print(`✗ ${err.message}`);
74
136
  deps.print("");
@@ -82,6 +144,20 @@ export async function publishCommand(deps, opts = {}) {
82
144
  const isFirstPublish = project.firstPublishedAt === null;
83
145
  const nextVersion = (project.highestVersion ?? 0) + 1;
84
146
  const orgName = await orgOf(deps);
147
+ // Refused here, before the prompt and before a byte moves: the api refuses
148
+ // every publish of a held project, and it used to do so only at commit.
149
+ if (project.publishState === "held_by_platform") {
150
+ deps.print("");
151
+ deps.print(copy.headerLine(project.name, orgName));
152
+ deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes, output));
153
+ for (const l of copy.heldByPlatform(address, {
154
+ orgSlug: (await deps.orgSlug?.()) || undefined,
155
+ consoleUrl: deps.consoleUrl,
156
+ })) {
157
+ deps.print(l);
158
+ }
159
+ return 1;
160
+ }
85
161
  // A non-interactive stdin with no --yes must REFUSE rather than assume yes.
86
162
  if (!opts.yes && !deps.isTTY) {
87
163
  deps.print("");
@@ -93,7 +169,9 @@ export async function publishCommand(deps, opts = {}) {
93
169
  deps.print("");
94
170
  return 1;
95
171
  }
96
- 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 });
172
+ const manifestPaths = new Set(manifest.files.map((f) => f.path));
173
+ const findings = await classifyDist(distDir, manifestPaths, (abs) => fs.readFile(abs, "utf8"), (dir, rel) => path.join(dir, ...rel.split("/")), { expectedHost: address });
174
+ const rootAbsolute = rootAbsoluteFindings(await readHtmlFiles(distDir, manifest), manifestPaths);
97
175
  const checklistInput = {
98
176
  projectName: project.name,
99
177
  orgName,
@@ -102,6 +180,8 @@ export async function publishCommand(deps, opts = {}) {
102
180
  totalBytes: manifest.totalBytes,
103
181
  nextVersion,
104
182
  findings,
183
+ output,
184
+ rootAbsolute,
105
185
  };
106
186
  let promptLine;
107
187
  let defaultYes;
@@ -119,6 +199,16 @@ export async function publishCommand(deps, opts = {}) {
119
199
  // accidental Enter must do nothing.
120
200
  defaultYes = false;
121
201
  }
202
+ else if (project.publishState !== "live") {
203
+ // not_serving, or a state newer than this CLI. Only `live` may say "Live at".
204
+ const known = project.publishState === "not_serving";
205
+ for (const l of copy.notServingChecklist(checklistInput, known))
206
+ deps.print(l);
207
+ promptLine = known
208
+ ? copy.republishPrompt(nextVersion)
209
+ : copy.unknownStatePrompt(nextVersion);
210
+ defaultYes = known;
211
+ }
122
212
  else {
123
213
  for (const l of copy.republishChecklist({
124
214
  ...checklistInput,
@@ -154,23 +244,35 @@ export async function publishCommand(deps, opts = {}) {
154
244
  address,
155
245
  liveVersion: project.liveVersion ?? undefined,
156
246
  fetchImpl,
247
+ // The S3 leg sets its own per-file deadline, sized to the file.
248
+ uploadFetch: deps.fetchImpl ?? ((input, init) => globalThis.fetch(input, init)),
157
249
  builtWith,
158
250
  org: {
159
251
  name: orgName || undefined,
160
252
  slug: (await deps.orgSlug?.()) || undefined,
161
253
  consoleUrl: deps.consoleUrl,
162
254
  },
255
+ probeFetch: deps.probeFetch ??
256
+ deps.fetchImpl ??
257
+ ((input, init) => globalThis.fetch(input, init)),
258
+ onCommitted,
163
259
  });
164
260
  }
165
261
  async function runUpload(deps, cfg, i) {
262
+ const open = () => openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
263
+ const openFailed = (err) => {
264
+ if (err instanceof AuthExpiredError)
265
+ return authExpired(deps);
266
+ for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
267
+ deps.print(l);
268
+ return 1;
269
+ };
166
270
  let opened;
167
271
  try {
168
- opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
272
+ opened = await open();
169
273
  }
170
274
  catch (err) {
171
- for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
172
- deps.print(l);
173
- return 1;
275
+ return openFailed(err);
174
276
  }
175
277
  deps.print(copy.uploadStart(i.manifest.files.length));
176
278
  const progress = createProgress({
@@ -180,30 +282,59 @@ async function runUpload(deps, cfg, i) {
180
282
  now: deps.now,
181
283
  });
182
284
  const startedAt = deps.now();
183
- try {
184
- await uploadAll({
185
- distDir: i.distDir,
186
- targets: opened.uploads,
187
- manifest: i.manifest.files,
188
- onProgress: (p) => progress.update(p),
189
- fetchImpl: i.fetchImpl,
190
- });
285
+ let reopens = 0;
286
+ let uploadErr;
287
+ for (;;) {
288
+ try {
289
+ await uploadAll({
290
+ distDir: i.distDir,
291
+ targets: opened.uploads,
292
+ manifest: i.manifest.files,
293
+ onProgress: (p) => progress.update(p),
294
+ fetchImpl: i.uploadFetch,
295
+ sleep: deps.sleep,
296
+ });
297
+ break;
298
+ }
299
+ catch (err) {
300
+ if (!(err instanceof UploadExpired) || reopens >= MAX_REOPENS) {
301
+ uploadErr = err;
302
+ break;
303
+ }
304
+ }
305
+ // An expired link: re-open for fresh URLs. An api that resumes returns the
306
+ // same upload with only the files still missing; one that does not returns
307
+ // a new upload and every file is sent again.
308
+ reopens += 1;
309
+ try {
310
+ opened = await open();
311
+ }
312
+ catch (err) {
313
+ progress.finish();
314
+ return openFailed(err);
315
+ }
191
316
  }
192
- catch (err) {
317
+ if (uploadErr !== undefined) {
318
+ const err = uploadErr;
193
319
  progress.finish();
194
- await abandonUpload(cfg, i.projectId, opened.uploadId, i.fetchImpl);
320
+ // A retryable failure (the network, S3 5xx, links that kept expiring) keeps
321
+ // the upload open so the next `vincentt publish` resumes it and sends only
322
+ // what is missing. Anything else is abandoned.
323
+ const resumable = err instanceof UploadExpired || (err instanceof UploadFailed && err.retryable);
324
+ if (!resumable)
325
+ await abandonUpload(cfg, i.projectId, opened.uploadId, i.fetchImpl);
195
326
  if (err instanceof UploadFailed && err.badDigest) {
196
327
  for (const l of copy.badDigest(err.path, i.address, i.liveVersion))
197
328
  deps.print(l);
198
329
  return 1;
199
330
  }
200
- const failedPath = err instanceof UploadFailed ? err.path : "a file";
331
+ const failedPath = err instanceof UploadFailed || err instanceof UploadExpired ? err.path : "a file";
201
332
  deps.print("");
202
333
  deps.print(`✗ Uploading ${failedPath} failed.`);
203
334
  deps.print("");
204
335
  deps.print(i.liveVersion === undefined
205
- ? ` Your live address is unchanged — ${i.address}.`
206
- : ` Your live address is unchanged — ${i.address} is still serving v${i.liveVersion}.`);
336
+ ? ` Your live address is unchanged: ${i.address}.`
337
+ : ` Your live address is unchanged: ${i.address} is still serving v${i.liveVersion}.`);
207
338
  deps.print("");
208
339
  deps.print(" Publish again: vincentt publish");
209
340
  deps.print("");
@@ -215,30 +346,41 @@ async function runUpload(deps, cfg, i) {
215
346
  return commitWithHealing(deps, cfg, i, opened.uploadId);
216
347
  }
217
348
  /**
218
- * The commit, with the healing retry. A 502 publish_flip_failed is retried
219
- * automatically — up to 3 attempts, 2s apart — because commit is idempotent on
220
- * uploadId and the retry is exactly what the healing path was built for.
221
- * Surfacing a 502 to a creator whose bytes are all safely stored and whose
222
- * publish will succeed on the next call would be manufacturing an incident.
349
+ * The commit, with the healing retry. Commit is idempotent on uploadId, so two
350
+ * failures are retried automatically, up to 3 attempts with the wait doubling
351
+ * from 2s: a 502 publish_flip_failed (what the healing path was built for), and
352
+ * a request whose answer never arrived (a dropped connection, a deadline, a 2xx
353
+ * body that is not a commit response). Surfacing either to a creator whose bytes
354
+ * are all safely stored would be manufacturing an incident.
355
+ *
356
+ * When the last attempt's answer never arrived, the outcome is unknown and the
357
+ * copy says so: the version may already be live.
223
358
  */
224
359
  async function commitWithHealing(deps, cfg, i, uploadId) {
225
360
  let announcedSlow = false;
226
361
  for (let attempt = 1; attempt <= COMMIT_RETRIES; attempt++) {
227
362
  try {
228
363
  const result = await commitUpload(cfg, i.projectId, uploadId, i.fetchImpl);
229
- for (const l of copy.liveAt(result.liveUrl, result.version))
230
- deps.print(l);
364
+ await reportVisibility(deps, i, result);
231
365
  return 0;
232
366
  }
233
367
  catch (err) {
234
- if (!(err instanceof PublishApiError))
235
- throw err;
368
+ // The token can lapse during a long upload. The files are stored and the
369
+ // live address is unchanged, so signing in and publishing again is safe.
370
+ if (err instanceof AuthExpiredError)
371
+ return authExpired(deps);
372
+ const unanswered = err instanceof MalformedCommitResponse || isTransportError(err);
236
373
  // The plan limit is a 409 and falls past this branch deliberately. It is
237
374
  // the interlock refusing, not the flip failing, and retrying it three
238
375
  // times would be the retry loop the refusal exists to stop.
239
- if (err.code === "publish_flip_failed" || err.status === 502) {
376
+ const flipFailed = err instanceof PublishApiError &&
377
+ (err.code === "publish_flip_failed" || err.status === 502);
378
+ if (unanswered || flipFailed) {
240
379
  if (attempt === COMMIT_RETRIES) {
241
- for (const l of copy.flipFailed(i.address, i.liveVersion))
380
+ const lines = unanswered
381
+ ? copy.commitOutcomeUnknown(i.address, i.liveVersion)
382
+ : copy.flipFailed(i.address, i.liveVersion);
383
+ for (const l of lines)
242
384
  deps.print(l);
243
385
  return 1;
244
386
  }
@@ -248,9 +390,11 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
248
390
  deps.print(copy.takingLonger());
249
391
  announcedSlow = true;
250
392
  }
251
- await deps.sleep(COMMIT_RETRY_DELAY_MS);
393
+ await deps.sleep(COMMIT_RETRY_DELAY_MS * 2 ** (attempt - 1));
252
394
  continue;
253
395
  }
396
+ if (!(err instanceof PublishApiError))
397
+ throw err;
254
398
  for (const l of commitErrorLines(err, i.address, i.liveVersion, i.org))
255
399
  deps.print(l);
256
400
  return 1;
@@ -258,6 +402,32 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
258
402
  }
259
403
  return 1;
260
404
  }
405
+ /**
406
+ * "Live" is printed only once the public address serves this commit's index.html.
407
+ * Running out the wait is still exit 0: the commit happened, only the edge lags.
408
+ */
409
+ async function reportVisibility(deps, i, result) {
410
+ const index = i.manifest.files.find((f) => f.path === "index.html");
411
+ const { visible, waitedMs } = index
412
+ ? await waitUntilServing(result.liveUrl, index.sha256, {
413
+ fetchImpl: i.probeFetch,
414
+ now: deps.now,
415
+ sleep: deps.sleep,
416
+ onWaiting: () => deps.print(copy.propagating(result.liveUrl, result.version)),
417
+ })
418
+ : { visible: true, waitedMs: 0 };
419
+ const lines = visible
420
+ ? copy.liveAt(result.liveUrl, result.version)
421
+ : copy.notYetVisible(result.liveUrl, result.version);
422
+ for (const l of lines)
423
+ deps.print(l);
424
+ i.onCommitted({
425
+ state: visible ? "live" : "committed_not_visible",
426
+ version: result.version,
427
+ liveUrl: result.liveUrl,
428
+ waitedMs,
429
+ });
430
+ }
261
431
  /**
262
432
  * The plan-limit refusal, from the detail the API sends. It is reachable from
263
433
  * open AND from commit, because the interlock lives at the port and every flip
@@ -284,6 +454,10 @@ function commitErrorLines(err, address, liveVersion, org) {
284
454
  switch (err.code) {
285
455
  case "unavailable_plan_limit":
286
456
  return planLimitLines(err, org);
457
+ case "published_limit":
458
+ // Two opens admitted in the same org race to go live; commit re-checks and
459
+ // refuses the loser, whose version the api keeps stored.
460
+ return publishedLimitRefused(resolveCountLimit(err.detail, org), "commit");
287
461
  case "publish_expired":
288
462
  return copy.publishExpired(address, liveVersion);
289
463
  case "publish_contended":
@@ -298,8 +472,8 @@ function commitErrorLines(err, address, liveVersion, org) {
298
472
  `✗ ${err.message}`,
299
473
  "",
300
474
  liveVersion === undefined
301
- ? ` Your live address is unchanged — ${address}.`
302
- : ` Your live address is unchanged — ${address} is still serving v${liveVersion}.`,
475
+ ? ` Your live address is unchanged: ${address}.`
476
+ : ` Your live address is unchanged: ${address} is still serving v${liveVersion}.`,
303
477
  "",
304
478
  " Publish again: vincentt publish",
305
479
  "",
@@ -319,12 +493,10 @@ function openErrorLines(err, address, liveVersion, org) {
319
493
  if (err.code === "unavailable_plan_limit") {
320
494
  return planLimitLines(err, org);
321
495
  }
322
- // The two count/ceiling refusals of this change. Both land at OPEN — before a
323
- // presigned URL is minted and before a byte moves — which is why they are here
324
- // and not in commitErrorLines: neither is reachable from commit.
325
496
  if (err.code === "published_limit") {
326
- return publishedLimitRefused(resolveCountLimit(err.detail, org));
497
+ return publishedLimitRefused(resolveCountLimit(err.detail, org), "open");
327
498
  }
499
+ // Refused only at open, before a presigned URL is minted.
328
500
  if (err.code === "storage_ceiling") {
329
501
  // It takes no detail. The body is `{"limit":"storage"}` and names nothing
330
502
  // else by design — the ceiling is an abuse ceiling, not a plan limit.
@@ -345,8 +517,8 @@ function openErrorLines(err, address, liveVersion, org) {
345
517
  `✗ ${err.message}`,
346
518
  "",
347
519
  liveVersion === undefined
348
- ? ` Your live address is unchanged — ${address}.`
349
- : ` Your live address is unchanged — ${address} is still serving v${liveVersion}.`,
520
+ ? ` Your live address is unchanged: ${address}.`
521
+ : ` Your live address is unchanged: ${address} is still serving v${liveVersion}.`,
350
522
  "",
351
523
  " Nothing was uploaded.",
352
524
  "",
@@ -361,13 +533,46 @@ function openErrorLines(err, address, liveVersion, org) {
361
533
  function numericLimit(limit) {
362
534
  return typeof limit === "number" ? limit : 0;
363
535
  }
364
- function manifestErrorLines(err, distDir) {
536
+ function manifestErrorLines(err, distDir, ctx) {
365
537
  if (err.reason === "no_dist")
366
- return copy.noDist(distDir);
538
+ return copy.noDist(distDir, ctx.buildCommand);
367
539
  if (err.reason === "no_index_html")
368
- return copy.noIndexHtml(distDir);
540
+ return copy.noIndexHtml(distDir, ctx.buildCommand);
541
+ if (err.reason === "output_is_repo_root")
542
+ return copy.outputIsRoot(ctx.origin);
543
+ if (err.reason === "output_in_hidden") {
544
+ return copy.outputInHidden(path.relative(ctx.root, distDir).split(path.sep).join("/"), err.offendingPath);
545
+ }
546
+ if (err.reason === "output_outside_repo") {
547
+ return copy.outputOutside(path.relative(ctx.root, distDir).split(path.sep).join("/"));
548
+ }
369
549
  return copy.badPath(err.reason, err.offendingPath);
370
550
  }
551
+ /** `dist/` for the default; the resolved folder and its source otherwise. */
552
+ function outputLabelOf(root, distDir, source) {
553
+ const rel = path.relative(root, distDir).split(path.sep).join("/");
554
+ const folder = `${rel || "."}/`;
555
+ const sourceNote = source === "vincentt.json"
556
+ ? "(from vincentt.json)"
557
+ : source === "flag"
558
+ ? "(from --dist)"
559
+ : "";
560
+ return { folder, sourceNote };
561
+ }
562
+ /** Every `.html` in the manifest, for the root-absolute reference check. */
563
+ async function readHtmlFiles(distDir, manifest) {
564
+ const out = [];
565
+ for (const f of manifest.files) {
566
+ if (!f.path.toLowerCase().endsWith(".html"))
567
+ continue;
568
+ const html = await fs
569
+ .readFile(path.join(distDir, ...f.path.split("/")), "utf8")
570
+ .catch(() => undefined);
571
+ if (html !== undefined)
572
+ out.push({ file: f.path, html });
573
+ }
574
+ return out;
575
+ }
371
576
  /**
372
577
  * The address as a creator reads it: the host, never the scheme. `liveUrl` is
373
578
  * server-computed (the apex differs per environment), so it is the authority and
@@ -383,6 +588,12 @@ function addressOf(project) {
383
588
  return project.liveUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
384
589
  }
385
590
  }
591
+ /** The api refused the token: the sign-in remedy, and the not-signed-in exit code. */
592
+ function authExpired(deps) {
593
+ for (const l of authExpiredLines())
594
+ deps.print(l);
595
+ return EXIT.NOT_AUTHENTICATED;
596
+ }
386
597
  async function orgOf(deps) {
387
598
  return (await deps.orgName?.()) ?? "";
388
599
  }
@@ -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