@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
@@ -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. */
@@ -378,7 +404,7 @@ export function tooLarge(v) {
378
404
  "",
379
405
  "✗ One file is too large to publish.",
380
406
  "",
381
- ...alignedPair([v.path ?? "", formatBytes(v.observed)], ["the per-file limit", formatLimit(v.limit)]),
407
+ ...alignedPair([escapeTerminal(v.path ?? ""), formatBytes(v.observed)], ["the per-file limit", formatLimit(v.limit)]),
382
408
  "",
383
409
  " Compress or externalise it, rebuild, then publish again.",
384
410
  "",
@@ -427,9 +453,10 @@ function alignedPair(a, b) {
427
453
  ];
428
454
  }
429
455
  function largestBlock(v) {
430
- const width = Math.max(0, ...v.largest.map((f) => f.path.length));
431
- const sizeWidth = Math.max(0, ...v.largest.map((f) => formatBytes(f.size).length));
432
- return v.largest.map((f) => ` ${f.path.padEnd(width)} ${formatBytes(f.size).padStart(sizeWidth)}`);
456
+ const largest = v.largest.map((f) => ({ path: escapeTerminal(f.path), size: f.size }));
457
+ const width = Math.max(0, ...largest.map((f) => f.path.length));
458
+ const sizeWidth = Math.max(0, ...largest.map((f) => formatBytes(f.size).length));
459
+ return largest.map((f) => ` ${f.path.padEnd(width)} ${formatBytes(f.size).padStart(sizeWidth)}`);
433
460
  }
434
461
  /** No dist at all, or no index.html at its root. Zero network calls. */
435
462
  /**
@@ -455,7 +482,7 @@ export function noDist(distDir, buildCommand) {
455
482
  "",
456
483
  " Nothing was uploaded.",
457
484
  "",
458
- ].map(escapeControl);
485
+ ].map(escapeTerminal);
459
486
  }
460
487
  export function noIndexHtml(distDir, buildCommand) {
461
488
  return [
@@ -469,7 +496,7 @@ export function noIndexHtml(distDir, buildCommand) {
469
496
  "",
470
497
  " Nothing was uploaded.",
471
498
  "",
472
- ].map(escapeControl);
499
+ ].map(escapeTerminal);
473
500
  }
474
501
  /** Flow U1 — the output is the project folder itself. */
475
502
  export function outputIsRoot(o) {
@@ -487,13 +514,13 @@ export function outputIsRoot(o) {
487
514
  "",
488
515
  " Nothing was uploaded.",
489
516
  "",
490
- ].map(escapeControl);
517
+ ].map(escapeTerminal);
491
518
  }
492
519
  /** Flow U1 — the output lies outside the project. `shown` is project-relative. */
