@vincentt-xr/cli 0.1.5 → 0.3.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 (92) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +14 -0
  3. package/dist/cli/args.js +51 -0
  4. package/dist/cli/index.js +416 -100
  5. package/dist/lib/backend.d.ts +44 -20
  6. package/dist/lib/backend.js +98 -51
  7. package/dist/lib/diagnostics.d.ts +27 -2
  8. package/dist/lib/diagnostics.js +73 -8
  9. package/dist/lib/initCommand.d.ts +14 -0
  10. package/dist/lib/initCommand.js +75 -0
  11. package/dist/lib/lifecycle.d.ts +85 -19
  12. package/dist/lib/lifecycle.js +133 -130
  13. package/dist/lib/org.d.ts +29 -0
  14. package/dist/lib/org.js +45 -0
  15. package/dist/lib/projects.d.ts +82 -0
  16. package/dist/lib/projects.js +217 -0
  17. package/dist/lib/relayInfo.d.ts +41 -3
  18. package/dist/lib/relayInfo.js +94 -0
  19. package/dist/lib/whoami.d.ts +11 -0
  20. package/dist/lib/whoami.js +38 -0
  21. package/dist/login/authBackend.d.ts +30 -0
  22. package/dist/login/authBackend.js +80 -0
  23. package/dist/login/guard.d.ts +13 -0
  24. package/dist/login/guard.js +17 -0
  25. package/dist/login/loginCommand.d.ts +39 -0
  26. package/dist/login/loginCommand.js +72 -0
  27. package/dist/login/pkce.d.ts +47 -0
  28. package/dist/login/pkce.js +106 -0
  29. package/dist/login/pkceLogin.d.ts +53 -0
  30. package/dist/login/pkceLogin.js +152 -0
  31. package/dist/mcp/server.js +52 -25
  32. package/dist/preview/api.d.ts +84 -0
  33. package/dist/preview/api.js +174 -0
  34. package/dist/preview/command.d.ts +96 -0
  35. package/dist/preview/command.js +276 -0
  36. package/dist/preview/copy.d.ts +209 -0
  37. package/dist/preview/copy.js +444 -0
  38. package/dist/preview/index.d.ts +2 -3
  39. package/dist/preview/index.js +5 -6
  40. package/dist/preview/proxy.d.ts +12 -1
  41. package/dist/preview/proxy.js +17 -7
  42. package/dist/preview/qr.d.ts +30 -0
  43. package/dist/preview/qr.js +89 -0
  44. package/dist/preview/runner.d.ts +155 -30
  45. package/dist/preview/runner.js +219 -68
  46. package/dist/preview/stream.d.ts +52 -0
  47. package/dist/preview/stream.js +171 -0
  48. package/dist/preview/viewers.d.ts +113 -0
  49. package/dist/preview/viewers.js +242 -0
  50. package/dist/publish/api.d.ts +67 -0
  51. package/dist/publish/api.js +96 -0
  52. package/dist/publish/bounds.d.ts +25 -0
  53. package/dist/publish/bounds.js +53 -0
  54. package/dist/publish/classify.d.ts +51 -0
  55. package/dist/publish/classify.js +138 -0
  56. package/dist/publish/command.d.ts +27 -0
  57. package/dist/publish/command.js +328 -0
  58. package/dist/publish/consoleVerbs.d.ts +17 -0
  59. package/dist/publish/consoleVerbs.js +26 -0
  60. package/dist/publish/copy.d.ts +180 -0
  61. package/dist/publish/copy.js +606 -0
  62. package/dist/publish/defaults.d.ts +16 -0
  63. package/dist/publish/defaults.js +52 -0
  64. package/dist/publish/manifest.d.ts +27 -0
  65. package/dist/publish/manifest.js +81 -0
  66. package/dist/publish/paths.d.ts +25 -0
  67. package/dist/publish/paths.js +78 -0
  68. package/dist/publish/progress.d.ts +17 -0
  69. package/dist/publish/progress.js +45 -0
  70. package/dist/publish/prompt.d.ts +4 -0
  71. package/dist/publish/prompt.js +31 -0
  72. package/dist/publish/qa.f4.fixtures.d.ts +25 -0
  73. package/dist/publish/qa.f4.fixtures.js +140 -0
  74. package/dist/publish/upload.d.ts +30 -0
  75. package/dist/publish/upload.js +93 -0
  76. package/dist/relay/server.d.ts +32 -0
  77. package/dist/relay/server.js +80 -12
  78. package/dist/relay/store.d.ts +32 -3
  79. package/dist/relay/store.js +76 -22
  80. package/dist/relay/testers.d.ts +90 -0
  81. package/dist/relay/testers.js +144 -0
  82. package/dist/shared/authError.d.ts +1 -0
  83. package/dist/shared/authError.js +11 -0
  84. package/dist/shared/config.d.ts +73 -5
  85. package/dist/shared/config.js +133 -20
  86. package/package.json +2 -3
  87. package/dist/login/login.d.ts +0 -34
  88. package/dist/login/login.js +0 -148
  89. package/dist/preview/cloudflared.d.ts +0 -13
  90. package/dist/preview/cloudflared.js +0 -46
  91. package/dist/preview/tunnel.d.ts +0 -14
  92. package/dist/preview/tunnel.js +0 -28
