@vincentt-xr/cli 0.19.0 → 0.21.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 (67) hide show
  1. package/MIGRATION.md +25 -0
  2. package/README.md +3 -2
  3. package/dist/cli/index.js +95 -13
  4. package/dist/cli/usage-text.js +3 -2
  5. package/dist/domain/api.js +16 -12
  6. package/dist/domain/command.js +9 -0
  7. package/dist/dx/command.js +3 -0
  8. package/dist/dx/consent.js +10 -14
  9. package/dist/dx/reportIo.js +6 -4
  10. package/dist/dx/send.js +6 -4
  11. package/dist/lib/backend.js +5 -10
  12. package/dist/lib/exitCodes.d.ts +6 -0
  13. package/dist/lib/exitCodes.js +6 -0
  14. package/dist/lib/terminalText.d.ts +6 -0
  15. package/dist/lib/terminalText.js +10 -0
  16. package/dist/lib/whoami.js +3 -0
  17. package/dist/library/assetsCommand.js +4 -0
  18. package/dist/library/client.js +6 -8
  19. package/dist/login/authBackend.d.ts +27 -2
  20. package/dist/login/authBackend.js +86 -17
  21. package/dist/login/loginCommand.d.ts +35 -3
  22. package/dist/login/loginCommand.js +81 -5
  23. package/dist/login/machineLabel.d.ts +46 -0
  24. package/dist/login/machineLabel.js +119 -0
  25. package/dist/login/pkceLogin.d.ts +17 -1
  26. package/dist/login/pkceLogin.js +30 -5
  27. package/dist/platform/releaseFetch.js +36 -69
  28. package/dist/preview/api.js +9 -12
  29. package/dist/preview/command.d.ts +3 -2
  30. package/dist/preview/command.js +8 -5
  31. package/dist/preview/copy.d.ts +8 -1
  32. package/dist/preview/copy.js +17 -4
  33. package/dist/preview/proxy.js +5 -39
  34. package/dist/preview/runner.d.ts +25 -9
  35. package/dist/preview/runner.js +42 -15
  36. package/dist/publish/api.js +14 -14
  37. package/dist/publish/command.d.ts +3 -0
  38. package/dist/publish/command.js +71 -7
  39. package/dist/publish/copy.d.ts +9 -0
  40. package/dist/publish/copy.js +83 -14
  41. package/dist/publish/keyScan.d.ts +25 -0
  42. package/dist/publish/keyScan.js +88 -0
  43. package/dist/publish/manifest.d.ts +51 -19
  44. package/dist/publish/manifest.js +246 -63
  45. package/dist/publish/upload.d.ts +8 -0
  46. package/dist/publish/upload.js +15 -0
  47. package/dist/publish/weight.d.ts +23 -0
  48. package/dist/publish/weight.js +113 -0
  49. package/dist/shared/authError.d.ts +1 -1
  50. package/dist/shared/authError.js +3 -1
  51. package/dist/shared/credentialNames.d.ts +17 -0
  52. package/dist/shared/credentialNames.js +72 -0
  53. package/dist/shared/http.d.ts +34 -0
  54. package/dist/shared/http.js +98 -2
  55. package/dist/shared/upgradeError.d.ts +62 -0
  56. package/dist/shared/upgradeError.js +215 -0
  57. package/dist/tunnel/caps.d.ts +19 -0
  58. package/dist/tunnel/caps.js +52 -0
  59. package/dist/tunnel/cbor.d.ts +39 -12
  60. package/dist/tunnel/cbor.js +84 -10
  61. package/dist/tunnel/client.d.ts +48 -2
  62. package/dist/tunnel/client.js +157 -44
  63. package/dist/tunnel/frame.d.ts +1 -1
  64. package/dist/tunnel/frame.js +8 -4
  65. package/dist/tunnel/index.d.ts +3 -2
  66. package/dist/tunnel/index.js +3 -2
  67. package/package.json +4 -3
@@ -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, 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,11 @@ 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
+ ? escapeTerminal(err.path)
347
+ : "a file";
332
348
  deps.print("");
333
349
  deps.print(`✗ Uploading ${failedPath} failed.`);