493
520
  export function outputOutside(shown) {
494
521
  return [
495
522
  "",
496
- `✗ The build output ${escapeControl(shown)} is outside this project.`,
523
+ `✗ The build output ${escapeTerminal(shown)} is outside this project.`,
497
524
  "",
498
525
  " Publish uploads a folder inside the project. Set --dist, or build.output in",
499
526
  " vincentt.json, to the folder your build writes.",
@@ -506,7 +533,7 @@ export function outputOutside(shown) {
506
533
  export function outputInHidden(shown, hidden) {
507
534
  return [
508
535
  "",
509
- `✗ The build output ${escapeControl(shown)} is inside ${hidden}/, not a build.`,
536
+ `✗ The build output ${escapeTerminal(shown)} is inside ${escapeTerminal(hidden)}/, not a build.`,
510
537
  "",
511
538
  " Publishing it would put your repository's history or its Vincentt link at a",
512
539
  " public address. Set --dist, or build.output in vincentt.json, to the folder",
@@ -516,18 +543,30 @@ export function outputInHidden(shown, hidden) {
516
543
  "",
517
544
  ];
518
545
  }
546
+ /** Where a credential reason starts, unless a longer path pushes it right. */
547
+ const WHY_COLUMN = 40;
548
+ const CREDENTIAL_WHY = {
549
+ name: "named like a credential",
550
+ private_key: "holds a private key",
551
+ service_account: "holds a service-account key",
552
+ authorized_user: "holds a Google sign-in credential",
553
+ };
519
554
  function offenderList(paths, folder) {
520
555
  // File names and the folder prefix come from the build and the creator's
521
556
  // settings, and may carry control characters.
522
- folder = escapeControl(folder);
557
+ folder = escapeTerminal(folder);
523
558
  const shown = paths.slice(0, FINDINGS_SHOWN).map((p) => ({
524
- path: escapeControl(p.path),
525
- target: p.target === undefined ? undefined : escapeControl(p.target),
559
+ path: escapeTerminal(p.path),
560
+ target: p.target === undefined ? undefined : escapeTerminal(p.target),
561
+ why: p.why,
526
562
  }));
527
563
  const width = Math.max(...shown.map((p) => folder.length + p.path.length));
564
+ const whyColumn = Math.max(WHY_COLUMN, width + 3);
528
565
  const out = shown.map((p) => p.target !== undefined
529
566
  ? ` ${(folder + p.path).padEnd(width)} links to ${p.target}`
530
- : ` ${folder}${p.path}`);
567
+ : p.why !== undefined
568
+ ? ` ${(folder + p.path).padEnd(whyColumn)}${CREDENTIAL_WHY[p.why]}`
569
+ : ` ${folder}${p.path}`);
531
570
  if (paths.length > FINDINGS_SHOWN)
532
571
  out.push(` and ${paths.length - FINDINGS_SHOWN} more`);
533
572
  return out;
@@ -540,13 +579,64 @@ export function secretFiles(paths, output = DEFAULT_OUTPUT) {
540
579
  "",
541
580
  ...offenderList(paths, output.folder),
542
581
  "",
543
- ` Everything in ${escapeControl(output.folder)} becomes public at your address. Remove these from the build`,
582
+ ` Everything in ${escapeTerminal(output.folder)} becomes public at your address. Remove these from the build`,
544
583
  " output, rebuild, then publish.",
545
584
  "",
546
585
  " Nothing was uploaded.",
547
586
  "",
548
587
  ];
549
588
  }
589
+ /**
590
+ * Flow U1 — files whose folder resolved outside the output after the walk: a
591
+ * folder was swapped for a link while publish read it. The whole publish is
592
+ * refused, never just the file. After the header and reading line.
593
+ */
594
+ export function escapedFiles(paths, output = DEFAULT_OUTPUT) {
595
+ return [
596
+ "",
597
+ `✗ These files resolved outside ${escapeTerminal(output.folder)} while publish was reading them.`,
598
+ "",
599
+ ...offenderList(paths, output.folder),
600
+ "",
601
+ " A folder in the build output became a link to somewhere else, so publish",
602
+ " cannot vouch for what it would upload. Let your build finish, then publish",
603
+ " again.",
604
+ "",
605
+ " Nothing was uploaded.",
606
+ "",
607
+ ];
608
+ }
609
+ /** Flow U1 — credential files, from the early checks or the hash step. After the header and reading line. */
610
+ export function credentialFiles(paths, output = DEFAULT_OUTPUT) {
611
+ return [
612
+ "",
613
+ "✗ This build holds files that look like private keys.",
614
+ "",
615
+ ...offenderList(paths, output.folder),
616
+ "",
617
+ ` Everything in ${escapeTerminal(output.folder)} becomes public at your address, and a published version`,
618
+ " cannot be taken back. Move these out of the build output, rebuild, then",
619
+ " publish. A file only named like a key, such as a Keynote .key, needs a new",
620
+ " name.",
621
+ "",
622
+ " Nothing was uploaded.",
623
+ "",
624
+ ];
625
+ }
626
+ /** Flow U1 — a file that changed, became a link, or could not be read while checked or hashed. */
627
+ export function fileUnreadable(filePath, output = DEFAULT_OUTPUT) {
628
+ return [
629
+ "",
630
+ `✗ ${escapeTerminal(output.folder + filePath)} changed while publish was reading it.`,
631
+ "",
632
+ " It became a link or a different file, or could not be read, so publish",
633
+ " cannot vouch for what it would upload. Let your build finish, then publish",
634
+ " again.",
635
+ "",
636
+ " Nothing was uploaded.",
637
+ "",
638
+ ];
639
+ }
550
640
  /** Flow U1 — Git LFS pointer files. The recorded build.command, verbatim, when there is one. */
551
641
  export function lfsPointers(paths, output = DEFAULT_OUTPUT, buildCommand) {
552
642
  return [
@@ -560,7 +650,7 @@ export function lfsPointers(paths, output = DEFAULT_OUTPUT, buildCommand) {
560
650
  "",
561
651
  " git lfs pull",
562
652
  buildCommand !== undefined
563
- ? ` ${escapeControl(buildCommand)}`
653
+ ? ` ${escapeTerminal(buildCommand)}`
564
654
  : " then run your build again",
565
655
  "",
566
656
  " Nothing was uploaded.",
@@ -581,7 +671,7 @@ export function badPath(reason, offending) {
581
671
  "",
582
672
  `✗ This build ${why[reason] ?? "has a path we can't publish"}.`,
583
673
  "",
584
- ` ${escapeControl(offending)}`,
674
+ ` ${escapeTerminal(offending)}`,
585
675
  "",
586
676
  " Rename it in your source, rebuild, then publish again.",
587
677
  "",
@@ -845,7 +935,7 @@ export function publishIncomplete(address, counts, liveVersion) {
845
935
  export function badDigest(filePath, address, liveVersion) {
846
936
  return [
847
937
  "",
848
- `✗ ${filePath} changed while uploading. Rebuild and publish again.`,
938
+ `✗ ${escapeTerminal(filePath)} changed while uploading. Rebuild and publish again.`,
849
939
  "",
850
940
  ...unchangedLines(address, liveVersion),
851
941
  "",
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { type CredentialJsonType } from "./keyScan.js";
1
2
  import { type PathRejection } from "./paths.js";
2
3
  export interface ManifestEntry {
3
4
  /** dist-relative, forward slashes, no leading `/`, normalized. */
@@ -10,13 +11,16 @@ export interface Manifest {
10
11
  files: ManifestEntry[];
11
12
  totalBytes: number;
12
13
  }
13
- export type ManifestReason = PathRejection | "no_dist" | "output_is_repo_root" | "output_outside_repo" | "output_in_hidden" | "secret_path" | "lfs_pointer";
14
- /** An output-relative path the leak or LFS check refused. */
14
+ export type ManifestReason = PathRejection | "no_dist" | "output_is_repo_root" | "output_outside_repo" | "output_in_hidden" | "secret_path" | "path_escaped" | "credential_file" | "lfs_pointer" | "file_unreadable";
15
+ export type CredentialWhy = "name" | "private_key" | CredentialJsonType;
16
+ /** An output-relative path the leak, credential or LFS check refused. */
15
17
  export interface Offender {
16
18
  /** Output-relative, POSIX. A refused folder carries a trailing `/`. */
17
19
  path: string;
18
20
  /** For a symlink: where it resolves, repo-relative inside and absolute outside. */
19
21
  target?: string;
22
+ /** For a credential file: what made it one. */
23
+ why?: CredentialWhy;
20
24
  }
21
25
  export interface ManifestErrorDetail {
22
26
  paths?: Offender[];
@@ -36,33 +40,70 @@ export declare class ManifestError extends Error {
36
40
  };
37
41
  constructor(reason: ManifestReason, offendingPath: string, message: string, detail?: ManifestErrorDetail);
38
42
  }
43
+ /** A file the walk passed: what the hash step must find again at the same path. */
44
+ export interface WalkedFile {
45
+ /** Output-relative, POSIX. */
46
+ rel: string;
47
+ /** Absolute, under the real output folder. */
48
+ abs: string;
49
+ size: number;
50
+ dev: number;
51
+ ino: number;
52
+ }
39
53
  export interface BuildManifestOptions {
40
54
  /**
41
- * The directory holding the binding. When given, the output is refused before
42
- * any hashing if it is the repo root, lies outside it, holds a dotted name
43
- * outside the two allowed ones, holds a symlink that resolves outside the root
44
- * or onto a dotted path, or holds a Git LFS pointer. All of it is local.
55
+ * The directory holding the binding. Required: there is no unchecked
56
+ * manifest. The output is refused before any hashing if it is the repo root,
57
+ * lies outside it, holds a dotted name outside the two allowed ones, holds a
58
+ * symlink that resolves outside the root or onto a dotted path, holds a
59
+ * credential file, or holds a Git LFS pointer. All of it is local.
60
+ */
61
+ repoRoot: string;
62
+ /**
63
+ * Test-only: runs after the checks and before the hash step, so a test can
64
+ * change a checked file. Never set by the command.
65
+ */
66
+ beforeHash?: (files: readonly WalkedFile[]) => Promise<void> | void;
67
+ /**
68
+ * Test-only: runs before the walk reads each folder, so a test can swap a
69
+ * folder mid-walk. Never set by the command.
45
70
  */
46
- repoRoot?: string;
71
+ beforeReadDir?: (dir: string) => Promise<void> | void;
72
+ }
73
+ /** Test-only hooks into the checks. */
74
+ export interface CheckOutputHooks {
75
+ beforeReadDir?: (dir: string) => Promise<void> | void;
47
76
  }
48
77
  /** A Git LFS pointer file: at most 1 KB, the spec's exact first line, and an oid line. */
49
- export declare function isLfsPointer(absPath: string, size: number): Promise<boolean>;
78
+ export declare function isLfsPointer(head: Uint8Array, size: number): boolean;
50
79
  /**
51
- * The leak and LFS checks, in that order, before anything is hashed or sent.
52
- * Returns the output-relative file list the checks passed, which is exactly the
53
- * list the manifest is built from: what is checked is what is uploaded.
80
+ * The early pass's read: the first `min(size, 4096)` bytes, or the whole file
81
+ * for a `.json` of at most 64 KB. Opens without following links.
54
82
  */
55
- export declare function checkOutput(distDir: string, repoRoot: string): Promise<{
56
- rel: string;
83
+ export declare function readHead(file: WalkedFile): Promise<Buffer>;
84
+ /**
85
+ * The leak, credential and LFS checks, in that order, before anything is hashed
86
+ * or sent. Returns the file list the checks passed, which is exactly the list
87
+ * the manifest is built from: what is checked is what is uploaded.
88
+ */
89
+ export declare function checkOutput(distDir: string, repoRoot: string, hooks?: CheckOutputHooks): Promise<WalkedFile[]>;
90
+ export interface HashResult {
91
+ /** base64 std, the exact form `x-amz-checksum-sha256` wants. */
92
+ sha256: string;
57
93
  size: number;
58
- }[]>;
59
- /** Recursively list files under `dir` as dir-relative POSIX paths. Symlinks are not followed. */
60
- export declare function walkDist(dir: string, base?: string): Promise<string[]>;
61
- /** sha256 of a file, base64 std — the exact form `x-amz-checksum-sha256` wants. */
62
- export declare function hashFile(absPath: string): Promise<string>;
94
+ /** Set when the hashed bytes hold a private key or a Google credential. */
95
+ why?: CredentialWhy;
96
+ }
97
+ /**
98
+ * Hash a walked file and re-run the content rules over the exact bytes hashed.
99
+ * The file is opened without following links and must still be the regular
100
+ * file the walk saw. Every read error throws `file_unreadable`; only a JSON
101
+ * parse failure is "not a match".
102
+ */
103
+ export declare function hashAndScan(absPath: string, walked: WalkedFile): Promise<HashResult>;
63
104
  /**
64
105
  * Build the manifest for a dist directory. Every path is normalized before it is
65
106
  * hashed, so the string this returns is the string the API validates, signs, and
66
107
  * later lists — there is no second form anywhere.
67
108
  */
68
- export declare function buildManifest(distDir: string, opts?: BuildManifestOptions): Promise<Manifest>;
109
+ export declare function buildManifest(distDir: string, opts: BuildManifestOptions): Promise<Manifest>;