@vincentt-xr/cli 0.21.0 → 0.23.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 (51) hide show
  1. package/MIGRATION.md +26 -0
  2. package/dist/cli/args.js +2 -0
  3. package/dist/cli/index.js +50 -8
  4. package/dist/cli/usage-text.js +10 -1
  5. package/dist/cli/verb-surface.d.ts +2 -1
  6. package/dist/cli/verb-surface.js +5 -0
  7. package/dist/dx/command.d.ts +74 -18
  8. package/dist/dx/command.js +137 -86
  9. package/dist/dx/consent.d.ts +25 -6
  10. package/dist/dx/consent.js +58 -25
  11. package/dist/dx/enablement.d.ts +63 -11
  12. package/dist/dx/enablement.js +93 -21
  13. package/dist/dx/journal.d.ts +3 -0
  14. package/dist/dx/journal.js +6 -1
  15. package/dist/dx/report.d.ts +4 -24
  16. package/dist/dx/report.js +32 -25
  17. package/dist/dx/skeleton.js +3 -0
  18. package/dist/editor/app.d.ts +61 -0
  19. package/dist/editor/app.js +180 -0
  20. package/dist/editor/copy.d.ts +22 -0
  21. package/dist/editor/copy.js +95 -0
  22. package/dist/editor/handoffs.d.ts +12 -0
  23. package/dist/editor/handoffs.js +93 -0
  24. package/dist/editor/launcher.d.ts +86 -0
  25. package/dist/editor/launcher.js +255 -0
  26. package/dist/editor/localFiles.d.ts +31 -0
  27. package/dist/editor/localFiles.js +100 -0
  28. package/dist/editor/reads.d.ts +47 -0
  29. package/dist/editor/reads.js +269 -0
  30. package/dist/editor/text.d.ts +7 -0
  31. package/dist/editor/text.js +58 -0
  32. package/dist/editor/tools.d.ts +5 -0
  33. package/dist/editor/tools.js +20 -0
  34. package/dist/lib/version.d.ts +28 -2
  35. package/dist/lib/version.js +30 -3
  36. package/dist/preview/command.js +2 -0
  37. package/dist/preview/copy.d.ts +5 -0
  38. package/dist/preview/copy.js +15 -0
  39. package/dist/preview/runner.d.ts +6 -0
  40. package/dist/preview/runner.js +1 -0
  41. package/dist/publish/command.js +5 -2
  42. package/dist/publish/copy.d.ts +6 -0
  43. package/dist/publish/copy.js +27 -6
  44. package/dist/publish/manifest.d.ts +11 -2
  45. package/dist/publish/manifest.js +45 -9
  46. package/dist/publish/upload.d.ts +10 -0
  47. package/dist/publish/upload.js +23 -0
  48. package/dist/shared/config.d.ts +6 -6
  49. package/dist/tunnel/client.d.ts +11 -3
  50. package/dist/tunnel/client.js +11 -8
  51. package/package.json +2 -2
@@ -404,7 +404,7 @@ export function tooLarge(v) {
404
404
  "",
405
405
  "✗ One file is too large to publish.",
406
406
  "",
407
- ...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)]),
408
408
  "",
409
409
  " Compress or externalise it, rebuild, then publish again.",
410
410
  "",
@@ -453,9 +453,10 @@ function alignedPair(a, b) {
453
453
  ];
454
454
  }
