@vincentt-xr/cli 0.20.0 → 0.22.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 (68) hide show
  1. package/MIGRATION.md +30 -0
  2. package/dist/cli/index.js +86 -12
  3. package/dist/domain/api.js +16 -12
  4. package/dist/domain/command.js +9 -0
  5. package/dist/dx/command.d.ts +74 -18
  6. package/dist/dx/command.js +140 -86
  7. package/dist/dx/consent.d.ts +25 -6
  8. package/dist/dx/consent.js +64 -35
  9. package/dist/dx/enablement.d.ts +63 -11
  10. package/dist/dx/enablement.js +93 -21
  11. package/dist/dx/report.d.ts +4 -24
  12. package/dist/dx/report.js +32 -25
  13. package/dist/dx/reportIo.js +6 -4
  14. package/dist/dx/send.js +6 -4
  15. package/dist/lib/backend.js +5 -10
  16. package/dist/lib/exitCodes.d.ts +6 -0
  17. package/dist/lib/exitCodes.js +6 -0
  18. package/dist/lib/terminalText.d.ts +6 -0
  19. package/dist/lib/terminalText.js +10 -0
  20. package/dist/lib/version.d.ts +28 -2
  21. package/dist/lib/version.js +30 -3
  22. package/dist/lib/whoami.js +3 -0
  23. package/dist/library/assetsCommand.js +4 -0
  24. package/dist/library/client.js +6 -8
  25. package/dist/login/authBackend.js +18 -15
  26. package/dist/login/loginCommand.js +31 -4
  27. package/dist/login/pkceLogin.d.ts +10 -0
  28. package/dist/login/pkceLogin.js +24 -1
  29. package/dist/platform/releaseFetch.js +36 -69
  30. package/dist/preview/api.js +9 -12
  31. package/dist/preview/command.d.ts +3 -2
  32. package/dist/preview/command.js +10 -5
  33. package/dist/preview/copy.d.ts +13 -1
  34. package/dist/preview/copy.js +31 -3
  35. package/dist/preview/proxy.js +5 -39
  36. package/dist/preview/runner.d.ts +31 -9
  37. package/dist/preview/runner.js +43 -15
  38. package/dist/publish/api.js +14 -14
  39. package/dist/publish/command.d.ts +3 -0
  40. package/dist/publish/command.js +74 -7
  41. package/dist/publish/copy.d.ts +15 -0
  42. package/dist/publish/copy.js +109 -19
  43. package/dist/publish/keyScan.d.ts +25 -0
  44. package/dist/publish/keyScan.js +88 -0
  45. package/dist/publish/manifest.d.ts +60 -19
  46. package/dist/publish/manifest.js +290 -71
  47. package/dist/publish/upload.d.ts +18 -0
  48. package/dist/publish/upload.js +38 -0
  49. package/dist/publish/weight.d.ts +23 -0
  50. package/dist/publish/weight.js +113 -0
  51. package/dist/shared/config.d.ts +6 -6
  52. package/dist/shared/credentialNames.d.ts +17 -0
  53. package/dist/shared/credentialNames.js +72 -0
  54. package/dist/shared/http.d.ts +34 -0
  55. package/dist/shared/http.js +98 -2
  56. package/dist/shared/upgradeError.d.ts +62 -0
  57. package/dist/shared/upgradeError.js +215 -0
  58. package/dist/tunnel/caps.d.ts +19 -0
  59. package/dist/tunnel/caps.js +52 -0
  60. package/dist/tunnel/cbor.d.ts +39 -12
  61. package/dist/tunnel/cbor.js +84 -10
  62. package/dist/tunnel/client.d.ts +56 -2
  63. package/dist/tunnel/client.js +160 -44
  64. package/dist/tunnel/frame.d.ts +1 -1
  65. package/dist/tunnel/frame.js +8 -4
  66. package/dist/tunnel/index.d.ts +3 -2
  67. package/dist/tunnel/index.js +3 -2
  68. package/package.json +4 -3