@@ -0,0 +1,27 @@
1
+ import { type PathRejection } from "./paths.js";
2
+ export interface ManifestEntry {
3
+ /** dist-relative, forward slashes, no leading `/`, normalized. */
4
+ path: string;
5
+ size: number;
6
+ /** base64 std. */
7
+ sha256: string;
8
+ }
9
+ export interface Manifest {
10
+ files: ManifestEntry[];
11
+ totalBytes: number;
12
+ }
13
+ export declare class ManifestError extends Error {
14
+ readonly reason: PathRejection | "no_dist";
15
+ readonly offendingPath: string;
16
+ constructor(reason: PathRejection | "no_dist", offendingPath: string, message: string);
17
+ }
18
+ /** Recursively list files under `dir` as dir-relative POSIX paths. Symlinks are not followed. */
19
+ export declare function walkDist(dir: string, base?: string): Promise<string[]>;
20
+ /** sha256 of a file, base64 std — the exact form `x-amz-checksum-sha256` wants. */
21
+ export declare function hashFile(absPath: string): Promise<string>;
22
+ /**
23
+ * Build the manifest for a dist directory. Every path is normalized before it is
24
+ * hashed, so the string this returns is the string the API validates, signs, and
25
+ * later lists — there is no second form anywhere.
26
+ */
27
+ export declare function buildManifest(distDir: string): Promise<Manifest>;
@@ -0,0 +1,81 @@
1
+ // The local manifest: one {path, size, sha256} per file under dist/.
2
+ //
3
+ // sha256 IS BASE64-STD, NOT HEX. It is fed verbatim into the presigned PUT's
4
+ // signed `x-amz-checksum-sha256` header, and hex there produces a BadDigest on
5
+ // every single PUT while both repos' unit suites stay green (§2.7's first
6
+ // contract trap).
7
+ import { createHash } from "node:crypto";
8
+ import { createReadStream, promises as fs } from "node:fs";
9
+ import path from "node:path";
10
+ import { normalizePath, validatePathSet } from "./paths.js";
11
+ export class ManifestError extends Error {
12
+ reason;
13
+ offendingPath;
14
+ constructor(reason, offendingPath, message) {
15
+ super(message);
16
+ this.reason = reason;
17
+ this.offendingPath = offendingPath;
18
+ this.name = "ManifestError";
19
+ }
20
+ }
21
+ /** Recursively list files under `dir` as dir-relative POSIX paths. Symlinks are not followed. */
22
+ export async function walkDist(dir, base = dir) {
23
+ let entries;
24
+ try {
25
+ entries = await fs.readdir(dir, { withFileTypes: true });
26
+ }
27
+ catch (err) {
28
+ if (err.code === "ENOENT") {
29
+ throw new ManifestError("no_dist", dir, `no dist at ${dir}`);
30
+ }
31
+ throw err;
32
+ }
33
+ const out = [];
34
+ for (const entry of entries) {
35
+ const full = path.join(dir, entry.name);
36
+ if (entry.isDirectory())
37
+ out.push(...(await walkDist(full, base)));
38
+ else if (entry.isFile()) {
39
+ out.push(path.relative(base, full).split(path.sep).join("/"));
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+ /** sha256 of a file, base64 std — the exact form `x-amz-checksum-sha256` wants. */
45
+ export async function hashFile(absPath) {
46
+ const hash = createHash("sha256");
47
+ const stream = createReadStream(absPath);
48
+ for await (const chunk of stream)
49
+ hash.update(chunk);
50
+ return hash.digest("base64");
51
+ }
52
+ /**
53
+ * Build the manifest for a dist directory. Every path is normalized before it is
54
+ * hashed, so the string this returns is the string the API validates, signs, and
55
+ * later lists — there is no second form anywhere.
56
+ */
57
+ export async function buildManifest(distDir) {
58
+ const raw = await walkDist(distDir);
59
+ const normalized = [];
60
+ for (const rel of raw) {
61
+ const result = normalizePath(rel);
62
+ if (!result.ok) {
63
+ throw new ManifestError(result.reason, rel, `${rel}: ${result.reason}`);
64
+ }
65
+ normalized.push({ rel, norm: result.path });
66
+ }
67
+ const pathError = validatePathSet(normalized.map((n) => n.norm));
68
+ if (pathError) {
69
+ throw new ManifestError(pathError.reason, pathError.path, `${pathError.path}: ${pathError.reason}`);
70
+ }
71
+ const files = [];
72
+ let totalBytes = 0;
73
+ for (const { rel, norm } of normalized) {
74
+ const abs = path.join(distDir, rel);
75
+ const stat = await fs.stat(abs);
76
+ files.push({ path: norm, size: stat.size, sha256: await hashFile(abs) });
77
+ totalBytes += stat.size;
78
+ }
79
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
80
+ return { files, totalBytes };
81
+ }
@@ -0,0 +1,25 @@
1
+ /** The closed reason enum the API also emits. Shared so both surfaces name a rejection identically. */
2
+ export type PathRejection = "path_traversal" | "absolute_path" | "duplicate_path" | "case_duplicate" | "no_index_html" | "bad_encoding" | "reserved_path";
3
+ export interface PathError {
4
+ reason: PathRejection;
5
+ path: string;
6
+ }
7
+ export interface NormalizedPath {
8
+ ok: true;
9
+ path: string;
10
+ }
11
+ export type NormalizeResult = NormalizedPath | {
12
+ ok: false;
13
+ reason: PathRejection;
14
+ };
15
+ /**
16
+ * Normalize a dist-relative path, then check it. `a/./b.js` normalizes to
17
+ * `a/b.js` and is legal; `a/../../b.js` normalizes out of the tree and is not.
18
+ */
19
+ export declare function normalizePath(raw: string): NormalizeResult;
20
+ /**
21
+ * Validate a whole set of normalized paths. The manifest is rejected as a WHOLE
22
+ * — never a subset — because a partial accept would publish a tree the creator
23
+ * did not build.
24
+ */
25
+ export declare function validatePathSet(paths: string[]): PathError | undefined;
@@ -0,0 +1,78 @@
1
+ // The dist-relative path rules, mirrored LOCALLY from the API's validator
2
+ // (implementation.md §2.4.1) so the creator fails fast and local rather than
3
+ // hashing 600 MB and learning from a 400.
4
+ //
5
+ // NORMALIZE THEN CHECK. A validator that checks the submitted string and stores a
6
+ // different one has a seam between what it approved and what it signed; here the
7
+ // normalized form is the only form that leaves this module.
8
+ const MAX_PATH_BYTES = 1024;
9
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/;
10
+ const LONE_SURROGATE = /[\ud800-\udfff]/;
11
+ const SURROGATE_PAIR = /[\ud800-\udbff][\udc00-\udfff]/g;
12
+ /**
13
+ * Normalize a dist-relative path, then check it. `a/./b.js` normalizes to
14
+ * `a/b.js` and is legal; `a/../../b.js` normalizes out of the tree and is not.
15
+ */
16
+ export function normalizePath(raw) {
17
+ if (raw.includes("\\"))
18
+ return { ok: false, reason: "path_traversal" };
19
+ if (raw.startsWith("/"))
20
+ return { ok: false, reason: "absolute_path" };
21
+ if (/^[a-zA-Z]:/.test(raw))
22
+ return { ok: false, reason: "absolute_path" };
23
+ // The signed canonical key and the key the CLI PUTs come from two different
24
+ // encoders, and a literal `%` makes them ambiguous.
25
+ if (raw.includes("%"))
26
+ return { ok: false, reason: "bad_encoding" };
27
+ if (CONTROL_CHARS.test(raw))
28
+ return { ok: false, reason: "path_traversal" };
29
+ if (Buffer.byteLength(raw, "utf8") > MAX_PATH_BYTES) {
30
+ return { ok: false, reason: "path_traversal" };
31
+ }
32
+ // A lone surrogate survives a JS string but is not encodable UTF-8, so the API
33
+ // would see different bytes than the CLI hashed.
34
+ if (LONE_SURROGATE.test(raw.replace(SURROGATE_PAIR, ""))) {
35
+ return { ok: false, reason: "bad_encoding" };
36
+ }
37
+ const out = [];
38
+ for (const seg of raw.split("/")) {
39
+ // An empty segment is `a//b.js`; a `.` segment is noise. Both are rejected as
40
+ // whole segments rather than collapsed, per §2.4.1's MF-6 closure.
41
+ if (seg === "" || seg === "." || seg === "..") {
42
+ return { ok: false, reason: "path_traversal" };
43
+ }
44
+ out.push(seg);
45
+ }
46
+ if (out.length === 0)
47
+ return { ok: false, reason: "path_traversal" };
48
+ // Broader than `_platform` on purpose: a future platform prefix then needs no
49
+ // validator change, and Vite emits no leading-underscore top-level directory.
50
+ // Non-first segments are unaffected — `a/_b.js` is legal.
51
+ if (out[0].startsWith("_"))
52
+ return { ok: false, reason: "reserved_path" };
53
+ return { ok: true, path: out.join("/") };
54
+ }
55
+ /**
56
+ * Validate a whole set of normalized paths. The manifest is rejected as a WHOLE
57
+ * — never a subset — because a partial accept would publish a tree the creator
58
+ * did not build.
59
+ */
60
+ export function validatePathSet(paths) {
61
+ const seen = new Set();
62
+ const seenLower = new Set();
63
+ for (const p of paths) {
64
+ if (seen.has(p))
65
+ return { reason: "duplicate_path", path: p };
66
+ seen.add(p);
67
+ // S3 is case-sensitive and macOS/Windows are not, so `Logo.png` + `logo.png`
68
+ // in one manifest means the creator's machine and the CDN disagree about
69
+ // what shipped.
70
+ const lower = p.toLowerCase();
71
+ if (seenLower.has(lower))
72
+ return { reason: "case_duplicate", path: p };
73
+ seenLower.add(lower);
74
+ }
75
+ if (!seen.has("index.html"))
76
+ return { reason: "no_index_html", path: "index.html" };
77
+ return undefined;
78
+ }
@@ -0,0 +1,17 @@
1
+ import type { UploadProgress } from "./upload.js";
2
+ export interface ProgressSink {
3
+ update: (p: UploadProgress) => void;
4
+ /** Clear the redrawn line so the completion lines start clean. */
5
+ finish: () => void;
6
+ }
7
+ export interface ProgressDeps {
8
+ isTTY: boolean;
9
+ /** Raw write for the redraw path (no trailing newline). */
10
+ write: (s: string) => void;
11
+ /** Whole-line print for the degraded path. */
12
+ print: (line: string) => void;
13
+ now: () => number;
14
+ /** The degraded cadence ceiling. */
15
+ minIntervalMs?: number;
16
+ }
17
+ export declare function createProgress(deps: ProgressDeps): ProgressSink;
@@ -0,0 +1,45 @@
1
+ // The one redrawn line in f4 (Flow L27), and its non-TTY degrade.
2
+ //
3
+ // f3 forbade redrawing A STREAM, because scrollback and piping must stay true. A
4
+ // bounded progress bar with a known total is not a stream, so this is a
5
+ // legitimate exception to that rule — but only on a TTY. On a non-TTY it
6
+ // degrades to periodic plain lines at MOST one per 2 seconds, so a piped log and
7
+ // an agent transcript stay readable and finite.
8
+ import { progressBar, progressPlain } from "./copy.js";
9
+ // Carriage return + erase-line. Built from a code point so no literal control
10
+ // byte lives in the source.
11
+ const CLEAR_LINE = String.fromCharCode(13) + String.fromCharCode(27) + "[2K";
12
+ export function createProgress(deps) {
13
+ const minInterval = deps.minIntervalMs ?? 2000;
14
+ let lastEmit = -Infinity;
15
+ let dirty = false;
16
+ if (!deps.isTTY) {
17
+ return {
18
+ update(p) {
19
+ const done = p.filesDone === p.filesTotal;
20
+ const t = deps.now();
21
+ // The last update always lands, so a piped log never stops short of the
22
+ // real total; everything else is rate-limited.
23
+ if (!done && t - lastEmit < minInterval)
24
+ return;
25
+ if (done)
26
+ return;
27
+ lastEmit = t;
28
+ deps.print(progressPlain(p.filesDone, p.filesTotal, p.bytesDone));
29
+ },
30
+ finish() {
31
+ // Nothing to clear: the degraded path emitted whole lines.
32
+ },
33
+ };
34
+ }
35
+ return {
36
+ update(p) {
37
+ dirty = true;
38
+ deps.write(CLEAR_LINE + progressBar(p.filesDone, p.filesTotal, p.bytesDone, p.bytesTotal));
39
+ },
40
+ finish() {
41
+ if (dirty)
42
+ deps.write(CLEAR_LINE);
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,4 @@
1
+ export type Confirm = (prompt: string, defaultYes: boolean) => Promise<boolean>;
2
+ /** Reads one line from stdin. Anything that is not an explicit answer takes the default. */
3
+ export declare function stdinConfirm(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream): Confirm;
4
+ export declare function interpretAnswer(answer: string, defaultYes: boolean): boolean;
@@ -0,0 +1,31 @@
1
+ // The one question in the product whose answer cannot be undone.
2
+ //
3
+ // `defaultYes` is what a bare Enter means, and the two stakes are genuinely
4
+ // different: on a first publish an accidental Enter must do nothing; on a
5
+ // republish it does the thing the creator typed the command to do.
6
+ import readline from "node:readline";
7
+ /** Reads one line from stdin. Anything that is not an explicit answer takes the default. */
8
+ export function stdinConfirm(input = process.stdin, output = process.stdout) {
9
+ return async (prompt, defaultYes) => {
10
+ const rl = readline.createInterface({ input, output });
11
+ try {
12
+ const answer = await new Promise((resolve) => rl.question(prompt, resolve));
13
+ return interpretAnswer(answer, defaultYes);
14
+ }
15
+ finally {
16
+ rl.close();
17
+ }
18
+ };
19
+ }
20
+ export function interpretAnswer(answer, defaultYes) {
21
+ const normalized = answer.trim().toLowerCase();
22
+ if (normalized === "")
23
+ return defaultYes;
24
+ if (normalized === "y" || normalized === "yes")
25
+ return true;
26
+ if (normalized === "n" || normalized === "no")
27
+ return false;
28
+ // An answer we cannot read is not consent on a [y/N], and is not a refusal on
29
+ // a [Y/n] the creator meant to accept.
30
+ return defaultYes;
31
+ }
@@ -0,0 +1,25 @@
1
+ export type DistFixtureName = "CLEAN" | "TEMPLATE" | "MISSING_META" | "DEAD_IMAGE" | "MULTI_HTML" | "ADVERSARIAL" | "NO_DIST" | "OVERSIZE";
2
+ /** The template defaults corpus the CLI ships, used verbatim by TEMPLATE. */
3
+ export declare const TEMPLATE_TITLE = "Vite + React";
4
+ export declare const TEMPLATE_DESCRIPTION = "Vite + React + TypeScript starter";
5
+ /**
6
+ * distFixture materializes one named tree under a fresh temp dir and returns
7
+ * its cwd. The caller removes it.
8
+ */
9
+ export declare function distFixture(name: DistFixtureName): Promise<string>;
10
+ export declare function removeFixture(cwd: string): Promise<void>;
11
+ /**
12
+ * The SHARED ADVERSARIAL ROWS. This list is the fixture the API's Go table test
13
+ * and this suite both read; row-count parity is asserted in both runners.
14
+ *
15
+ * `reason` is the closed-enum value the API answers with. A row whose reason
16
+ * differs between the two runners is a validator that disagrees with itself
17
+ * across a repo boundary, which shows up only as a publish that cannot succeed.
18
+ */
19
+ export interface AdversarialRow {
20
+ name: string;
21
+ path: string;
22
+ /** "" means the row must be ACCEPTED. */
23
+ reason: string;
24
+ }
25
+ export declare function adversarialRows(): AdversarialRow[];
@@ -0,0 +1,140 @@
1
+ // QA f4 · the eight on-disk `dist/` trees the catalog names (4C / 4E).
2
+ //
3
+ // A distFixture is a REAL DIRECTORY, not a mock: the checklist parses HTML off
4
+ // disk and the manifest hashes bytes off disk, so a fixture that stubbed either
5
+ // would be asserting against a shape the product never reads.
6
+ //
7
+ // `ADVERSARIAL` is shared BYTE-FOR-BYTE with the API's validateManifest table —
8
+ // one set of rows, read by both the Go table test and this vitest suite. A row
9
+ // present in one runner and absent in the other fails the parity assertion, or
10
+ // the shared fixture degrades into two fixtures with one name (f3's QA-F3-G11
11
+ // lesson).
12
+ import { promises as fs } from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ /** The template defaults corpus the CLI ships, used verbatim by TEMPLATE. */
16
+ export const TEMPLATE_TITLE = "Vite + React";
17
+ export const TEMPLATE_DESCRIPTION = "Vite + React + TypeScript starter";
18
+ function html(opts) {
19
+ const parts = ["<!doctype html><html><head>"];
20
+ if (opts.title !== undefined)
21
+ parts.push(`<title>${opts.title}</title>`);
22
+ if (opts.ogTitle !== undefined) {
23
+ parts.push(`<meta property="og:title" content="${opts.ogTitle}">`);
24
+ }
25
+ if (opts.description !== undefined) {
26
+ parts.push(`<meta name="description" content="${opts.description}">`);
27
+ }
28
+ if (opts.ogImage !== undefined) {
29
+ parts.push(`<meta property="og:image" content="${opts.ogImage}">`);
30
+ }
31
+ parts.push("</head><body><div id=\"root\"></div></body></html>");
32
+ return parts.join("");
33
+ }
34
+ /**
35
+ * distFixture materializes one named tree under a fresh temp dir and returns
36
+ * its cwd. The caller removes it.
37
+ */
38
+ export async function distFixture(name) {
39
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), `vct-f4-${name.toLowerCase()}-`));
40
+ const dist = path.join(cwd, "dist");
41
+ if (name === "NO_DIST") {
42
+ // The ABSENCE of a dist/ directory IS the fixture.
43
+ return cwd;
44
+ }
45
+ await fs.mkdir(dist, { recursive: true });
46
+ switch (name) {
47
+ case "CLEAN": {
48
+ // A real title, description and og:image; og.png PRESENT; 6 files.
49
+ await fs.writeFile(path.join(dist, "index.html"), html({
50
+ title: "Harbour Wing — an AR walk",
51
+ description: "A guided AR tour of the Harbour Wing collection.",
52
+ ogImage: "/og.png",
53
+ }));
54
+ await fs.writeFile(path.join(dist, "og.png"), "png-bytes-clean");
55
+ await fs.mkdir(path.join(dist, "assets"), { recursive: true });
56
+ await fs.writeFile(path.join(dist, "assets", "app-a1b2c3.js"), "export const a=1;");
57
+ await fs.writeFile(path.join(dist, "assets", "app-d4e5f6.css"), "body{margin:0}");
58
+ await fs.writeFile(path.join(dist, "favicon.ico"), "ico-bytes");
59
+ await fs.writeFile(path.join(dist, "model.glb"), "glb-bytes");
60
+ return cwd;
61
+ }
62
+ case "TEMPLATE": {
63
+ // The template's own strings, untouched.
64
+ await fs.writeFile(path.join(dist, "index.html"), html({
65
+ title: TEMPLATE_TITLE,
66
+ description: TEMPLATE_DESCRIPTION,
67
+ ogImage: "/og.png",
68
+ }));
69
+ await fs.writeFile(path.join(dist, "og.png"), "png-bytes-template");
70
+ return cwd;
71
+ }
72
+ case "MISSING_META": {
73
+ // A title ONLY — no description, no og:image.
74
+ await fs.writeFile(path.join(dist, "index.html"), html({ title: "Harbour Wing — an AR walk" }));
75
+ return cwd;
76
+ }
77
+ case "DEAD_IMAGE": {
78
+ // og:image references /og.png, which is ABSENT from the tree. This is the
79
+ // ONLY fixture that produces a ✗.
80
+ await fs.writeFile(path.join(dist, "index.html"), html({
81
+ title: "Harbour Wing — an AR walk",
82
+ description: "A guided AR tour of the Harbour Wing collection.",
83
+ ogImage: "/og.png",
84
+ }));
85
+ return cwd;
86
+ }
87
+ case "MULTI_HTML": {
88
+ await fs.writeFile(path.join(dist, "index.html"), html({
89
+ title: "Harbour Wing — an AR walk",
90
+ description: "A guided AR tour of the Harbour Wing collection.",
91
+ ogImage: "/og.png",
92
+ }));
93
+ await fs.writeFile(path.join(dist, "og.png"), "png-bytes-multi");
94
+ // gallery.html has its OWN, DIFFERING findings.
95
+ await fs.writeFile(path.join(dist, "gallery.html"), html({ title: TEMPLATE_TITLE }));
96
+ return cwd;
97
+ }
98
+ case "ADVERSARIAL": {
99
+ // Paths only, and NO index.html at the root. The tree cannot literally
100
+ // contain "../escape.js" — the ROWS are the fixture, and they are shared
101
+ // with the Go table test through adversarialRows() below.
102
+ await fs.mkdir(path.join(dist, "sub"), { recursive: true });
103
+ await fs.writeFile(path.join(dist, "sub", "index.html"), html({ title: "nested" }));
104
+ return cwd;
105
+ }
106
+ case "OVERSIZE": {
107
+ // The bound variants are driven by SYNTHETIC MANIFEST ROWS rather than
108
+ // real bytes: writing 612 MB to a temp dir on every run would make the
109
+ // suite unusable, and the bound is checked against declared sizes before
110
+ // a byte moves — which is exactly the property under test.
111
+ await fs.writeFile(path.join(dist, "index.html"), html({ title: "big" }));
112
+ return cwd;
113
+ }
114
+ }
115
+ return cwd;
116
+ }
117
+ export async function removeFixture(cwd) {
118
+ await fs.rm(cwd, { recursive: true, force: true });
119
+ }
120
+ export function adversarialRows() {
121
+ return [
122
+ { name: "absolute", path: "/a.js", reason: "absolute_path" },
123
+ { name: "traversal", path: "../a.js", reason: "path_traversal" },
124
+ { name: "traversal_after_normalize", path: "a/../../b.js", reason: "path_traversal" },
125
+ { name: "backslash", path: "a\\b.js", reason: "backslash" },
126
+ { name: "empty_segment", path: "a//b.js", reason: "empty_segment" },
127
+ { name: "over_1024_bytes", path: `${"a".repeat(1025)}.js`, reason: "path_too_long" },
128
+ { name: "control_char", path: `a${String.fromCharCode(7)}b.js`, reason: "control_char" },
129
+ { name: "percent", path: "a%20b.js", reason: "bad_encoding" },
130
+ { name: "percent_2e2e", path: "a%2e%2e/b.js", reason: "bad_encoding" },
131
+ { name: "reserved_platform", path: "_platform/x.js", reason: "reserved_path" },
132
+ { name: "reserved_platform2", path: "_platform2/x.js", reason: "reserved_path" },
133
+ { name: "reserved_leading_underscore", path: "_anything/x.js", reason: "reserved_path" },
134
+ { name: "dot_segment", path: ".", reason: "path_traversal" },
135
+ { name: "dotdot_segment", path: "..", reason: "path_traversal" },
136
+ // THE ACCEPTED ROW. Without one the table passes on a validator that
137
+ // rejects everything.
138
+ { name: "nested_leading_underscore_is_ALLOWED", path: "a/_b.js", reason: "" },
139
+ ];
140
+ }
@@ -0,0 +1,30 @@
1
+ import type { UploadTarget } from "./api.js";
2
+ import type { ManifestEntry } from "./manifest.js";
3
+ export declare class UploadFailed extends Error {
4
+ readonly path: string;
5
+ readonly status: number;
6
+ readonly badDigest: boolean;
7
+ constructor(path: string, status: number, badDigest: boolean);
8
+ }
9
+ export interface UploadProgress {
10
+ filesDone: number;
11
+ filesTotal: number;
12
+ bytesDone: number;
13
+ bytesTotal: number;
14
+ }
15
+ export interface UploadOptions {
16
+ distDir: string;
17
+ targets: UploadTarget[];
18
+ manifest: ManifestEntry[];
19
+ concurrency?: number;
20
+ onProgress?: (p: UploadProgress) => void;
21
+ fetchImpl?: typeof fetch;
22
+ /** Per-file retries for a transient network/5xx failure. A BadDigest is retried once. */
23
+ maxAttempts?: number;
24
+ }
25
+ /**
26
+ * PUT every file. A re-run that finds files already landed simply uploads
27
+ * fewer — resumption is silent, because the creator did not ask for a resume,
28
+ * they asked to publish.
29
+ */
30
+ export declare function uploadAll(opts: UploadOptions): Promise<void>;
@@ -0,0 +1,93 @@
1
+ // The S3 leg: one presigned PUT per file, bounded concurrency, resumable.
2
+ //
3
+ // THE HEADERS MAP IS FORWARDED VERBATIM — every key, including Content-Type and
4
+ // x-amz-checksum-sha256. Both are covered by the SigV4 signature, so dropping
5
+ // either yields SignatureDoesNotMatch. That reads as an auth bug and gets
6
+ // "fixed" by deleting the header, which deletes the cost control in one case and
7
+ // makes every published index.html download instead of render in the other
8
+ // (§2.7's contract traps 3 and 4).
9
+ //
10
+ // The CLI never synthesizes a MIME type and never parses the presigned URL.
11
+ import { promises as fs } from "node:fs";
12
+ import path from "node:path";
13
+ export class UploadFailed extends Error {
14
+ path;
15
+ status;
16
+ badDigest;
17
+ constructor(path, status, badDigest) {
18
+ super(`upload failed for ${path} (${status})`);
19
+ this.path = path;
20
+ this.status = status;
21
+ this.badDigest = badDigest;
22
+ this.name = "UploadFailed";
23
+ }
24
+ }
25
+ const DEFAULT_CONCURRENCY = 6;
26
+ /**
27
+ * PUT every file. A re-run that finds files already landed simply uploads
28
+ * fewer — resumption is silent, because the creator did not ask for a resume,
29
+ * they asked to publish.
30
+ */
31
+ export async function uploadAll(opts) {
32
+ const fetchImpl = opts.fetchImpl ?? fetch;
33
+ const sizes = new Map(opts.manifest.map((f) => [f.path, f.size]));
34
+ const bytesTotal = opts.manifest.reduce((n, f) => n + f.size, 0);
35
+ const queue = [...opts.targets];
36
+ const maxAttempts = opts.maxAttempts ?? 3;
37
+ let filesDone = 0;
38
+ let bytesDone = 0;
39
+ const report = () => opts.onProgress?.({
40
+ filesDone,
41
+ filesTotal: opts.targets.length,
42
+ bytesDone,
43
+ bytesTotal,
44
+ });
45
+ report();
46
+ const worker = async () => {
47
+ for (;;) {
48
+ const target = queue.shift();
49
+ if (!target)
50
+ return;
51
+ await putOne(target, opts.distDir, fetchImpl, maxAttempts);
52
+ filesDone += 1;
53
+ bytesDone += sizes.get(target.path) ?? 0;
54
+ report();
55
+ }
56
+ };
57
+ const workers = Array.from({ length: Math.min(opts.concurrency ?? DEFAULT_CONCURRENCY, queue.length || 1) }, () => worker());
58
+ await Promise.all(workers);
59
+ }
60
+ async function putOne(target, distDir, fetchImpl, maxAttempts) {
61
+ const abs = path.join(distDir, ...target.path.split("/"));
62
+ const body = await fs.readFile(abs);
63
+ let lastStatus = 0;
64
+ let sawBadDigest = false;
65
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
66
+ let res;
67
+ try {
68
+ res = await fetchImpl(target.url, {
69
+ method: "PUT",
70
+ // VERBATIM. No key dropped, no key added, no key renamed.
71
+ headers: { ...target.headers },
72
+ body: new Uint8Array(body),
73
+ });
74
+ }
75
+ catch {
76
+ lastStatus = 0;
77
+ continue;
78
+ }
79
+ if (res.ok)
80
+ return;
81
+ lastStatus = res.status;
82
+ const text = await res.text().catch(() => "");
83
+ if (text.includes("BadDigest") || res.status === 400) {
84
+ // The bytes on disk differ from what was hashed. One silent re-PUT covers
85
+ // a torn read; a repeat means the file genuinely changed under us.
86
+ if (sawBadDigest)
87
+ throw new UploadFailed(target.path, res.status, true);
88
+ sawBadDigest = true;
89
+ continue;
90
+ }
91
+ }
92
+ throw new UploadFailed(target.path, lastStatus, sawBadDigest);
93
+ }
@@ -1,6 +1,8 @@
1
1
  import { EventStore } from "./store.js";
2
+ import { TesterRegistry } from "./testers.js";
2
3
  import { AnnotationStore } from "./annotations.js";
3
4
  import { type Dispatcher } from "./injectors.js";
5
+ import type { DiagEvent } from "@vincentt-xr/harness/events";
4
6
  export interface RelayOptions {
5
7
  port?: number;
6
8
  /** WS path the client connects to (default matches HarnessProvider). */
@@ -15,10 +17,40 @@ export interface RelayOptions {
15
17
  dispatcher?: Dispatcher;
16
18
  /** Injected for tests. */
17
19
  makeId?: () => string;
20
+ /**
21
+ * Live tap for the CLI's terminal stream. It fires per batch as events land,
22
+ * so the creator sees a line the moment the phone produces it rather than on
23
+ * a poll. The relay still buffers everything — this is an addition, not a
24
+ * replacement, and a missing handler changes nothing.
25
+ */
26
+ onEvents?: (events: DiagEvent[], tags: EventTags) => void;
27
+ /**
28
+ * Fires ONCE per refused session when the tester cap is reached, so the CLI
29
+ * can print the one warning line. Once per session rather than once per batch:
30
+ * the registry holds a tombstone precisely so a chatty refused client cannot
31
+ * repaint the warning on every flush.
32
+ */
33
+ onCapReached?: () => void;
34
+ /** Injected for tests, so cap behavior is drivable without 64 real clients. */
35
+ testerRegistry?: TesterRegistry;
36
+ }
37
+ /** Who produced a batch: the connection's viewer label and its resolved tester. */
38
+ export interface EventTags {
39
+ viewer?: string;
40
+ tester?: string;
18
41
  }
19
42
  export interface RelayHandle {
20
43
  store: EventStore;
21
44
  annotations?: AnnotationStore;
45
+ /** The run's tester registry — read-only to callers; the relay owns writes. */
46
+ testers: TesterRegistry;
22
47
  close: () => Promise<void>;
23
48
  }
24
49
  export declare function startRelay(opts?: RelayOptions): RelayHandle;
50
+ /**
51
+ * readViewerLabel accepts ONLY `^viewer-[0-9]{1,4}$` and returns undefined for
52
+ * everything else — an array-valued header (a duplicate), a forged class, an
53
+ * ANSI escape, a newline. There is no "sanitize and keep" branch: a label that
54
+ * does not match is not a label, and the events simply carry no viewer.
55
+ */
56
+ export declare function readViewerLabel(raw: string | string[] | undefined): string | undefined;