@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.
- package/README.md +94 -0
- package/dist/cli/args.d.ts +14 -0
- package/dist/cli/args.js +51 -0
- package/dist/cli/index.js +416 -100
- package/dist/lib/backend.d.ts +44 -20
- package/dist/lib/backend.js +98 -51
- package/dist/lib/diagnostics.d.ts +27 -2
- package/dist/lib/diagnostics.js +73 -8
- package/dist/lib/initCommand.d.ts +14 -0
- package/dist/lib/initCommand.js +75 -0
- package/dist/lib/lifecycle.d.ts +85 -19
- package/dist/lib/lifecycle.js +133 -130
- package/dist/lib/org.d.ts +29 -0
- package/dist/lib/org.js +45 -0
- package/dist/lib/projects.d.ts +82 -0
- package/dist/lib/projects.js +217 -0
- package/dist/lib/relayInfo.d.ts +41 -3
- package/dist/lib/relayInfo.js +94 -0
- package/dist/lib/whoami.d.ts +11 -0
- package/dist/lib/whoami.js +38 -0
- package/dist/login/authBackend.d.ts +30 -0
- package/dist/login/authBackend.js +80 -0
- package/dist/login/guard.d.ts +13 -0
- package/dist/login/guard.js +17 -0
- package/dist/login/loginCommand.d.ts +39 -0
- package/dist/login/loginCommand.js +72 -0
- package/dist/login/pkce.d.ts +47 -0
- package/dist/login/pkce.js +106 -0
- package/dist/login/pkceLogin.d.ts +53 -0
- package/dist/login/pkceLogin.js +152 -0
- package/dist/mcp/server.js +52 -25
- package/dist/preview/api.d.ts +84 -0
- package/dist/preview/api.js +174 -0
- package/dist/preview/command.d.ts +96 -0
- package/dist/preview/command.js +276 -0
- package/dist/preview/copy.d.ts +209 -0
- package/dist/preview/copy.js +444 -0
- package/dist/preview/index.d.ts +2 -3
- package/dist/preview/index.js +5 -6
- package/dist/preview/proxy.d.ts +12 -1
- package/dist/preview/proxy.js +17 -7
- package/dist/preview/qr.d.ts +30 -0
- package/dist/preview/qr.js +89 -0
- package/dist/preview/runner.d.ts +155 -30
- package/dist/preview/runner.js +219 -68
- package/dist/preview/stream.d.ts +52 -0
- package/dist/preview/stream.js +171 -0
- package/dist/preview/viewers.d.ts +113 -0
- package/dist/preview/viewers.js +242 -0
- package/dist/publish/api.d.ts +67 -0
- package/dist/publish/api.js +96 -0
- package/dist/publish/bounds.d.ts +25 -0
- package/dist/publish/bounds.js +53 -0
- package/dist/publish/classify.d.ts +51 -0
- package/dist/publish/classify.js +138 -0
- package/dist/publish/command.d.ts +27 -0
- package/dist/publish/command.js +328 -0
- package/dist/publish/consoleVerbs.d.ts +17 -0
- package/dist/publish/consoleVerbs.js +26 -0
- package/dist/publish/copy.d.ts +180 -0
- package/dist/publish/copy.js +606 -0
- package/dist/publish/defaults.d.ts +16 -0
- package/dist/publish/defaults.js +52 -0
- package/dist/publish/manifest.d.ts +27 -0
- package/dist/publish/manifest.js +81 -0
- package/dist/publish/paths.d.ts +25 -0
- package/dist/publish/paths.js +78 -0
- package/dist/publish/progress.d.ts +17 -0
- package/dist/publish/progress.js +45 -0
- package/dist/publish/prompt.d.ts +4 -0
- package/dist/publish/prompt.js +31 -0
- package/dist/publish/qa.f4.fixtures.d.ts +25 -0
- package/dist/publish/qa.f4.fixtures.js +140 -0
- package/dist/publish/upload.d.ts +30 -0
- package/dist/publish/upload.js +93 -0
- package/dist/relay/server.d.ts +32 -0
- package/dist/relay/server.js +80 -12
- package/dist/relay/store.d.ts +32 -3
- package/dist/relay/store.js +76 -22
- package/dist/relay/testers.d.ts +90 -0
- package/dist/relay/testers.js +144 -0
- package/dist/shared/authError.d.ts +1 -0
- package/dist/shared/authError.js +11 -0
- package/dist/shared/config.d.ts +73 -5
- package/dist/shared/config.js +133 -20
- package/package.json +2 -3
- package/dist/login/login.d.ts +0 -34
- package/dist/login/login.js +0 -148
- package/dist/preview/cloudflared.d.ts +0 -13
- package/dist/preview/cloudflared.js +0 -46
- package/dist/preview/tunnel.d.ts +0 -14
- 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
|
+
}
|
package/dist/relay/server.d.ts
CHANGED
|
@@ -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;
|