@vincentt-xr/cli 0.16.0 → 0.18.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/MIGRATION.md +24 -0
- package/README.md +94 -1
- package/dist/cli/index.js +196 -87
- package/dist/cli/usage-text.js +32 -10
- package/dist/domain/api.d.ts +10 -8
- package/dist/domain/api.js +14 -9
- package/dist/domain/command.js +25 -16
- package/dist/domain/copy.d.ts +6 -3
- package/dist/domain/copy.js +10 -6
- package/dist/dx/command.d.ts +24 -4
- package/dist/dx/command.js +80 -25
- package/dist/dx/consent.js +6 -5
- package/dist/dx/journal.d.ts +17 -2
- package/dist/dx/journal.js +19 -3
- package/dist/dx/report.js +4 -3
- package/dist/dx/reportIo.js +6 -5
- package/dist/dx/send.js +6 -5
- package/dist/lib/backend.d.ts +0 -16
- package/dist/lib/backend.js +2 -26
- package/dist/lib/detect.d.ts +87 -0
- package/dist/lib/detect.js +867 -0
- package/dist/lib/diagnostics.js +2 -2
- package/dist/lib/feedback.js +1 -1
- package/dist/lib/feedbackCopy.js +1 -1
- package/dist/lib/initFrom.js +4 -4
- package/dist/lib/lifecycle.d.ts +2 -0
- package/dist/lib/lifecycle.js +9 -3
- package/dist/lib/listProjects.js +2 -1
- package/dist/lib/projects.d.ts +37 -2
- package/dist/lib/projects.js +86 -21
- package/dist/lib/settings.d.ts +113 -0
- package/dist/lib/settings.js +284 -0
- package/dist/lib/settingsCopy.d.ts +6 -0
- package/dist/lib/settingsCopy.js +99 -0
- package/dist/lib/settle.d.ts +61 -0
- package/dist/lib/settle.js +338 -0
- package/dist/lib/template.d.ts +19 -0
- package/dist/lib/template.js +73 -0
- package/dist/lib/terminalText.d.ts +3 -0
- package/dist/lib/terminalText.js +10 -0
- package/dist/lib/version.js +5 -11
- package/dist/lib/whoami.js +10 -3
- package/dist/library/assetsCommand.js +1 -1
- package/dist/library/client.js +8 -3
- package/dist/library/render.js +8 -6
- package/dist/login/authBackend.js +3 -2
- package/dist/login/ensure.d.ts +8 -2
- package/dist/login/ensure.js +21 -10
- package/dist/login/guard.js +5 -4
- package/dist/login/loginCommand.d.ts +2 -2
- package/dist/login/loginCommand.js +29 -5
- package/dist/login/pkce.js +1 -1
- package/dist/login/pkceLogin.js +1 -1
- package/dist/platform/outdated.js +2 -2
- package/dist/platform/upgradeReport.js +16 -12
- package/dist/preview/api.d.ts +24 -11
- package/dist/preview/api.js +42 -15
- package/dist/preview/command.d.ts +29 -0
- package/dist/preview/command.js +61 -1
- package/dist/preview/copy.d.ts +35 -3
- package/dist/preview/copy.js +102 -10
- package/dist/preview/proxy.d.ts +38 -1
- package/dist/preview/proxy.js +151 -2
- package/dist/preview/qrPage.js +1 -1
- package/dist/preview/runner.d.ts +25 -13
- package/dist/preview/runner.js +127 -26
- package/dist/preview/viewers.js +1 -1
- package/dist/publish/api.d.ts +39 -6
- package/dist/publish/api.js +80 -9
- package/dist/publish/command.d.ts +25 -0
- package/dist/publish/command.js +230 -32
- package/dist/publish/copy.d.ts +78 -5
- package/dist/publish/copy.js +271 -37
- package/dist/publish/fit.d.ts +15 -0
- package/dist/publish/fit.js +103 -0
- package/dist/publish/manifest.d.ts +44 -3
- package/dist/publish/manifest.js +172 -3
- package/dist/publish/prompt.d.ts +4 -0
- package/dist/publish/prompt.js +15 -1
- package/dist/publish/upload.d.ts +6 -0
- package/dist/publish/upload.js +11 -1
- package/dist/publish/visibility.d.ts +21 -0
- package/dist/publish/visibility.js +68 -0
- package/dist/relay/annotations.js +9 -4
- package/dist/relay/server.d.ts +21 -0
- package/dist/relay/server.js +35 -1
- package/dist/shared/authError.d.ts +27 -0
- package/dist/shared/authError.js +48 -4
- package/dist/shared/config.d.ts +21 -1
- package/dist/shared/config.js +39 -8
- package/dist/shared/countLimit.d.ts +114 -0
- package/dist/shared/countLimit.js +176 -0
- package/dist/shared/http.d.ts +23 -0
- package/dist/shared/http.js +58 -0
- package/package.json +7 -3
package/dist/publish/manifest.js
CHANGED
|
@@ -11,13 +11,178 @@ import { normalizePath, validatePathSet } from "./paths.js";
|
|
|
11
11
|
export class ManifestError extends Error {
|
|
12
12
|
reason;
|
|
13
13
|
offendingPath;
|
|
14
|
-
|
|
14
|
+
paths;
|
|
15
|
+
stats;
|
|
16
|
+
constructor(reason, offendingPath, message, detail = {}) {
|
|
15
17
|
super(message);
|
|
16
18
|
this.reason = reason;
|
|
17
19
|
this.offendingPath = offendingPath;
|
|
18
20
|
this.name = "ManifestError";
|
|
21
|
+
this.paths = detail.paths ?? [];
|
|
22
|
+
this.stats = detail.stats;
|
|
19
23
|
}
|
|
20
24
|
}
|
|
25
|
+
/** The only dotted names allowed, and only at the output root. Case-insensitive. */
|
|
26
|
+
const ALLOWED_ROOT_DIR = ".well-known";
|
|
27
|
+
const ALLOWED_ROOT_FILE = ".nojekyll";
|
|
28
|
+
const LFS_POINTER_MAX_BYTES = 1024;
|
|
29
|
+
const LFS_FIRST_LINE = "version https://git-lfs.github.com/spec/v1";
|
|
30
|
+
function isInside(child, parent) {
|
|
31
|
+
const rel = path.relative(parent, child);
|
|
32
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
33
|
+
}
|
|
34
|
+
function hasDottedSegment(rel) {
|
|
35
|
+
return rel.split(/[\\/]/).some((seg) => seg.startsWith("."));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The leak walk. Unlike `walkDist` it records what it does not upload: a dotted
|
|
39
|
+
* name is an offender (a folder once, with a trailing slash, never descended),
|
|
40
|
+
* and every symlink is resolved and judged against the real repo root.
|
|
41
|
+
*/
|
|
42
|
+
async function walkOutput(outReal, rootReal) {
|
|
43
|
+
const files = [];
|
|
44
|
+
const offenders = [];
|
|
45
|
+
const visit = async (dir) => {
|
|
46
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
47
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const full = path.join(dir, entry.name);
|
|
50
|
+
const rel = path.relative(outReal, full).split(path.sep).join("/");
|
|
51
|
+
const atRoot = dir === outReal;
|
|
52
|
+
const lower = entry.name.toLowerCase();
|
|
53
|
+
if (entry.name.startsWith(".")) {
|
|
54
|
+
const allowed = atRoot &&
|
|
55
|
+
((lower === ALLOWED_ROOT_DIR && entry.isDirectory()) ||
|
|
56
|
+
(lower === ALLOWED_ROOT_FILE && entry.isFile()));
|
|
57
|
+
if (!allowed) {
|
|
58
|
+
offenders.push({ path: entry.isDirectory() ? `${rel}/` : rel });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (entry.isSymbolicLink()) {
|
|
63
|
+
const target = await symlinkTarget(full);
|
|
64
|
+
if (!isInside(target, rootReal)) {
|
|
65
|
+
offenders.push({ path: rel, target });
|
|
66
|
+
}
|
|
67
|
+
else if (hasDottedSegment(path.relative(rootReal, target))) {
|
|
68
|
+
offenders.push({
|
|
69
|
+
path: rel,
|
|
70
|
+
target: path.relative(rootReal, target).split(path.sep).join("/"),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// A symlink that resolves cleanly inside is not uploaded, as before.
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
await visit(full);
|
|
78
|
+
}
|
|
79
|
+
else if (entry.isFile()) {
|
|
80
|
+
const stat = await fs.stat(full);
|
|
81
|
+
files.push({ rel, size: stat.size });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
await visit(outReal);
|
|
86
|
+
return { files, offenders };
|
|
87
|
+
}
|
|
88
|
+
/** Where a symlink lands: its realpath, or its lexical target when it dangles. */
|
|
89
|
+
async function symlinkTarget(link) {
|
|
90
|
+
try {
|
|
91
|
+
return await fs.realpath(link);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
const raw = await fs.readlink(link).catch(() => link);
|
|
95
|
+
return path.resolve(path.dirname(link), raw);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** A Git LFS pointer file: at most 1 KB, the spec's exact first line, and an oid line. */
|
|
99
|
+
export async function isLfsPointer(absPath, size) {
|
|
100
|
+
if (size > LFS_POINTER_MAX_BYTES)
|
|
101
|
+
return false;
|
|
102
|
+
const head = await fs.readFile(absPath, "utf8");
|
|
103
|
+
const lines = head.split("\n");
|
|
104
|
+
if (lines[0] !== LFS_FIRST_LINE)
|
|
105
|
+
return false;
|
|
106
|
+
return lines.some((l) => l.startsWith("oid sha256:"));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The leak and LFS checks, in that order, before anything is hashed or sent.
|
|
110
|
+
* Throws ManifestError; returns nothing on a clean output.
|
|
111
|
+
*/
|
|
112
|
+
/**
|
|
113
|
+
* Folders an output must never be, or sit inside: the repository's history and
|
|
114
|
+
* the Vincentt binding. Compared case-insensitively, as a default macOS volume
|
|
115
|
+
* resolves `.GIT` to `.git`. Other dotted parents stay allowed (`.output/public`).
|
|
116
|
+
*/
|
|
117
|
+
const FORBIDDEN_OUTPUT_SEGMENTS = new Set([".git", ".vincentt"]);
|
|
118
|
+
function forbiddenSegmentIn(rel) {
|
|
119
|
+
return rel
|
|
120
|
+
.split(/[\\/]/)
|
|
121
|
+
.find((seg) => FORBIDDEN_OUTPUT_SEGMENTS.has(seg.toLowerCase()));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The leak and LFS checks, in that order, before anything is hashed or sent.
|
|
125
|
+
* Returns the output-relative file list the checks passed, which is exactly the
|
|
126
|
+
* list the manifest is built from: what is checked is what is uploaded.
|
|
127
|
+
*/
|
|
128
|
+
export async function checkOutput(distDir, repoRoot) {
|
|
129
|
+
const rootReal = await fs.realpath(repoRoot).catch(() => path.resolve(repoRoot));
|
|
130
|
+
const lexical = path.resolve(distDir);
|
|
131
|
+
const lexicalRoot = path.resolve(repoRoot);
|
|
132
|
+
if (lexical === lexicalRoot) {
|
|
133
|
+
throw new ManifestError("output_is_repo_root", distDir, `${distDir} is the repo root`);
|
|
134
|
+
}
|
|
135
|
+
if (!isInside(lexical, lexicalRoot) && !isInside(lexical, rootReal)) {
|
|
136
|
+
throw new ManifestError("output_outside_repo", distDir, `${distDir} is outside the repo`);
|
|
137
|
+
}
|
|
138
|
+
let outReal;
|
|
139
|
+
try {
|
|
140
|
+
outReal = await fs.realpath(distDir);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (err.code === "ENOENT") {
|
|
144
|
+
throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
|
|
145
|
+
}
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
if (outReal === rootReal) {
|
|
149
|
+
throw new ManifestError("output_is_repo_root", distDir, `${distDir} resolves to the repo root`);
|
|
150
|
+
}
|
|
151
|
+
if (!isInside(outReal, rootReal)) {
|
|
152
|
+
throw new ManifestError("output_outside_repo", distDir, `${distDir} resolves outside the repo`);
|
|
153
|
+
}
|
|
154
|
+
const hiddenParent = forbiddenSegmentIn(path.relative(lexicalRoot, lexical)) ??
|
|
155
|
+
forbiddenSegmentIn(path.relative(rootReal, outReal));
|
|
156
|
+
if (hiddenParent !== undefined) {
|
|
157
|
+
throw new ManifestError("output_in_hidden", hiddenParent, `${distDir} is inside ${hiddenParent}`);
|
|
158
|
+
}
|
|
159
|
+
if (!(await fs.stat(outReal)).isDirectory()) {
|
|
160
|
+
throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
|
|
161
|
+
}
|
|
162
|
+
const walk = await walkOutput(outReal, rootReal);
|
|
163
|
+
const stats = {
|
|
164
|
+
files: walk.files.length,
|
|
165
|
+
bytes: walk.files.reduce((n, f) => n + f.size, 0),
|
|
166
|
+
};
|
|
167
|
+
if (walk.offenders.length > 0) {
|
|
168
|
+
throw new ManifestError("secret_path", walk.offenders[0].path, "hidden paths in the output", {
|
|
169
|
+
paths: walk.offenders,
|
|
170
|
+
stats,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const pointers = [];
|
|
174
|
+
for (const f of walk.files) {
|
|
175
|
+
if (await isLfsPointer(path.join(outReal, f.rel), f.size))
|
|
176
|
+
pointers.push({ path: f.rel });
|
|
177
|
+
}
|
|
178
|
+
if (pointers.length > 0) {
|
|
179
|
+
throw new ManifestError("lfs_pointer", pointers[0].path, "Git LFS pointers in the output", {
|
|
180
|
+
paths: pointers,
|
|
181
|
+
stats,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return walk.files;
|
|
185
|
+
}
|
|
21
186
|
/** Recursively list files under `dir` as dir-relative POSIX paths. Symlinks are not followed. */
|
|
22
187
|
export async function walkDist(dir, base = dir) {
|
|
23
188
|
let entries;
|
|
@@ -54,8 +219,12 @@ export async function hashFile(absPath) {
|
|
|
54
219
|
* hashed, so the string this returns is the string the API validates, signs, and
|
|
55
220
|
* later lists — there is no second form anywhere.
|
|
56
221
|
*/
|
|
57
|
-
export async function buildManifest(distDir) {
|
|
58
|
-
|
|
222
|
+
export async function buildManifest(distDir, opts = {}) {
|
|
223
|
+
// With a repo root, the list the leak and LFS checks passed IS the upload
|
|
224
|
+
// list. A second walk could see a file created after the checks ran.
|
|
225
|
+
const raw = opts.repoRoot !== undefined
|
|
226
|
+
? (await checkOutput(distDir, opts.repoRoot)).map((f) => f.rel)
|
|
227
|
+
: await walkDist(distDir);
|
|
59
228
|
const normalized = [];
|
|
60
229
|
for (const rel of raw) {
|
|
61
230
|
const result = normalizePath(rel);
|
package/dist/publish/prompt.d.ts
CHANGED
|
@@ -3,5 +3,9 @@ export type Confirm = (prompt: string, defaultYes: boolean) => Promise<boolean>;
|
|
|
3
3
|
export declare function stdinConfirm(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream): Confirm;
|
|
4
4
|
/** Reads one free-text line. An empty answer takes the offered default. */
|
|
5
5
|
export type Ask = (prompt: string, fallback: string) => Promise<string>;
|
|
6
|
+
/** Input ended (Ctrl-D, a closed pipe) before a free-text question was answered. */
|
|
7
|
+
export declare class PromptClosedError extends Error {
|
|
8
|
+
constructor();
|
|
9
|
+
}
|
|
6
10
|
export declare function stdinAsk(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream): Ask;
|
|
7
11
|
export declare function interpretAnswer(answer: string, defaultYes: boolean): boolean;
|
package/dist/publish/prompt.js
CHANGED
|
@@ -32,11 +32,25 @@ export function stdinConfirm(input = process.stdin, output = process.stdout) {
|
|
|
32
32
|
}
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
/** Input ended (Ctrl-D, a closed pipe) before a free-text question was answered. */
|
|
36
|
+
export class PromptClosedError extends Error {
|
|
37
|
+
constructor() {
|
|
38
|
+
super("Input ended before the question was answered. Nothing more was done.");
|
|
39
|
+
this.name = "PromptClosedError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
35
42
|
export function stdinAsk(input = process.stdin, output = process.stdout) {
|
|
36
43
|
return async (prompt, fallback) => {
|
|
37
44
|
const rl = readline.createInterface({ input, output });
|
|
38
45
|
try {
|
|
39
|
-
|
|
46
|
+
// EOF is a refusal, not an empty answer: taking the fallback here would
|
|
47
|
+
// register a name or address the creator never agreed to.
|
|
48
|
+
const answer = await new Promise((resolve) => {
|
|
49
|
+
rl.question(prompt, resolve);
|
|
50
|
+
rl.once("close", () => resolve(undefined));
|
|
51
|
+
});
|
|
52
|
+
if (answer === undefined)
|
|
53
|
+
throw new PromptClosedError();
|
|
40
54
|
return answer.trim() === "" ? fallback : answer.trim();
|
|
41
55
|
}
|
|
42
56
|
finally {
|
package/dist/publish/upload.d.ts
CHANGED
|
@@ -22,6 +22,12 @@ export interface UploadOptions {
|
|
|
22
22
|
/** Per-file retries for a transient network/5xx failure. A BadDigest is retried once. */
|
|
23
23
|
maxAttempts?: number;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* One PUT's deadline: a minute, plus the time the file takes at 64 KB/s. A fixed
|
|
27
|
+
* deadline would fail every large file on a slow uplink; none at all lets a
|
|
28
|
+
* stalled connection hang the publish forever.
|
|
29
|
+
*/
|
|
30
|
+
export declare function putDeadlineMs(bytes: number): number;
|
|
25
31
|
/**
|
|
26
32
|
* PUT every file. A re-run that finds files already landed simply uploads
|
|
27
33
|
* fewer — resumption is silent, because the creator did not ask for a resume,
|
package/dist/publish/upload.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// The CLI never synthesizes a MIME type and never parses the presigned URL.
|
|
11
11
|
import { promises as fs } from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import { withDeadline } from "../shared/http.js";
|
|
13
14
|
export class UploadFailed extends Error {
|
|
14
15
|
path;
|
|
15
16
|
status;
|
|
@@ -23,6 +24,14 @@ export class UploadFailed extends Error {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
const DEFAULT_CONCURRENCY = 6;
|
|
27
|
+
/**
|
|
28
|
+
* One PUT's deadline: a minute, plus the time the file takes at 64 KB/s. A fixed
|
|
29
|
+
* deadline would fail every large file on a slow uplink; none at all lets a
|
|
30
|
+
* stalled connection hang the publish forever.
|
|
31
|
+
*/
|
|
32
|
+
export function putDeadlineMs(bytes) {
|
|
33
|
+
return 60_000 + Math.ceil(bytes / 64_000) * 1000;
|
|
34
|
+
}
|
|
26
35
|
/**
|
|
27
36
|
* PUT every file. A re-run that finds files already landed simply uploads
|
|
28
37
|
* fewer — resumption is silent, because the creator did not ask for a resume,
|
|
@@ -60,12 +69,13 @@ export async function uploadAll(opts) {
|
|
|
60
69
|
async function putOne(target, distDir, fetchImpl, maxAttempts) {
|
|
61
70
|
const abs = path.join(distDir, ...target.path.split("/"));
|
|
62
71
|
const body = await fs.readFile(abs);
|
|
72
|
+
const put = withDeadline(fetchImpl, putDeadlineMs(body.byteLength));
|
|
63
73
|
let lastStatus = 0;
|
|
64
74
|
let sawBadDigest = false;
|
|
65
75
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
66
76
|
let res;
|
|
67
77
|
try {
|
|
68
|
-
res = await
|
|
78
|
+
res = await put(target.url, {
|
|
69
79
|
method: "PUT",
|
|
70
80
|
// VERBATIM. No key dropped, no key added, no key renamed.
|
|
71
81
|
headers: { ...target.headers },
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The longest the CLI waits for the address to serve the new version. */
|
|
2
|
+
export declare const VISIBILITY_WAIT_MS = 90000;
|
|
3
|
+
export interface VisibilityDeps {
|
|
4
|
+
fetchImpl: typeof fetch;
|
|
5
|
+
now: () => number;
|
|
6
|
+
sleep: (ms: number) => Promise<void>;
|
|
7
|
+
/** Called once, before the first wait, and only if the first probe missed. */
|
|
8
|
+
onWaiting?: () => void;
|
|
9
|
+
}
|
|
10
|
+
export interface VisibilityResult {
|
|
11
|
+
visible: boolean;
|
|
12
|
+
waitedMs: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Probe `liveUrl` until its body hashes to `indexSha256` (std base64, as in the
|
|
16
|
+
* manifest) or `VISIBILITY_WAIT_MS` passes. Never throws: a failed probe is a miss.
|
|
17
|
+
*
|
|
18
|
+
* Elapsed time is the larger of the clock and the summed sleeps, so the bound
|
|
19
|
+
* holds under a frozen test clock as well as against slow probes.
|
|
20
|
+
*/
|
|
21
|
+
export declare function waitUntilServing(liveUrl: string, indexSha256: string, deps: VisibilityDeps): Promise<VisibilityResult>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// After commit, wait until the public address serves the version just committed.
|
|
2
|
+
//
|
|
3
|
+
// The api flips the slug pointer in CloudFront KeyValueStore, which is eventually
|
|
4
|
+
// consistent: staging measured 13 to 36 s between "committed" and the edge serving
|
|
5
|
+
// the new bytes. The marker is index.html itself. The CLI already holds its sha256
|
|
6
|
+
// from the manifest, so a probe hashes the served body and compares; nothing new
|
|
7
|
+
// has to be added to the bundle, the router or the api.
|
|
8
|
+
//
|
|
9
|
+
// Used by publish/command.ts. IO (fetch, clock, sleep) is injected for tests.
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { withDeadline } from "../shared/http.js";
|
|
12
|
+
/** The longest the CLI waits for the address to serve the new version. */
|
|
13
|
+
export const VISIBILITY_WAIT_MS = 90_000;
|
|
14
|
+
const FIRST_DELAY_MS = 1_000;
|
|
15
|
+
const MAX_DELAY_MS = 5_000;
|
|
16
|
+
const PROBE_DEADLINE_MS = 10_000;
|
|
17
|
+
/**
|
|
18
|
+
* Probe `liveUrl` until its body hashes to `indexSha256` (std base64, as in the
|
|
19
|
+
* manifest) or `VISIBILITY_WAIT_MS` passes. Never throws: a failed probe is a miss.
|
|
20
|
+
*
|
|
21
|
+
* Elapsed time is the larger of the clock and the summed sleeps, so the bound
|
|
22
|
+
* holds under a frozen test clock as well as against slow probes.
|
|
23
|
+
*/
|
|
24
|
+
export async function waitUntilServing(liveUrl, indexSha256, deps) {
|
|
25
|
+
const probe = withDeadline(deps.fetchImpl, PROBE_DEADLINE_MS);
|
|
26
|
+
const startedAt = deps.now();
|
|
27
|
+
let slept = 0;
|
|
28
|
+
let delay = FIRST_DELAY_MS;
|
|
29
|
+
const elapsed = () => Math.max(deps.now() - startedAt, slept);
|
|
30
|
+
for (let attempt = 1;; attempt++) {
|
|
31
|
+
if (await serves(probe, liveUrl, indexSha256, attempt)) {
|
|
32
|
+
return { visible: true, waitedMs: elapsed() };
|
|
33
|
+
}
|
|
34
|
+
const left = VISIBILITY_WAIT_MS - elapsed();
|
|
35
|
+
if (left <= 0)
|
|
36
|
+
return { visible: false, waitedMs: elapsed() };
|
|
37
|
+
if (attempt === 1)
|
|
38
|
+
deps.onWaiting?.();
|
|
39
|
+
const step = Math.min(delay, left);
|
|
40
|
+
await deps.sleep(step);
|
|
41
|
+
slept += step;
|
|
42
|
+
delay = Math.min(delay * 2, MAX_DELAY_MS);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function serves(probe, liveUrl, indexSha256, attempt) {
|
|
46
|
+
try {
|
|
47
|
+
const res = await probe(bustedUrl(liveUrl, attempt), {
|
|
48
|
+
headers: { "cache-control": "no-cache", pragma: "no-cache" },
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
return false;
|
|
52
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
53
|
+
return createHash("sha256").update(body).digest("base64") === indexSha256;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function bustedUrl(liveUrl, attempt) {
|
|
60
|
+
try {
|
|
61
|
+
const u = new URL(liveUrl);
|
|
62
|
+
u.searchParams.set("vincentt-check", `${Date.now().toString(36)}-${attempt}`);
|
|
63
|
+
return u.toString();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return liveUrl;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -74,7 +74,7 @@ export class AnnotationStore {
|
|
|
74
74
|
};
|
|
75
75
|
this.items.push(annotation);
|
|
76
76
|
await fs.appendFile(path.join(this.dir, STORE_FILE), JSON.stringify(annotation) + "\n");
|
|
77
|
-
this.wake(
|
|
77
|
+
this.wake();
|
|
78
78
|
return annotation;
|
|
79
79
|
}
|
|
80
80
|
/** The first annotation with seq greater than `after`, if one is already held. */
|
|
@@ -113,12 +113,17 @@ export class AnnotationStore {
|
|
|
113
113
|
this.waiters.push(waiter);
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
|
-
|
|
116
|
+
// A waiter gets the LOWEST held seq past its cursor, not the annotation whose
|
|
117
|
+
// write just finished: concurrent adds can finish persisting out of seq order,
|
|
118
|
+
// and handing over the later one first moves the consumer's cursor past the
|
|
119
|
+
// earlier one for good.
|
|
120
|
+
wake() {
|
|
117
121
|
const remaining = [];
|
|
118
122
|
for (const w of this.waiters) {
|
|
119
|
-
|
|
123
|
+
const next = this.peekAfter(w.after);
|
|
124
|
+
if (next) {
|
|
120
125
|
clearTimeout(w.timer);
|
|
121
|
-
w.resolve({ kind: "annotation", annotation:
|
|
126
|
+
w.resolve({ kind: "annotation", annotation: next });
|
|
122
127
|
}
|
|
123
128
|
else {
|
|
124
129
|
remaining.push(w);
|
package/dist/relay/server.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
import { type IncomingMessage } from "node:http";
|
|
1
2
|
import { EventStore } from "./store.js";
|
|
2
3
|
import { TesterRegistry } from "./testers.js";
|
|
3
4
|
import { AnnotationStore } from "./annotations.js";
|
|
4
5
|
import type { DiagEvent } from "@vincentt-xr/harness/events";
|
|
5
6
|
export interface RelayOptions {
|
|
6
7
|
port?: number;
|
|
8
|
+
/**
|
|
9
|
+
* The interface to listen on. Loopback by default and in every caller: the
|
|
10
|
+
* phone reaches the relay only through the tunnel client on this machine,
|
|
11
|
+
* which dials loopback, and `/query` and `feedback/next` are local-only.
|
|
12
|
+
*/
|
|
13
|
+
host?: string;
|
|
7
14
|
/** WS path the client connects to (default matches HarnessProvider). */
|
|
8
15
|
path?: string;
|
|
9
16
|
capacity?: number;
|
|
@@ -92,6 +99,20 @@ export declare function startRelay(opts?: RelayOptions): RelayHandle;
|
|
|
92
99
|
* does not match is not a label, and the events simply carry no viewer.
|
|
93
100
|
*/
|
|
94
101
|
export declare function readViewerLabel(raw: string | string[] | undefined): string | undefined;
|
|
102
|
+
/**
|
|
103
|
+
* Whether a request arrived through the preview tunnel. The tunnel client stamps
|
|
104
|
+
* `x-vincentt-viewer` on EVERY request it forwards, after it has sanitised the
|
|
105
|
+
* viewer's headers, so a viewer cannot remove it; a local CLI never sends it.
|
|
106
|
+
* Any value counts, including a malformed or duplicated one: presence is the
|
|
107
|
+
* signal, and a viewer adding a second copy only makes it more present.
|
|
108
|
+
*/
|
|
109
|
+
export declare function cameThroughTunnel(req: IncomingMessage): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* A raw request path the relay refuses before `URL` normalises it: a `.` or `..`
|
|
112
|
+
* segment, or an encoded dot or separator. Without this, `/__harness/../query`
|
|
113
|
+
* normalises onto a local-only route.
|
|
114
|
+
*/
|
|
115
|
+
export declare function isUnsafeRelayPath(rawUrl: string): boolean;
|
|
95
116
|
/**
|
|
96
117
|
* Clamp the hold to `[0, MAX]`, explicitly, with `0` meaning ANSWER NOW.
|
|
97
118
|
*
|
package/dist/relay/server.js
CHANGED
|
@@ -117,7 +117,8 @@ export function startRelay(opts = {}) {
|
|
|
117
117
|
});
|
|
118
118
|
ws.on("close", () => log("client disconnected"));
|
|
119
119
|
});
|
|
120
|
-
|
|
120
|
+
const host = opts.host ?? "127.0.0.1";
|
|
121
|
+
http.listen(port, host, () => log(`relay listening on ${host}:${port} (ws ${wsPath}, http /query)`));
|
|
121
122
|
return {
|
|
122
123
|
store,
|
|
123
124
|
annotations,
|
|
@@ -140,9 +141,42 @@ export function readViewerLabel(raw) {
|
|
|
140
141
|
return undefined;
|
|
141
142
|
return VIEWER_LABEL_RE.test(raw) ? raw : undefined;
|
|
142
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Whether a request arrived through the preview tunnel. The tunnel client stamps
|
|
146
|
+
* `x-vincentt-viewer` on EVERY request it forwards, after it has sanitised the
|
|
147
|
+
* viewer's headers, so a viewer cannot remove it; a local CLI never sends it.
|
|
148
|
+
* Any value counts, including a malformed or duplicated one: presence is the
|
|
149
|
+
* signal, and a viewer adding a second copy only makes it more present.
|
|
150
|
+
*/
|
|
151
|
+
export function cameThroughTunnel(req) {
|
|
152
|
+
return req.headers[VIEWER_STAMP_HEADER] !== undefined;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A raw request path the relay refuses before `URL` normalises it: a `.` or `..`
|
|
156
|
+
* segment, or an encoded dot or separator. Without this, `/__harness/../query`
|
|
157
|
+
* normalises onto a local-only route.
|
|
158
|
+
*/
|
|
159
|
+
export function isUnsafeRelayPath(rawUrl) {
|
|
160
|
+
const raw = rawUrl.split("?")[0] ?? "";
|
|
161
|
+
if (/%(2e|2f|5c)/i.test(raw) || raw.includes("\\"))
|
|
162
|
+
return true;
|
|
163
|
+
return raw.split("/").some((s) => s === "." || s === "..");
|
|
164
|
+
}
|
|
143
165
|
function handleHttp(req, res, ctx) {
|
|
166
|
+
if (isUnsafeRelayPath(req.url ?? "/")) {
|
|
167
|
+
res.writeHead(404).end();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
144
170
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
145
171
|
const p = url.pathname;
|
|
172
|
+
// Local-only: the diagnostics read and the reverse-channel drain belong to
|
|
173
|
+
// the creator's own CLI. A link holder reaches the relay only through the
|
|
174
|
+
// tunnel, and is refused here even if a route to it were ever opened.
|
|
175
|
+
const localOnly = p === "/query" || p === `${ctx.wsPath}/feedback/next`;
|
|
176
|
+
if (localOnly && cameThroughTunnel(req)) {
|
|
177
|
+
res.writeHead(404).end();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
146
180
|
if (p === "/query") {
|
|
147
181
|
handleQuery(req, res, url, ctx);
|
|
148
182
|
return;
|
|
@@ -1 +1,28 @@
|
|
|
1
|
+
export declare const AUTH_EXPIRED_MESSAGE = "Your Vincentt access token is no longer valid. Run `vincentt login` to sign in again.";
|
|
1
2
|
export declare function authFailureMessage(label: string, status: number, detail: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The api refused the token. Distinct from every other refusal because the
|
|
5
|
+
* remedy is the same wherever it happens and it is not a retry: a command that
|
|
6
|
+
* reads it prints authExpiredLines() and exits EXIT.NOT_AUTHENTICATED.
|
|
7
|
+
*/
|
|
8
|
+
export declare class AuthExpiredError extends Error {
|
|
9
|
+
readonly status = 401;
|
|
10
|
+
constructor();
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Every authenticated api client calls this on a non-OK response before it
|
|
14
|
+
* reads the error body. authPaths.test.ts drives each client with a 401 and
|
|
15
|
+
* fails if one reaches its own error arm instead.
|
|
16
|
+
*/
|
|
17
|
+
export declare function rejectAuthExpired(res: Response): void;
|
|
18
|
+
export declare function authExpiredLines(): string[];
|
|
19
|
+
export declare const NOT_SIGNED_IN_LINES: readonly string[];
|
|
20
|
+
/** No token on this machine. Thrown where a verb needs one and cannot ask for it. */
|
|
21
|
+
export declare class NotSignedInError extends Error {
|
|
22
|
+
constructor();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The api refused the token, in either shape a client reports it: the shared
|
|
26
|
+
* AuthExpiredError, or a client error carrying the 401 status.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isAuthRefused(err: unknown): boolean;
|
package/dist/shared/authError.js
CHANGED
|
@@ -2,10 +2,54 @@
|
|
|
2
2
|
// universal first-run state: every existing `vct_` token was invalidated, so the raw
|
|
3
3
|
// "(401): <body>" is unactionable noise for every user at once. Name the cause and give
|
|
4
4
|
// the one instruction that fixes it. Any other status keeps its existing "(<n>): <body>"
|
|
5
|
-
// shape
|
|
5
|
+
// shape: a 500/timeout is a different problem and must not be mislabeled a stale token.
|
|
6
|
+
export const AUTH_EXPIRED_MESSAGE = "Your Vincentt access token is no longer valid. Run `vincentt login` to sign in again.";
|
|
6
7
|
export function authFailureMessage(label, status, detail) {
|
|
7
|
-
if (status === 401)
|
|
8
|
-
return
|
|
9
|
-
}
|
|
8
|
+
if (status === 401)
|
|
9
|
+
return AUTH_EXPIRED_MESSAGE;
|
|
10
10
|
return `${label} (${status})${detail ? `: ${detail}` : ""}`;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* The api refused the token. Distinct from every other refusal because the
|
|
14
|
+
* remedy is the same wherever it happens and it is not a retry: a command that
|
|
15
|
+
* reads it prints authExpiredLines() and exits EXIT.NOT_AUTHENTICATED.
|
|
16
|
+
*/
|
|
17
|
+
export class AuthExpiredError extends Error {
|
|
18
|
+
status = 401;
|
|
19
|
+
constructor() {
|
|
20
|
+
super(AUTH_EXPIRED_MESSAGE);
|
|
21
|
+
this.name = "AuthExpiredError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Every authenticated api client calls this on a non-OK response before it
|
|
26
|
+
* reads the error body. authPaths.test.ts drives each client with a 401 and
|
|
27
|
+
* fails if one reaches its own error arm instead.
|
|
28
|
+
*/
|
|
29
|
+
export function rejectAuthExpired(res) {
|
|
30
|
+
if (res.status === 401)
|
|
31
|
+
throw new AuthExpiredError();
|
|
32
|
+
}
|
|
33
|
+
export function authExpiredLines() {
|
|
34
|
+
return ["", `✗ ${AUTH_EXPIRED_MESSAGE}`, ""];
|
|
35
|
+
}
|
|
36
|
+
export const NOT_SIGNED_IN_LINES = [
|
|
37
|
+
"✗ You're not signed in.",
|
|
38
|
+
" Run `vincentt login` to connect this machine to your Vincentt account.",
|
|
39
|
+
];
|
|
40
|
+
/** No token on this machine. Thrown where a verb needs one and cannot ask for it. */
|
|
41
|
+
export class NotSignedInError extends Error {
|
|
42
|
+
constructor() {
|
|
43
|
+
super(NOT_SIGNED_IN_LINES.join("\n"));
|
|
44
|
+
this.name = "NotSignedInError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The api refused the token, in either shape a client reports it: the shared
|
|
49
|
+
* AuthExpiredError, or a client error carrying the 401 status.
|
|
50
|
+
*/
|
|
51
|
+
export function isAuthRefused(err) {
|
|
52
|
+
if (err instanceof AuthExpiredError)
|
|
53
|
+
return true;
|
|
54
|
+
return err instanceof Error && err.status === 401;
|
|
55
|
+
}
|
package/dist/shared/config.d.ts
CHANGED
|
@@ -117,7 +117,27 @@ export interface ResolvedConfig {
|
|
|
117
117
|
apiUrl: string;
|
|
118
118
|
pat: string;
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Every environment variable the CLI reads a credential from. They are removed
|
|
122
|
+
* from the environment of the creator's dev command, which is repo-controlled.
|
|
123
|
+
* A credential read added to this file without a name here fails a unit test.
|
|
124
|
+
*/
|
|
125
|
+
export declare const CLI_CREDENTIAL_ENV: readonly string[];
|
|
126
|
+
export declare function withoutCliCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
127
|
+
export declare const DEFAULT_API_URL = "https://api.vincentt.studio";
|
|
128
|
+
/**
|
|
129
|
+
* The API host the person running the command configured: an explicit flag, then
|
|
130
|
+
* VINCENTT_API_URL, then the machine config, then the default. Every verb reads
|
|
131
|
+
* its host through here, so a token is never sent to one host by one verb and to
|
|
132
|
+
* another by the next. A binding's override is layered on top by decideApiUrl.
|
|
133
|
+
*/
|
|
134
|
+
export declare function configuredApiUrl(input: {
|
|
135
|
+
flagApiUrl?: string;
|
|
136
|
+
envApiUrl?: string;
|
|
137
|
+
machineApiUrl?: string;
|
|
138
|
+
}): string;
|
|
139
|
+
/** configuredApiUrl from this process's environment and a machine config already read. */
|
|
140
|
+
export declare function machineApiUrl(machine: Partial<MachineConfig>, flagApiUrl?: string): string;
|
|
121
141
|
export interface ResolveConfigOptions {
|
|
122
142
|
/** Honor a binding's apiUrl override (the `--allow-custom-api` flag). */
|
|
123
143
|
allowCustomApi?: boolean;
|