334
350
  deps.print("");
@@ -365,6 +381,15 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
365
381
  return 0;
366
382
  }
367
383
  catch (err) {
384
+ // A version refusal is final: COMMIT_RETRIES is for transient failures,
385
+ // and each retry would be one more refused call.
386
+ if (err instanceof UpgradeRequiredError) {
387
+ return refusedVersion(deps, err, {
388
+ kind: "publish",
389
+ address: i.address,
390
+ stillServing: i.liveVersion,
391
+ });
392
+ }
368
393
  // The token can lapse during a long upload. The files are stored and the
369
394
  // live address is unchanged, so signing in and publishing again is safe.
370
395
  if (err instanceof AuthExpiredError)
@@ -533,6 +558,21 @@ function openErrorLines(err, address, liveVersion, org) {
533
558
  function numericLimit(limit) {
534
559
  return typeof limit === "number" ? limit : 0;
535
560
  }
561
+ /** The refusals about what the output holds, printed after the header and reading line. */
562
+ function outputRefusal(err, output, buildCommand) {
563
+ switch (err.reason) {
564
+ case "secret_path":
565
+ return copy.secretFiles(err.paths, output);
566
+ case "credential_file":
567
+ return copy.credentialFiles(err.paths, output);
568
+ case "file_unreadable":
569
+ return copy.fileUnreadable(err.offendingPath, output);
570
+ case "lfs_pointer":
571
+ return copy.lfsPointers(err.paths, output, buildCommand ?? undefined);
572
+ default:
573
+ return undefined;
574
+ }
575
+ }
536
576
  function manifestErrorLines(err, distDir, ctx) {
537
577
  if (err.reason === "no_dist")
538
578
  return copy.noDist(distDir, ctx.buildCommand);
@@ -559,6 +599,24 @@ function outputLabelOf(root, distDir, source) {
559
599
  : "";
560
600
  return { folder, sourceNote };
561
601
  }
602
+ /** The first `n` bytes of a file, or fewer if it is shorter. */
603
+ async function readFileHead(abs, n) {
604
+ const handle = await fs.open(abs, "r");
605
+ try {
606
+ const buf = Buffer.alloc(n);
607
+ let got = 0;
608
+ while (got < n) {
609
+ const { bytesRead } = await handle.read(buf, got, n - got, got);
610
+ if (bytesRead === 0)
611
+ break;
612
+ got += bytesRead;
613
+ }
614
+ return buf.subarray(0, got);
615
+ }
616
+ finally {
617
+ await handle.close().catch(() => undefined);
618
+ }
619
+ }
562
620
  /** Every `.html` in the manifest, for the root-absolute reference check. */
563
621
  async function readHtmlFiles(distDir, manifest) {
564
622
  const out = [];
@@ -588,6 +646,12 @@ function addressOf(project) {
588
646
  return project.liveUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
589
647
  }
590
648
  }
649
+ /** The platform refused this CLI's version. Nothing further is sent. */
650
+ function refusedVersion(deps, err, arm) {
651
+ for (const l of upgradeRefusalLines(err, arm))
652
+ deps.print(l);
653
+ return EXIT.UPGRADE_REQUIRED;
654
+ }
591
655
  /** The api refused the token: the sign-in remedy, and the not-signed-in exit code. */
592
656
  function authExpired(deps) {
593
657
  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,10 @@ 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
+ /** Flow U1 — credential files, from the early checks or the hash step. After the header and reading line. */
180
+ export declare function credentialFiles(paths: Offender[], output?: OutputLabel): string[];
181
+ /** Flow U1 — a file that changed, became a link, or could not be read while checked or hashed. */
182
+ export declare function fileUnreadable(filePath: string, output?: OutputLabel): string[];
174
183
  /** Flow U1 — Git LFS pointer files. The recorded build.command, verbatim, when there is one. */
175
184
  export declare function lfsPointers(paths: Offender[], output?: OutputLabel, buildCommand?: string): string[];
176
185
  /** A local path rule the creator's own build broke. Local, before any request. */
@@ -12,7 +12,7 @@
12
12
  // NEVER PRINTED, at any point, in any state: the project id, the org id, the
13
13
  // uploadId, the PAT, any presigned URL or fragment of one, the bucket, any S3
14
14
  // key, or a per-file upload log.
15
- import { escapeControl } from "../lib/terminalText.js";
15
+ import { escapeTerminal } from "../lib/terminalText.js";
16
16
  /** The only horizontal rules in any f4 output. They exist here and nowhere else. */
17
17
  const RULE = " ─────────────────────────────────────────────────────────────────────";
18
18
  const LABELS = {
@@ -166,11 +166,33 @@ export function pathsFromRootLines(findings, folder = "dist/") {
166
166
  out.push("", " Vincentt serves this app at the root of its address. A missing path that", " starts with / usually means the build was made for a subpath, such as a", " GitHub Pages project site. Nothing here blocks you.", "");
167
167
  return out;
168
168
  }
169
+ /** The `Heavy files` section: warning-only, absent when there are no findings. */
170
+ export function heavyFileLines(findings) {
171
+ if (!findings || findings.length === 0)
172
+ return [];
173
+ const out = [" Heavy files (each one downloads to the phone before it can show)", ""];
174
+ for (const f of findings.slice(0, FINDINGS_SHOWN)) {
175
+ const name = escapeTerminal(f.path);
176
+ if (f.kind === "model") {
177
+ out.push(` ! ${name} is ${formatBytes(f.bytes)}`);
178
+ out.push(`${" ".repeat(21)}a model over 10 MB`);
179
+ }
180
+ else {
181
+ out.push(` ! ${name} is ${f.width} x ${f.height} px`);
182
+ out.push(`${" ".repeat(21)}an image over 2048 px on its long side`);
183
+ }
184
+ }
185
+ if (findings.length > FINDINGS_SHOWN) {
186
+ out.push(` and ${findings.length - FINDINGS_SHOWN} more`);
187
+ }
188
+ out.push("", " Every phone that opens this app downloads these files over its own", " connection. Compress the model or shrink the image, then build again.", " Nothing here blocks you.", "");
189
+ return out;
190
+ }
169
191
  const DEFAULT_OUTPUT = { folder: "dist/", sourceNote: "" };
170
192
  export function readingLine(fileCount, totalBytes, output = DEFAULT_OUTPUT) {
171
193
  const note = output.sourceNote ? ` ${output.sourceNote}` : "";
172
194
  // The folder is the creator's build.output or --dist, so it is escaped.
173
- return `→ Reading ${escapeControl(output.folder)}${note} … ${fileCount} files · ${formatBytes(totalBytes)}`;
195
+ return `→ Reading ${escapeTerminal(output.folder)}${note} … ${fileCount} files · ${formatBytes(totalBytes)}`;
174
196
  }
175
197
  export function headerLine(projectName, orgName) {
176
198
  return orgName ? `→ ${projectName} · ${orgName}` : `→ ${projectName}`;
@@ -211,6 +233,7 @@ export function firstPublishChecklist(i) {
211
233
  ...findingLines(i.findings, true, i.output?.folder),
212
234
  ...nothingBlocksYou(),
213
235
  ...pathsFromRootLines(i.rootAbsolute, i.output?.folder),
236
+ ...heavyFileLines(i.heavy),
214
237
  ` Publishing v${i.nextVersion} · ${i.fileCount} files · ${formatBytes(i.totalBytes)}`,
215
238
  "",
216
239
  ];
@@ -255,6 +278,7 @@ export function republishChecklist(i) {
255
278
  "",
256
279
  ...findingLines(i.findings, false, i.output?.folder),
257
280
  ...pathsFromRootLines(i.rootAbsolute, i.output?.folder),
281
+ ...heavyFileLines(i.heavy),
258
282
  ];
259
283
  }
260
284
  /** `[Y/n]` — on a republish an accidental Enter does the thing the creator typed the command to do. */
@@ -279,6 +303,7 @@ export function offlineChecklist(i) {
279
303
  "",
280
304
  ...findingLines(i.findings, false, i.output?.folder),
281
305
  ...pathsFromRootLines(i.rootAbsolute, i.output?.folder),
306
+ ...heavyFileLines(i.heavy),
282
307
  ];
283
308
  }
284
309
  export function offlinePrompt(version) {
@@ -303,6 +328,7 @@ export function notServingChecklist(i, stateKnown) {
303
328
  "",
304
329
  ...findingLines(i.findings, false, i.output?.folder),
305
330
  ...pathsFromRootLines(i.rootAbsolute, i.output?.folder),
331
+ ...heavyFileLines(i.heavy),
306
332
  ];
307
333
  }
308
334
  /** `[y/N]`: the CLI cannot say what this publish changes, so Enter does nothing. */
@@ -455,7 +481,7 @@ export function noDist(distDir, buildCommand) {
455
481
  "",
456
482
  " Nothing was uploaded.",
457
483
  "",
458
- ].map(escapeControl);
484
+ ].map(escapeTerminal);
459
485
  }
460
486
  export function noIndexHtml(distDir, buildCommand) {
461
487
  return [
@@ -469,7 +495,7 @@ export function noIndexHtml(distDir, buildCommand) {
469
495
  "",
470
496
  " Nothing was uploaded.",
471
497
  "",
472
- ].map(escapeControl);
498
+ ].map(escapeTerminal);
473
499
  }
474
500
  /** Flow U1 — the output is the project folder itself. */
475
501
  export function outputIsRoot(o) {
@@ -487,13 +513,13 @@ export function outputIsRoot(o) {
487
513
  "",
488
514
  " Nothing was uploaded.",
489
515
  "",
490
- ].map(escapeControl);
516
+ ].map(escapeTerminal);
491
517
  }
492
518
  /** Flow U1 — the output lies outside the project. `shown` is project-relative. */
493
519
  export function outputOutside(shown) {
494
520
  return [
495
521
  "",
496
- `✗ The build output ${escapeControl(shown)} is outside this project.`,
522
+ `✗ The build output ${escapeTerminal(shown)} is outside this project.`,
497
523
  "",
498
524
  " Publish uploads a folder inside the project. Set --dist, or build.output in",
499
525
  " vincentt.json, to the folder your build writes.",
@@ -506,7 +532,7 @@ export function outputOutside(shown) {
506
532
  export function outputInHidden(shown, hidden) {
507
533
  return [
508
534
  "",
509
- `✗ The build output ${escapeControl(shown)} is inside ${hidden}/, not a build.`,
535
+ `✗ The build output ${escapeTerminal(shown)} is inside ${hidden}/, not a build.`,
510
536
  "",
511
537
  " Publishing it would put your repository's history or its Vincentt link at a",
512
538
  " public address. Set --dist, or build.output in vincentt.json, to the folder",
@@ -516,18 +542,30 @@ export function outputInHidden(shown, hidden) {
516
542
  "",
517
543
  ];
518
544
  }
545
+ /** Where a credential reason starts, unless a longer path pushes it right. */
546
+ const WHY_COLUMN = 40;
547
+ const CREDENTIAL_WHY = {
548
+ name: "named like a credential",
549
+ private_key: "holds a private key",
550
+ service_account: "holds a service-account key",
551
+ authorized_user: "holds a Google sign-in credential",
552
+ };
519
553
  function offenderList(paths, folder) {
520
554
  // File names and the folder prefix come from the build and the creator's
521
555
  // settings, and may carry control characters.
522
- folder = escapeControl(folder);
556
+ folder = escapeTerminal(folder);
523
557
  const shown = paths.slice(0, FINDINGS_SHOWN).map((p) => ({
524
- path: escapeControl(p.path),
525
- target: p.target === undefined ? undefined : escapeControl(p.target),
558
+ path: escapeTerminal(p.path),
559
+ target: p.target === undefined ? undefined : escapeTerminal(p.target),
560
+ why: p.why,
526
561
  }));
527
562
  const width = Math.max(...shown.map((p) => folder.length + p.path.length));
563
+ const whyColumn = Math.max(WHY_COLUMN, width + 3);
528
564
  const out = shown.map((p) => p.target !== undefined
529
565
  ? ` ${(folder + p.path).padEnd(width)} links to ${p.target}`
530
- : ` ${folder}${p.path}`);
566
+ : p.why !== undefined
567
+ ? ` ${(folder + p.path).padEnd(whyColumn)}${CREDENTIAL_WHY[p.why]}`
568
+ : ` ${folder}${p.path}`);
531
569
  if (paths.length > FINDINGS_SHOWN)
532
570
  out.push(` and ${paths.length - FINDINGS_SHOWN} more`);
533
571
  return out;
@@ -540,13 +578,44 @@ export function secretFiles(paths, output = DEFAULT_OUTPUT) {
540
578
  "",
541
579
  ...offenderList(paths, output.folder),
542
580
  "",
543
- ` Everything in ${escapeControl(output.folder)} becomes public at your address. Remove these from the build`,
581
+ ` Everything in ${escapeTerminal(output.folder)} becomes public at your address. Remove these from the build`,
544
582
  " output, rebuild, then publish.",
545
583
  "",
546
584
  " Nothing was uploaded.",
547
585
  "",
548
586
  ];
549
587
  }
588
+ /** Flow U1 — credential files, from the early checks or the hash step. After the header and reading line. */
589
+ export function credentialFiles(paths, output = DEFAULT_OUTPUT) {
590
+ return [
591
+ "",
592
+ "✗ This build holds files that look like private keys.",
593
+ "",
594
+ ...offenderList(paths, output.folder),
595
+ "",
596
+ ` Everything in ${escapeTerminal(output.folder)} becomes public at your address, and a published version`,
597
+ " cannot be taken back. Move these out of the build output, rebuild, then",
598
+ " publish. A file only named like a key, such as a Keynote .key, needs a new",
599
+ " name.",
600
+ "",
601
+ " Nothing was uploaded.",
602
+ "",
603
+ ];
604
+ }
605
+ /** Flow U1 — a file that changed, became a link, or could not be read while checked or hashed. */
606
+ export function fileUnreadable(filePath, output = DEFAULT_OUTPUT) {
607
+ return [
608
+ "",
609
+ `✗ ${escapeTerminal(output.folder + filePath)} changed while publish was reading it.`,
610
+ "",
611
+ " It became a link or a different file, or could not be read, so publish",
612
+ " cannot vouch for what it would upload. Let your build finish, then publish",
613
+ " again.",
614
+ "",
615
+ " Nothing was uploaded.",
616
+ "",
617
+ ];
618
+ }
550
619
  /** Flow U1 — Git LFS pointer files. The recorded build.command, verbatim, when there is one. */
551
620
  export function lfsPointers(paths, output = DEFAULT_OUTPUT, buildCommand) {
552
621
  return [
@@ -560,7 +629,7 @@ export function lfsPointers(paths, output = DEFAULT_OUTPUT, buildCommand) {
560
629
  "",
561
630
  " git lfs pull",
562
631
  buildCommand !== undefined
563
- ? ` ${escapeControl(buildCommand)}`
632
+ ? ` ${escapeTerminal(buildCommand)}`
564
633
  : " then run your build again",
565
634
  "",
566
635
  " Nothing was uploaded.",
@@ -581,7 +650,7 @@ export function badPath(reason, offending) {
581
650
  "",
582
651
  `✗ This build ${why[reason] ?? "has a path we can't publish"}.`,
583
652
  "",
584
- ` ${escapeControl(offending)}`,
653
+ ` ${escapeTerminal(offending)}`,
585
654
  "",
586
655
  " Rename it in your source, rebuild, then publish again.",
587
656
  "",
@@ -0,0 +1,25 @@
1
+ /** The label is bounded to 40 characters so the carried tail stays finite. */
2
+ export declare const KEY_HEADER: RegExp;
3
+ /**
4
+ * A newline (real, or the two-character `\n` or four-character `\r\n` of a
5
+ * string literal), then a base64 line.
6
+ */
7
+ export declare const KEY_BODY: RegExp;
8
+ /** Below this offset a header alone is a match. */
9
+ export declare const HEAD_BYTES = 4096;
10
+ /** How far past a header its key body may sit. */
11
+ export declare const BODY_WINDOW = 256;
12
+ export interface KeyScanner {
13
+ /** Feed the next chunk, as latin1. */
14
+ push(chunk: string): void;
15
+ /** Judge any header still waiting on its body window. True when a key was seen. */
16
+ end(): boolean;
17
+ readonly matched: boolean;
18
+ }
19
+ export declare function createKeyScanner(): KeyScanner;
20
+ export type CredentialJsonType = "service_account" | "authorized_user";
21
+ /**
22
+ * The top-level `type` of a Google credential JSON, or undefined. A parse
23
+ * failure is "not a match" and its message is discarded: it echoes the input.
24
+ */
25
+ export declare function credentialJson(bytes: Uint8Array): CredentialJsonType | undefined;
@@ -0,0 +1,88 @@
1
+ // Content rules for credential files: a private-key header (PEM, OpenSSH, PGP)
2
+ // and a Google service-account or user credential JSON. Bytes are read as
3
+ // latin1, one character per byte, so the ASCII header matches inside UTF-8 and
4
+ // no decode can throw.
5
+ /** The label is bounded to 40 characters so the carried tail stays finite. */
6
+ export const KEY_HEADER = /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY( BLOCK)?-----/;
7
+ /**
8
+ * A newline (real, or the two-character `\n` or four-character `\r\n` of a
9
+ * string literal), then a base64 line.
10
+ */
11
+ export const KEY_BODY = /(?:\r?\n|(?:\\r)?\\n)[A-Za-z0-9+/=]{64}/;
12
+ /** Below this offset a header alone is a match. */
13
+ export const HEAD_BYTES = 4096;
14
+ /** How far past a header its key body may sit. */
15
+ export const BODY_WINDOW = 256;
16
+ /** Covers the longest header plus the body window. */
17
+ const CARRY = 512;
18
+ export function createKeyScanner() {
19
+ let buf = "";
20
+ let bufStart = 0;
21
+ // Absolute offset below which every header has been judged.
22
+ let judgedTo = 0;
23
+ let matched = false;
24
+ const scan = (final) => {
25
+ const re = new RegExp(KEY_HEADER.source, "g");
26
+ re.lastIndex = Math.max(0, judgedTo - bufStart);
27
+ let m;
28
+ while ((m = re.exec(buf)) !== null) {
29
+ const at = bufStart + m.index;
30
+ if (at < HEAD_BYTES) {
31
+ matched = true;
32
+ return;
33
+ }
34
+ const end = m.index + m[0].length;
35
+ if (!final && end + BODY_WINDOW > buf.length) {
36
+ judgedTo = at;
37
+ return;
38
+ }
39
+ if (KEY_BODY.test(buf.slice(end, end + BODY_WINDOW))) {
40
+ matched = true;
41
+ return;
42
+ }
43
+ judgedTo = at + 1;
44
+ re.lastIndex = m.index + 1;
45
+ }
46
+ };
47
+ return {
48
+ push(chunk) {
49
+ if (matched)
50
+ return;
51
+ buf += chunk;
52
+ scan(false);
53
+ if (buf.length > CARRY) {
54
+ const drop = buf.length - CARRY;
55
+ buf = buf.slice(drop);
56
+ bufStart += drop;
57
+ }
58
+ },
59
+ end() {
60
+ if (!matched)
61
+ scan(true);
62
+ return matched;
63
+ },
64
+ get matched() {
65
+ return matched;
66
+ },
67
+ };
68
+ }
69
+ /**
70
+ * The top-level `type` of a Google credential JSON, or undefined. A parse
71
+ * failure is "not a match" and its message is discarded: it echoes the input.
72
+ */
73
+ export function credentialJson(bytes) {
74
+ const start = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
75
+ ? 3
76
+ : 0;
77
+ let value;
78
+ try {
79
+ value = JSON.parse(Buffer.from(bytes.buffer, bytes.byteOffset + start, bytes.length - start).toString("utf8"));
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ if (value === null || typeof value !== "object" || Array.isArray(value))
85
+ return undefined;
86
+ const type = value.type;
87
+ return type === "service_account" || type === "authorized_user" ? type : undefined;
88
+ }