455
455
  function largestBlock(v) {
456
- const width = Math.max(0, ...v.largest.map((f) => f.path.length));
457
- const sizeWidth = Math.max(0, ...v.largest.map((f) => formatBytes(f.size).length));
458
- 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)}`);
459
460
  }
460
461
  /** No dist at all, or no index.html at its root. Zero network calls. */
461
462
  /**
@@ -532,7 +533,7 @@ export function outputOutside(shown) {
532
533
  export function outputInHidden(shown, hidden) {
533
534
  return [
534
535
  "",
535
- `✗ The build output ${escapeTerminal(shown)} is inside ${hidden}/, not a build.`,
536
+ `✗ The build output ${escapeTerminal(shown)} is inside ${escapeTerminal(hidden)}/, not a build.`,
536
537
  "",
537
538
  " Publishing it would put your repository's history or its Vincentt link at a",
538
539
  " public address. Set --dist, or build.output in vincentt.json, to the folder",
@@ -585,6 +586,26 @@ export function secretFiles(paths, output = DEFAULT_OUTPUT) {
585
586
  "",
586
587
  ];
587
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
+ }
588
609
  /** Flow U1 — credential files, from the early checks or the hash step. After the header and reading line. */
589
610
  export function credentialFiles(paths, output = DEFAULT_OUTPUT) {
590
611
  return [
@@ -914,7 +935,7 @@ export function publishIncomplete(address, counts, liveVersion) {
914
935
  export function badDigest(filePath, address, liveVersion) {
915
936
  return [
916
937
  "",
917
- `✗ ${filePath} changed while uploading. Rebuild and publish again.`,
938
+ `✗ ${escapeTerminal(filePath)} changed while uploading. Rebuild and publish again.`,
918
939
  "",
919
940
  ...unchangedLines(address, liveVersion),
920
941
  "",
@@ -11,7 +11,7 @@ export interface Manifest {
11
11
  files: ManifestEntry[];
12
12
  totalBytes: number;
13
13
  }
14
- export type ManifestReason = PathRejection | "no_dist" | "output_is_repo_root" | "output_outside_repo" | "output_in_hidden" | "secret_path" | "credential_file" | "lfs_pointer" | "file_unreadable";
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
15
  export type CredentialWhy = "name" | "private_key" | CredentialJsonType;
16
16
  /** An output-relative path the leak, credential or LFS check refused. */
17
17
  export interface Offender {
@@ -64,6 +64,15 @@ export interface BuildManifestOptions {
64
64
  * change a checked file. Never set by the command.
65
65
  */
66
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.
70
+ */
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;
67
76
  }
68
77
  /** A Git LFS pointer file: at most 1 KB, the spec's exact first line, and an oid line. */
69
78
  export declare function isLfsPointer(head: Uint8Array, size: number): boolean;
@@ -77,7 +86,7 @@ export declare function readHead(file: WalkedFile): Promise<Buffer>;
77
86
  * or sent. Returns the file list the checks passed, which is exactly the list
78
87
  * the manifest is built from: what is checked is what is uploaded.
79
88
  */
80
- export declare function checkOutput(distDir: string, repoRoot: string): Promise<WalkedFile[]>;
89
+ export declare function checkOutput(distDir: string, repoRoot: string, hooks?: CheckOutputHooks): Promise<WalkedFile[]>;
81
90
  export interface HashResult {
82
91
  /** base64 std, the exact form `x-amz-checksum-sha256` wants. */
83
92
  sha256: string;
@@ -55,15 +55,23 @@ function isJsonName(rel) {
55
55
  * symlink is resolved and judged against the real repo root, and a name on the
56
56
  * credential list is recorded apart (a folder once, never descended).
57
57
  */
58
- async function walkOutput(outReal, rootReal) {
58
+ async function walkOutput(outReal, rootReal, hooks) {
59
59
  const walk = { files: [], offenders: [], credentialNames: [] };
60
60
  let seq = 0;
61
+ const relOf = (full) => path.relative(outReal, full).split(path.sep).join("/");
62
+ // A read error's message carries the raw absolute path. It becomes a refusal
63
+ // naming the output-relative path, which the copy escapes.
64
+ const unreadable = (rel) => new ManifestError("file_unreadable", rel, "a path could not be read");
61
65
  const visit = async (dir) => {
62
- const entries = await fs.readdir(dir, { withFileTypes: true });
66
+ await hooks.beforeReadDir?.(dir);
67
+ const dirRel = dir === outReal ? "" : `${relOf(dir)}/`;
68
+ const entries = await fs
69
+ .readdir(dir, { withFileTypes: true })
70
+ .catch(() => Promise.reject(unreadable(dirRel)));
63
71
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
64
72
  for (const entry of entries) {
65
73
  const full = path.join(dir, entry.name);
66
- const rel = path.relative(outReal, full).split(path.sep).join("/");
74
+ const rel = relOf(full);
67
75
  const atRoot = dir === outReal;
68
76
  const lower = entry.name.toLowerCase();
69
77
  if (entry.name.startsWith(".")) {
@@ -101,7 +109,7 @@ async function walkOutput(outReal, rootReal) {
101
109
  await visit(full);
102
110
  }
103
111
  else if (entry.isFile()) {
104
- const stat = await fs.lstat(full);
112
+ const stat = await fs.lstat(full).catch(() => Promise.reject(unreadable(rel)));
105
113
  const at = seq++;
106
114
  if (nameMatched) {
107
115
  walk.credentialNames.push({ seq: at, offender: { path: rel, why: "name" } });
@@ -121,6 +129,25 @@ async function walkOutput(outReal, rootReal) {
121
129
  await visit(outReal);
122
130
  return walk;
123
131
  }
132
+ /**
133
+ * Files whose real parent folder is not inside the real output. The walk reads
134
+ * a folder by path, so a folder swapped for a symlink mid-walk is walked through
135
+ * the link; this catches it after the fact. A parent that cannot be resolved
136
+ * counts as escaped: it is never assumed inside.
137
+ */
138
+ async function escapedFiles(files, outReal) {
139
+ const parents = new Map();
140
+ const inside = (dir) => {
141
+ let p = parents.get(dir);
142
+ if (p === undefined) {
143
+ p = fs.realpath(dir).then((real) => real === outReal || isInside(real, outReal), () => false);
144
+ parents.set(dir, p);
145
+ }
146
+ return p;
147
+ };
148
+ const verdicts = await mapLimit(files, READ_CONCURRENCY, (f) => inside(path.dirname(f.abs)));
149
+ return files.filter((_, i) => !verdicts[i]).map((f) => ({ path: f.rel }));
150
+ }
124
151
  /** Where a symlink lands: its realpath, or its lexical target when it dangles. */
125
152
  async function symlinkTarget(link) {
126
153
  try {
@@ -235,7 +262,7 @@ function forbiddenSegmentIn(rel) {
235
262
  * or sent. Returns the file list the checks passed, which is exactly the list
236
263
  * the manifest is built from: what is checked is what is uploaded.
237
264
  */
238
- export async function checkOutput(distDir, repoRoot) {
265
+ export async function checkOutput(distDir, repoRoot, hooks = {}) {
239
266
  const rootReal = await fs.realpath(repoRoot).catch(() => path.resolve(repoRoot));
240
267
  const lexical = path.resolve(distDir);
241
268
  const lexicalRoot = path.resolve(repoRoot);
@@ -253,7 +280,7 @@ export async function checkOutput(distDir, repoRoot) {
253
280
  if (err.code === "ENOENT") {
254
281
  throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
255
282
  }
256
- throw err;
283
+ throw new ManifestError("file_unreadable", "", "the output could not be read");
257
284
  }
258
285
  if (outReal === rootReal) {
259
286
  throw new ManifestError("output_is_repo_root", distDir, `${distDir} resolves to the repo root`);
@@ -266,14 +293,21 @@ export async function checkOutput(distDir, repoRoot) {
266
293
  if (hiddenParent !== undefined) {
267
294
  throw new ManifestError("output_in_hidden", hiddenParent, `${distDir} is inside ${hiddenParent}`);
268
295
  }
269
- if (!(await fs.stat(outReal)).isDirectory()) {
296
+ const outStat = await fs.stat(outReal).catch(() => {
297
+ throw new ManifestError("file_unreadable", "", "the output could not be read");
298
+ });
299
+ if (!outStat.isDirectory()) {
270
300
  throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
271
301
  }
272
- const walk = await walkOutput(outReal, rootReal);
302
+ const walk = await walkOutput(outReal, rootReal, hooks);
273
303
  const stats = {
274
304
  files: walk.files.length,
275
305
  bytes: walk.files.reduce((n, f) => n + f.size, 0),
276
306
  };
307
+ const escaped = await escapedFiles(walk.files, outReal);
308
+ if (escaped.length > 0) {
309
+ throw new ManifestError("path_escaped", escaped[0].path, "files resolved outside the output", { paths: escaped, stats });
310
+ }
277
311
  if (walk.offenders.length > 0) {
278
312
  throw new ManifestError("secret_path", walk.offenders[0].path, "hidden paths in the output", {
279
313
  paths: walk.offenders,
@@ -385,7 +419,9 @@ export async function hashAndScan(absPath, walked) {
385
419
  export async function buildManifest(distDir, opts) {
386
420
  // The list the checks passed IS the upload list. A second walk could see a
387
421
  // file created after the checks ran.
388
- const walked = await checkOutput(distDir, opts.repoRoot);
422
+ const walked = await checkOutput(distDir, opts.repoRoot, {
423
+ beforeReadDir: opts.beforeReadDir,
424
+ });
389
425
  const normalized = [];
390
426
  for (const file of walked) {
391
427
  const result = normalizePath(file.rel);
@@ -30,6 +30,16 @@ export declare class UploadTargetRejected extends Error {
30
30
  readonly path: string;
31
31
  constructor(path: string);
32
32
  }
33
+ /**
34
+ * A target's signed checksum is not the one this run's manifest holds for that
35
+ * path. The PUT reopens the file by path, so the signed checksum is what makes
36
+ * "checked is uploaded" hold; a target that would sign anything else is refused
37
+ * before any byte is sent.
38
+ */
39
+ export declare class UploadChecksumMismatch extends Error {
40
+ readonly path: string;
41
+ constructor(path: string);
42
+ }
33
43
  export interface UploadProgress {
34
44
  filesDone: number;
35
45
  filesTotal: number;
@@ -58,6 +58,25 @@ export class UploadTargetRejected extends Error {
58
58
  this.name = "UploadTargetRejected";
59
59
  }
60
60
  }
61
+ /**
62
+ * A target's signed checksum is not the one this run's manifest holds for that
63
+ * path. The PUT reopens the file by path, so the signed checksum is what makes
64
+ * "checked is uploaded" hold; a target that would sign anything else is refused
65
+ * before any byte is sent.
66
+ */
67
+ export class UploadChecksumMismatch extends Error {
68
+ path;
69
+ constructor(path) {
70
+ super(`the upload target ${path} does not carry this build's checksum`);
71
+ this.path = path;
72
+ this.name = "UploadChecksumMismatch";
73
+ }
74
+ }
75
+ const CHECKSUM_HEADER = "x-amz-checksum-sha256";
76
+ function signedChecksum(headers) {
77
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === CHECKSUM_HEADER);
78
+ return key === undefined ? undefined : headers[key];
79
+ }
61
80
  const DEFAULT_CONCURRENCY = 6;