@@ -2,6 +2,7 @@ import { type ControlKind, type TunnelState, type UAClass } from "../tunnel/inde
2
2
  import type { DiagEvent } from "@vincentt-xr/harness/events";
3
3
  import { type EventTags } from "../relay/server.js";
4
4
  import { type ResolvedConfig } from "../shared/config.js";
5
+ import { type UpgradeFacts } from "../shared/upgradeError.js";
5
6
  /** The path a 404 advisory names: the request's own path, without its query. */
6
7
  export declare function requestPathOf(url: string | undefined): string;
7
8
  /**
@@ -31,7 +32,25 @@ export type PreviewEndReason = "stopped"
31
32
  * elapsed without the client getting back): same remedy, different diagnosis,
32
33
  * and the copy explains the bound only at the moment it bites.
33
34
  */
34
- | "grace_cap" | "authz_revoked" | "authz_unconfirmed"
35
+ | "grace_cap" | "authz_revoked"
36
+ /**
37
+ * The edge drained the session because the platform stopped confirming it,
38
+ * and said so (CONTROL `authz_expired`). Final: a new preview is a new link,
39
+ * so nothing is re-minted and nothing redials.
40
+ */
41
+ | "authz_lapsed"
42
+ /**
43
+ * The platform refused this CLI's version, on the heartbeat (426) or on a
44
+ * dial (410 + `X-Vincentt-Refusal: cli_upgrade_required`). One screen for
45
+ * both. No further platform call is made, the DELETE included.
46
+ */
47
+ | "upgrade_required"
48
+ /**
49
+ * The edge broke the agreed tunnel wire (a malformed 101 caps header, a
50
+ * CONTROL frame this connection cannot read, or a fatal OPEN). Final: a redial reaches the
51
+ * same edge, so the tunnel client has already stopped dialling.
52
+ */
53
+ | "protocol_violation"
35
54
  /**
36
55
  * The 30-minute heartbeat-silence flip. NEVER reported as `hard_max`: a
37
56
  * session ended at minute 30 has not reached its 12-hour ceiling, and saying
@@ -130,8 +149,11 @@ export interface StartPreviewOptions {
130
149
  onOriginError?: (label: string, code: string) => void;
131
150
  /** Their dev server answered again. Re-arms the advisory; prints NOTHING. */
132
151
  onOriginRecovered?: () => void;
133
- /** The session ended for a reason the creator did not cause. */
134
- onEnded?: (reason: PreviewEndReason) => void;
152
+ /**
153
+ * The session ended for a reason the creator did not cause. `refusal` carries
154
+ * the validated floor facts on `upgrade_required` only.
155
+ */
156
+ onEnded?: (reason: PreviewEndReason, refusal?: UpgradeFacts) => void;
135
157
  /** The hard max is approaching. A WARNING, never an ending (L20b). */
136
158
  onExpiring?: () => void;
137
159
  /** Injected for tests. */
@@ -154,11 +176,11 @@ export interface RunningPreview {
154
176
  }
155
177
  export declare function startPreview(opts: StartPreviewOptions): Promise<RunningPreview>;
156
178
  /**
157
- * Two of the three CONTROL kinds are endings. `expiring` is NOT: it is the
158
- * hard-max warning, and it arrives on the same channel. Mapping it to an ending
159
- * would take away a link that has half an hour left, at exactly the moment the
160
- * creator is being warned to plan around it — so it returns null and the caller
161
- * renders the warning instead.
179
+ * Three of the four kinds that reach onControl are endings. `expiring` is NOT:
180
+ * it is the hard-max warning, and it arrives on the same channel. Mapping it to
181
+ * an ending would take away a link that has half an hour left, at exactly the
182
+ * moment the creator is being warned to plan around it, so it returns null and
183
+ * the caller renders the warning instead. (`viewer_gone` never reaches here.)
162
184
  */
163
185
  export declare function controlToReason(kind: ControlKind): PreviewEndReason | null;
164
186
  /**
@@ -199,7 +221,7 @@ export declare function refusalToReason(status: number): PreviewEndReason | null
199
221
  * reason maps to its own screen. A 410 with no reason gets the cause-free
200
222
  * ending. See the note on PreviewEndedError.
201
223
  */
202
- export declare function runHeartbeat(config: ResolvedConfig, sessionId: string, fetchImpl: typeof fetch, onEnded?: (reason: PreviewEndReason) => void): Promise<void>;
224
+ export declare function runHeartbeat(config: ResolvedConfig, sessionId: string, fetchImpl: typeof fetch, onEnded?: (reason: PreviewEndReason, refusal?: UpgradeFacts) => void): Promise<void>;
203
225
  /**
204
226
  * The API's `endReason` → the CLI's ending screen.
205
227
  *
@@ -26,6 +26,7 @@ import { withoutCliCredentials } from "../shared/config.js";
26
26
  import { ProjectUnreachableError } from "../lib/backend.js";
27
27
  import { AuthExpiredError } from "../shared/authError.js";
28
28
  import { apiFetch } from "../shared/http.js";
29
+ import { UpgradeRequiredError, parseDeclared, parseMinimum, } from "../shared/upgradeError.js";
29
30
  import { terminalSafe } from "../lib/terminalText.js";
30
31
  /** The path a 404 advisory names: the request's own path, without its query. */