62
81
  const DEFAULT_MAX_ATTEMPTS = 5;
63
82
  const BACKOFF_BASE_MS = 500;
@@ -114,6 +133,10 @@ export async function uploadAll(opts) {
114
133
  const foreign = opts.targets.find((t) => !sizes.has(t.path));
115
134
  if (foreign !== undefined)
116
135
  throw new UploadTargetRejected(foreign.path);
136
+ const sums = new Map(opts.manifest.map((f) => [f.path, f.sha256]));
137
+ const mismatched = opts.targets.find((t) => signedChecksum(t.headers) !== sums.get(t.path));
138
+ if (mismatched !== undefined)
139
+ throw new UploadChecksumMismatch(mismatched.path);
117
140
  const bytesTotal = opts.targets.reduce((n, t) => n + (sizes.get(t.path) ?? 0), 0);
118
141
  const queue = [...opts.targets];
119
142
  const retry = {
@@ -8,14 +8,14 @@ export interface MachineConfig {
8
8
  /** Org slug commands act in when no --org flag or VINCENTT_ORG env is given. */
9
9
  activeOrg: string;
10
10
  /**
11
- * Durable authorization for unattended `dx --send`. Written only by
12
- * `vincentt dx enable`, which requires a TTY. Cleared by `disable` and by `logout`.
11
+ * This machine's copy of the account's unattended-send grant. Written by
12
+ * `vincentt dx enable` only after the account grant succeeded, on either path.
13
+ * Authorizes a send on its own ONLY when GET /me cannot be read. Cleared by
14
+ * `disable`, by `logout`, and by any /me answer that is not a grant.
13
15
  */
14
16
  dxSendEnabled?: boolean;
15
- /**
16
- * ISO8601 UTC, when enablement was granted. Its AGE is the mitigation for having
17
- * no expiry, so an unparseable value fails closed rather than printing.
18
- */
17
+ /** ISO8601 UTC: the account's dxReportsConsentAt on the terminal path; the CLI's
18
+ * clock on the browser path, whose callback carries no date. */
19
19
  dxSendEnabledAt?: string;
20
20
  }
21
21
  /**
@@ -157,6 +157,13 @@ export interface TunnelClientOptions {
157
157
  * this preview, 410 the session is gone. They map to different creator actions.
158
158
  */
159
159
  readonly onReconnectRefused?: (status: number) => void;
160
+ /**
161
+ * The edge broke the agreed wire: a malformed 101 caps header, a CONTROL
162
+ * frame this connection cannot read, or a fatal OPEN. Only an edge bug causes
163
+ * it, and a redial
164
+ * reaches the same edge, so the client makes no further dial after this.
165
+ */
166
+ readonly onProtocolViolation?: () => void;
160
167
  /** Injectable for tests; never a real socket in a unit test. */
161
168
  readonly connect?: (url: string, headers: Record<string, string>) => WebSocket;
162
169
  }
@@ -232,10 +239,11 @@ export declare class TunnelClient {
232
239
  private open;
233
240
  private onClosed;
234
241
  /**
235
- * Close this connection for a violation the edge alone could cause. The
236
- * ordinary close path then decides whether to redial.
242
+ * End the preview for a violation the edge alone could cause. A redial reaches
243
+ * the same edge and gets the same answer: before this was terminal, a
244
+ * persistently malformed 101 redialled every 8 seconds indefinitely.
237
245
  */
238
- private protocolViolation;
246
+ private fatalViolation;
239
247
  private startPing;
240
248
  private stopPing;
241
249
  private send;
@@ -281,10 +281,10 @@ export class TunnelClient {
281
281
  // The agreed set fixes this connection's grammar. Absent is the base
282
282
  // protocol (an edge that predates negotiation); a malformed answer, or
283
283
  // one naming a token this client did not offer, is the edge
284
- // misbehaving and closes the connection.
284
+ // misbehaving and ends the preview.
285
285
  const agreed = parseAgreedCaps(res.headers[CAPS_HEADER], res.rawHeaders);
286
286
  if (agreed === null) {
287
- this.protocolViolation(ws);
287
+ this.fatalViolation(ws);
288
288
  return;
289
289
  }
290
290
  this.caps = agreed;
@@ -382,12 +382,15 @@ export class TunnelClient {
382
382
  }, delay);
383
383
  }
384
384
  /**
385
- * Close this connection for a violation the edge alone could cause. The
386
- * ordinary close path then decides whether to redial.
385
+ * End the preview for a violation the edge alone could cause. A redial reaches
386
+ * the same edge and gets the same answer: before this was terminal, a
387
+ * persistently malformed 101 redialled every 8 seconds indefinitely.
387
388
  */
388
- protocolViolation(ws) {
389
- if (this.ws !== ws)
389
+ fatalViolation(ws) {
390
+ if (ws === null || this.ws !== ws || this.closed)
390
391
  return;
392
+ this.closed = true;
393
+ this.opts.onProtocolViolation?.();
391
394
  ws.close();
392
395
  }
393
396
  startPing() {
@@ -453,7 +456,7 @@ export class TunnelClient {
453
456
  catch {
454
457
  // A CONTROL this connection cannot read (malformed, unknown, or a
455
458
  // kind it did not agree) is fatal: only the edge writes stream 0.
456
- this.ws?.close();
459
+ this.fatalViolation(this.ws);
457
460
  return;
458
461
  }
459
462
  if (ctrl.kind === "viewer_gone") {
@@ -478,7 +481,7 @@ export class TunnelClient {
478
481
  }
479
482
  catch (err) {
480
483
  if (err instanceof ProtocolError && err.fatal) {
481
- this.ws?.close();
484
+ this.fatalViolation(this.ws);
482
485
  return;
483
486
  }
484
487
  this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "qrcode": "^1.5.4",
29
29
  "ws": "^8.18.0",
30
30
  "zod": "^3.23.8",
31
- "@vincentt-xr/harness": "3.0.0"
31
+ "@vincentt-xr/harness": "3.1.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/qrcode": "^1.5.5",