31
32
  export function requestPathOf(url) {
@@ -63,6 +64,17 @@ export async function startPreview(opts) {
63
64
  let heartbeat;
64
65
  let minted;
65
66
  let stopped = false;
67
+ // Set once the platform refuses this CLI's version: every later platform
68
+ // call would be refused too, so none is made.
69
+ let versionRefused = false;
70
+ const ended = (reason, refusal) => {
71
+ if (reason === "upgrade_required") {
72
+ versionRefused = true;
73
+ if (heartbeat !== undefined)
74
+ clearInterval(heartbeat);
75
+ }
76
+ opts.onEnded?.(reason, refusal);
77
+ };
66
78
  const stop = async () => {
67
79
  if (stopped)
68
80
  return;
@@ -84,8 +96,9 @@ export async function startPreview(opts) {
84
96
  // best-effort in intent even though it is in effect: the 12-hour ceiling
85
97
  // and the edge's 60-second fail-closed deadline both reap a session whose
86
98
  // DELETE never landed.
87
- if (minted)
99
+ if (minted && !versionRefused) {
88
100
  await endPreviewSession(config, minted.sessionId, fetchImpl);
101
+ }
89
102
  };
90
103
  try {
91
104
  // The MINT comes first. Everything below it costs the creator a dev-server
@@ -231,7 +244,7 @@ export async function startPreview(opts) {
231
244
  opts.onExpiring?.();
232
245
  return;
233
246
  }
234
- opts.onEnded?.(reason);
247
+ ended(reason);
235
248
  },
236
249
  onOriginError: opts.onOriginError,
237
250
  onStateChange: opts.onTunnelState,
@@ -241,8 +254,13 @@ export async function startPreview(opts) {
241
254
  onReconnectRefused: (status) => {
242
255
  const reason = refusalToReason(status);
243
256
  if (reason !== null)
244
- opts.onEnded?.(reason);
257
+ ended(reason);
245
258
  },
259
+ onProtocolViolation: () => ended("protocol_violation"),
260
+ onRefused: (_reason, minimum, declared) => ended("upgrade_required", {
261
+ minimum: parseMinimum(minimum),
262
+ declared: parseDeclared(declared),
263
+ }),
246
264
  });
247
265
  tunnel.start();
248
266
  // The heartbeat is keepalive AND re-authorization: every beat re-runs
@@ -252,10 +270,10 @@ export async function startPreview(opts) {
252
270
  // One beat in flight at a time: a slow api must not stack up requests.
253
271
  let beating = false;
254
272
  heartbeat = setInterval(() => {
255
- if (beating)
273
+ if (beating || versionRefused)
256
274
  return;
257
275
  beating = true;
258
- void runHeartbeat(config, minted.sessionId, fetchImpl, opts.onEnded).finally(() => {
276
+ void runHeartbeat(config, minted.sessionId, fetchImpl, ended).finally(() => {
259
277
  beating = false;
260
278
  });
261
279
  }, minted.heartbeatSec * 1000);
@@ -284,18 +302,24 @@ export async function startPreview(opts) {
284
302
  }
285
303
  }
286
304
  /**
287
- * Two of the three CONTROL kinds are endings. `expiring` is NOT: it is the
288
- * hard-max warning, and it arrives on the same channel. Mapping it to an ending
289
- * would take away a link that has half an hour left, at exactly the moment the
290
- * creator is being warned to plan around it — so it returns null and the caller
291
- * renders the warning instead.
305
+ * Three of the four kinds that reach onControl are endings. `expiring` is NOT:
306
+ * it is the hard-max warning, and it arrives on the same channel. Mapping it to
307
+ * an ending would take away a link that has half an hour left, at exactly the
308
+ * moment the creator is being warned to plan around it, so it returns null and
309
+ * the caller renders the warning instead. (`viewer_gone` never reaches here.)
292
310
  */
293
311
  export function controlToReason(kind) {
294
- if (kind === "superseded")
295
- return "superseded";
296
- if (kind === "revoke")
297
- return "authz_revoked";
298
- return null;
312
+ switch (kind) {
313
+ case "superseded":
314
+ return "superseded";
315
+ case "revoke":
316
+ return "authz_revoked";
317
+ case "authz_expired":
318
+ return "authz_lapsed";
319
+ case "expiring":
320
+ case "viewer_gone":
321
+ return null;
322
+ }
299
323
  }
300
324
  /**
301
325
  * A refused RECONNECT dial → the ending screen it means.
@@ -346,6 +370,10 @@ export async function runHeartbeat(config, sessionId, fetchImpl, onEnded) {
346
370
  await heartbeatPreviewSession(config, sessionId, fetchImpl);
347
371
  }
348
372
  catch (err) {
373
+ if (err instanceof UpgradeRequiredError) {
374
+ onEnded?.("upgrade_required", { minimum: err.minimum, declared: err.declared });
375
+ return;
376
+ }
349
377
  if (err instanceof AuthExpiredError) {
350
378
  onEnded?.("auth_expired");
351
379
  return;
@@ -5,9 +5,9 @@
5
5
  //
6
6
  // The bytes do NOT transit the API. Open returns one presigned PUT per file,
7
7
  // each signed for one exact key; the CLI PUTs direct to S3 and then commits.
8
- import { clientHeaders } from "../shared/clientHeader.js";
9
8
  import { rejectAuthExpired } from "../shared/authError.js";
10
- import { apiFetch } from "../shared/http.js";
9
+ import { apiFetch, apiRequest } from "../shared/http.js";
10
+ import { rejectUpgradeRequired } from "../shared/upgradeError.js";
11
11
  /**
12
12
  * The api's publish states, `domain.PublishState*`. apiPublishStates.test.ts
13
13
  * checks this list against the committed api capture, so a state the api adds
@@ -82,8 +82,12 @@ export class PublishApiError extends Error {
82
82
  this.name = "PublishApiError";
83
83
  }
84
84
  }
85
- /** Rejects with AuthExpiredError on a 401; every other status becomes a PublishApiError. */
85
+ /**
86
+ * Rejects with UpgradeRequiredError on a 426 and AuthExpiredError on a 401;
87
+ * every other status becomes a PublishApiError.
88
+ */
86
89
  async function readError(res) {
90
+ await rejectUpgradeRequired(res);
87
91
  rejectAuthExpired(res);
88
92
  let body = {};
89
93
  try {
@@ -95,14 +99,10 @@ async function readError(res) {
95
99
  const retryAfter = res.headers.get("retry-after");
96
100
  return new PublishApiError(res.status, body.code ?? `http_${res.status}`, body.message ?? `request failed (${res.status})`, body.detail ?? {}, retryAfter ? Number(retryAfter) : undefined);
97
101
  }
98
- function authHeaders(cfg) {
99
- return { ...clientHeaders(), authorization: `Bearer ${cfg.pat}` };
100
- }
102
+ const AUTHENTICATED = { authenticated: true };
101
103
  /** The ONE call before the checklist. liveVersion + livePublishedAt ride it, by design. */
102
104
  export async function readProject(cfg, projectId, fetchImpl = apiFetch) {
103
- const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}`, {
104
- headers: authHeaders(cfg),
105
- });
105
+ const res = await apiRequest(cfg, `/projects/${encodeURIComponent(projectId)}`, {}, AUTHENTICATED, fetchImpl);
106
106
  if (!res.ok)
107
107
  throw await readError(res);
108
108
  const raw = (await res.json());
@@ -137,15 +137,15 @@ export async function readProject(cfg, projectId, fetchImpl = apiFetch) {
137
137
  * and specific fact.
138
138
  */
139
139
  export async function openUpload(cfg, projectId, files, note, fetchImpl = apiFetch, builtWith) {
140
- const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes`, {
140
+ const res = await apiRequest(cfg, `/projects/${encodeURIComponent(projectId)}/publishes`, {
141
141
  method: "POST",
142
- headers: { ...authHeaders(cfg), "content-type": "application/json" },
142
+ headers: { "content-type": "application/json" },
143
143
  body: JSON.stringify({
144
144
  files,
145
145
  ...(note ? { note } : {}),
146
146
  ...(builtWith ? { builtWith } : {}),
147
147
  }),
148
- });
148
+ }, AUTHENTICATED, fetchImpl);
149
149
  if (!res.ok)
150
150
  throw await readError(res);
151
151
  const body = (await res.json());
@@ -159,7 +159,7 @@ export async function openUpload(cfg, projectId, files, note, fetchImpl = apiFet
159
159
  };
160
160
  }
161
161
  export async function commitUpload(cfg, projectId, uploadId, fetchImpl = apiFetch) {
162
- const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}/commit`, { method: "POST", headers: authHeaders(cfg) });
162
+ const res = await apiRequest(cfg, `/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}/commit`, { method: "POST" }, AUTHENTICATED, fetchImpl);
163
163
  if (!res.ok)
164
164
  throw await readError(res);
165
165
  let raw;
@@ -174,7 +174,7 @@ export async function commitUpload(cfg, projectId, uploadId, fetchImpl = apiFetc
174
174
  /** Abandon an open upload. Idempotent server-side; a failure here must never mask the real error. */
175
175
  export async function abandonUpload(cfg, projectId, uploadId, fetchImpl = apiFetch) {
176
176
  try {
177
- await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}`, { method: "DELETE", headers: authHeaders(cfg) });
177
+ await apiRequest(cfg, `/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}`, { method: "DELETE" }, AUTHENTICATED, fetchImpl);
178
178
  }
179
179
  catch {
180
180
  // Best-effort cleanup: the TTL reaps the row either way.
@@ -3,6 +3,7 @@ export declare const MAX_REOPENS = 2;
3
3
  import type { ResolvedConfig } from "../shared/config.js";
4
4
  import { type BuiltWith } from "../platform/resolve.js";
5
5
  import { type VincenttJson } from "../lib/settings.js";
6
+ import { type ReadHead } from "./weight.js";
6
7
  export interface PublishDeps {
7
8
  print: (line: string) => void;
8
9
  /** Raw write, for the redrawn progress line only. */
@@ -42,6 +43,8 @@ export interface PublishDeps {
42
43
  probeFetch?: typeof fetch;
43
44
  /** Where the --json document goes (stdout). Defaults to print. */
44
45
  emit?: (line: string) => void;
46
+ /** The Heavy files section's image read, by output-relative path. Injected for tests; defaults to a read under the output folder. */
47
+ readHead?: ReadHead;
45
48
  }
46
49
  export interface PublishOptions {
47
50
  distDir?: string;
@@ -15,19 +15,22 @@ 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 { UploadExpired, UploadFailed, uploadAll } from "./upload.js";
18
+ import { UploadExpired, UploadFailed, UploadTargetRejected, UploadChecksumMismatch, uploadAll, } from "./upload.js";
19
+ import { escapeTerminal } from "../lib/terminalText.js";
19
20
  /** Re-opens after an expired upload link, per publish, before giving up. */
20
21
  export const MAX_REOPENS = 2;
21
22
  import { waitUntilServing } from "./visibility.js";
22
23
  import * as copy from "./copy.js";
23
24
  import { publishedLimitRefused, resolveCountLimit, storageCeilingRefused, } from "../shared/countLimit.js";
24
25
  import { AuthExpiredError, authExpiredLines } from "../shared/authError.js";
26
+ import { UpgradeRequiredError, upgradeRefusalLines, } from "../shared/upgradeError.js";
25
27
  import { EXIT } from "../lib/exitCodes.js";
26
28
  import { apiFetch, isTransportError } from "../shared/http.js";
27
29
  import { resolveBuiltWith } from "../platform/resolve.js";
28
30
  import { readSettings, resolveSettings, SettingsError, TEMPLATE_DEFAULTS, } from "../lib/settings.js";
29
31
  import { settingsRefusal } from "../lib/settingsCopy.js";
30
32
  import { rootAbsoluteFindings } from "./fit.js";
33
+ import { heavyFileFindings } from "./weight.js";
31
34
  const COMMIT_RETRIES = 3;
32
35
  const COMMIT_RETRY_DELAY_MS = 2000;
33
36
  export async function publishCommand(deps, opts = {}) {
@@ -91,13 +94,11 @@ async function runPublish(deps, opts, onCommitted) {
91
94
  }
92
95
  catch (err) {
93
96
  if (err instanceof ManifestError) {
94
- if (err.reason === "secret_path" || err.reason === "lfs_pointer") {
97
+ const block = outputRefusal(err, output, buildCommand);
98
+ if (block !== undefined) {
95
99
  deps.print(copy.headerLine(await projectLabel(deps, binding.slug), await orgOf(deps)));
96
100
  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
+ for (const l of block)
101
102
  deps.print(l);
102
103
  return 1;
103
104
  }
@@ -129,6 +130,11 @@ async function runPublish(deps, opts, onCommitted) {
129
130
  project = await readProject(cfg, binding.projectId, fetchImpl);
130
131
  }
131
132
  catch (err) {
133
+ if (err instanceof UpgradeRequiredError) {
134
+ deps.print(copy.headerLine(await projectLabel(deps, binding.slug), await orgOf(deps)));
135
+ deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes, output));
136
+ return refusedVersion(deps, err, { kind: "publish" });
137
+ }
132
138
  if (err instanceof AuthExpiredError)
133
139
  return authExpired(deps);
134
140
  if (err instanceof PublishApiError && err.status === 404) {
@@ -172,6 +178,8 @@ async function runPublish(deps, opts, onCommitted) {
172
178
  const manifestPaths = new Set(manifest.files.map((f) => f.path));
173
179
  const findings = await classifyDist(distDir, manifestPaths, (abs) => fs.readFile(abs, "utf8"), (dir, rel) => path.join(dir, ...rel.split("/")), { expectedHost: address });
174
180
  const rootAbsolute = rootAbsoluteFindings(await readHtmlFiles(distDir, manifest), manifestPaths);
181
+ // Warning-only: a fault in this section never changes the exit code.
182
+ const heavy = await heavyFileFindings(manifest.files, deps.readHead ?? ((rel, n) => readFileHead(path.join(distDir, ...rel.split("/")), n))).catch(() => []);
175
183
  const checklistInput = {
176
184
  projectName: project.name,
177
185
  orgName,
@@ -182,6 +190,7 @@ async function runPublish(deps, opts, onCommitted) {
182
190
  findings,
183
191
  output,
184
192
  rootAbsolute,
193
+ heavy,
185
194
  };
186
195
  let promptLine;
187
196
  let defaultYes;
@@ -261,6 +270,9 @@ async function runPublish(deps, opts, onCommitted) {
261
270
  async function runUpload(deps, cfg, i) {
262
271
  const open = () => openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
263
272
  const openFailed = (err) => {
273
+ if (err instanceof UpgradeRequiredError) {
274
+ return refusedVersion(deps, err, { kind: "publish", address: i.address });
275
+ }
264
276
  if (err instanceof AuthExpiredError)
265
277
  return authExpired(deps);
266
278
  for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
@@ -328,7 +340,12 @@ async function runUpload(deps, cfg, i) {
328
340
  deps.print(l);
329
341
  return 1;
330
342
  }
331
- const failedPath = err instanceof UploadFailed || err instanceof UploadExpired ? err.path : "a file";
343
+ const failedPath = err instanceof UploadFailed ||
344
+ err instanceof UploadExpired ||
345
+ err instanceof UploadTargetRejected ||
346
+ err instanceof UploadChecksumMismatch
347
+ ? escapeTerminal(err.path)
348
+ : "a file";
332
349
  deps.print("");
333
350
  deps.print(`✗ Uploading ${failedPath} failed.`);
334
351
  deps.print("");
@@ -365,6 +382,15 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
365
382
  return 0;
366
383
  }
367
384
  catch (err) {
385
+ // A version refusal is final: COMMIT_RETRIES is for transient failures,
386
+ // and each retry would be one more refused call.
387
+ if (err instanceof UpgradeRequiredError) {
388
+ return refusedVersion(deps, err, {
389
+ kind: "publish",
390
+ address: i.address,
391
+ stillServing: i.liveVersion,
392
+ });
393
+ }
368
394
  // The token can lapse during a long upload. The files are stored and the
369
395
  // live address is unchanged, so signing in and publishing again is safe.
370
396
  if (err instanceof AuthExpiredError)
@@ -533,6 +559,23 @@ function openErrorLines(err, address, liveVersion, org) {
533
559
  function numericLimit(limit) {
534
560
  return typeof limit === "number" ? limit : 0;
535
561
  }
562
+ /** The refusals about what the output holds, printed after the header and reading line. */
563
+ function outputRefusal(err, output, buildCommand) {
564
+ switch (err.reason) {
565
+ case "secret_path":
566
+ return copy.secretFiles(err.paths, output);
567
+ case "path_escaped":
568
+ return copy.escapedFiles(err.paths, output);
569
+ case "credential_file":
570
+ return copy.credentialFiles(err.paths, output);
571
+ case "file_unreadable":
572
+ return copy.fileUnreadable(err.offendingPath, output);
573
+ case "lfs_pointer":
574
+ return copy.lfsPointers(err.paths, output, buildCommand ?? undefined);
575
+ default:
576
+ return undefined;
577
+ }
578
+ }
536
579
  function manifestErrorLines(err, distDir, ctx) {
537
580
  if (err.reason === "no_dist")
538
581
  return copy.noDist(distDir, ctx.buildCommand);
@@ -559,6 +602,24 @@ function outputLabelOf(root, distDir, source) {
559
602
  : "";
560
603
  return { folder, sourceNote };
561
604
  }
605
+ /** The first `n` bytes of a file, or fewer if it is shorter. */
606
+ async function readFileHead(abs, n) {
607
+ const handle = await fs.open(abs, "r");
608
+ try {
609
+ const buf = Buffer.alloc(n);
610
+ let got = 0;
611
+ while (got < n) {
612
+ const { bytesRead } = await handle.read(buf, got, n - got, got);
613
+ if (bytesRead === 0)
614
+ break;
615
+ got += bytesRead;
616
+ }
617
+ return buf.subarray(0, got);
618
+ }
619
+ finally {
620
+ await handle.close().catch(() => undefined);
621
+ }
622
+ }
562
623
  /** Every `.html` in the manifest, for the root-absolute reference check. */
563
624
  async function readHtmlFiles(distDir, manifest) {
564
625
  const out = [];
@@ -588,6 +649,12 @@ function addressOf(project) {
588
649
  return project.liveUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
589
650
  }
590
651
  }
652
+ /** The platform refused this CLI's version. Nothing further is sent. */
653
+ function refusedVersion(deps, err, arm) {
654
+ for (const l of upgradeRefusalLines(err, arm))
655
+ deps.print(l);
656
+ return EXIT.UPGRADE_REQUIRED;
657
+ }
591
658
  /** The api refused the token: the sign-in remedy, and the not-signed-in exit code. */
592
659
  function authExpired(deps) {
593
660
  for (const l of authExpiredLines())
@@ -1,6 +1,7 @@
1
1
  import type { FileFindings } from "./classify.js";
2
2
  import type { BoundViolation } from "./bounds.js";
3
3
  import type { RootAbsoluteFinding } from "./fit.js";
4
+ import type { HeavyFinding } from "./weight.js";
4
5
  import type { Offender } from "./manifest.js";
5
6
  export declare function truncate(value: string, max?: number): string;
6
7
  /** Bytes as the creator reads them. Decimal MB, matching what a build tool prints. */
@@ -46,6 +47,8 @@ export interface ChecklistInput {
46
47
  output?: OutputLabel;
47
48
  /** Root-absolute references that do not resolve. The section is absent when empty. */
48
49
  rootAbsolute?: RootAbsoluteFinding[];
50
+ /** Models and images over the weight limits. The section is absent when empty. */
51
+ heavy?: HeavyFinding[];
49
52
  }
50
53
  /**
51
54
  * The `Paths from the root` section: a second section of the checklist, same
@@ -53,6 +56,8 @@ export interface ChecklistInput {
53
56
  * empty heading. One explanation for the whole section.
54
57
  */
55
58
  export declare function pathsFromRootLines(findings: RootAbsoluteFinding[] | undefined, folder?: string): string[];
59
+ /** The `Heavy files` section: warning-only, absent when there are no findings. */
60
+ export declare function heavyFileLines(findings: HeavyFinding[] | undefined): string[];
56
61
  /**
57
62
  * The output folder as the creator reads it, `dist/` for the template. The
58
63
  * source is appended only when it is not the default, so the template's
@@ -171,6 +176,16 @@ export declare function outputOutside(shown: string): string[];
171
176
  export declare function outputInHidden(shown: string, hidden: string): string[];
172
177
  /** Flow U1 — hidden names or symlinks that would leak. After the header and reading line. */
173
178
  export declare function secretFiles(paths: Offender[], output?: OutputLabel): string[];
179
+ /**
180
+ * Flow U1 — files whose folder resolved outside the output after the walk: a
181
+ * folder was swapped for a link while publish read it. The whole publish is
182
+ * refused, never just the file. After the header and reading line.
183
+ */
184
+ export declare function escapedFiles(paths: Offender[], output?: OutputLabel): string[];
185
+ /** Flow U1 — credential files, from the early checks or the hash step. After the header and reading line. */
186
+ export declare function credentialFiles(paths: Offender[], output?: OutputLabel): string[];
187
+ /** Flow U1 — a file that changed, became a link, or could not be read while checked or hashed. */
188
+ export declare function fileUnreadable(filePath: string, output?: OutputLabel): string[];
174
189
  /** Flow U1 — Git LFS pointer files. The recorded build.command, verbatim, when there is one. */
175
190
  export declare function lfsPointers(paths: Offender[], output?: OutputLabel, buildCommand?: string): string[];
176
191
  /** A local path rule the creator's own build broke. Local, before any request. */