@vincentt-xr/cli 0.20.0 → 0.21.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 +15 -0
- package/dist/cli/index.js +63 -6
- package/dist/domain/api.js +16 -12
- package/dist/domain/command.js +9 -0
- package/dist/dx/command.js +3 -0
- package/dist/dx/consent.js +10 -14
- package/dist/dx/reportIo.js +6 -4
- package/dist/dx/send.js +6 -4
- package/dist/lib/backend.js +5 -10
- package/dist/lib/exitCodes.d.ts +6 -0
- package/dist/lib/exitCodes.js +6 -0
- package/dist/lib/terminalText.d.ts +6 -0
- package/dist/lib/terminalText.js +10 -0
- package/dist/lib/whoami.js +3 -0
- package/dist/library/assetsCommand.js +4 -0
- package/dist/library/client.js +6 -8
- package/dist/login/authBackend.js +18 -15
- package/dist/login/loginCommand.js +31 -4
- package/dist/login/pkceLogin.d.ts +10 -0
- package/dist/login/pkceLogin.js +24 -1
- package/dist/platform/releaseFetch.js +36 -69
- package/dist/preview/api.js +9 -12
- package/dist/preview/command.d.ts +3 -2
- package/dist/preview/command.js +8 -5
- package/dist/preview/copy.d.ts +8 -1
- package/dist/preview/copy.js +16 -3
- package/dist/preview/proxy.js +5 -39
- package/dist/preview/runner.d.ts +25 -9
- package/dist/preview/runner.js +42 -15
- package/dist/publish/api.js +14 -14
- package/dist/publish/command.d.ts +3 -0
- package/dist/publish/command.js +71 -7
- package/dist/publish/copy.d.ts +9 -0
- package/dist/publish/copy.js +83 -14
- package/dist/publish/keyScan.d.ts +25 -0
- package/dist/publish/keyScan.js +88 -0
- package/dist/publish/manifest.d.ts +51 -19
- package/dist/publish/manifest.js +246 -63
- package/dist/publish/upload.d.ts +8 -0
- package/dist/publish/upload.js +15 -0
- package/dist/publish/weight.d.ts +23 -0
- package/dist/publish/weight.js +113 -0
- package/dist/shared/credentialNames.d.ts +17 -0
- package/dist/shared/credentialNames.js +72 -0
- package/dist/shared/http.d.ts +34 -0
- package/dist/shared/http.js +98 -2
- package/dist/shared/upgradeError.d.ts +62 -0
- package/dist/shared/upgradeError.js +215 -0
- package/dist/tunnel/caps.d.ts +19 -0
- package/dist/tunnel/caps.js +52 -0
- package/dist/tunnel/cbor.d.ts +39 -12
- package/dist/tunnel/cbor.js +84 -10
- package/dist/tunnel/client.d.ts +48 -2
- package/dist/tunnel/client.js +157 -44
- package/dist/tunnel/frame.d.ts +1 -1
- package/dist/tunnel/frame.js +8 -4
- package/dist/tunnel/index.d.ts +3 -2
- package/dist/tunnel/index.js +3 -2
- package/package.json +4 -3
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ManifestEntry } from "./manifest.js";
|
|
2
|
+
export declare const HEAVY_MODEL_BYTES = 10000000;
|
|
3
|
+
export declare const HEAVY_IMAGE_PX = 2048;
|
|
4
|
+
export declare const IMAGE_HEAD_BYTES = 524288;
|
|
5
|
+
export type HeavyFinding = {
|
|
6
|
+
path: string;
|
|
7
|
+
kind: "model";
|
|
8
|
+
bytes: number;
|
|
9
|
+
} | {
|
|
10
|
+
path: string;
|
|
11
|
+
kind: "image";
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
};
|
|
15
|
+
export type ReadHead = (path: string, n: number) => Promise<Uint8Array>;
|
|
16
|
+
export declare function heavyFileFindings(files: readonly ManifestEntry[], readHead: ReadHead): Promise<HeavyFinding[]>;
|
|
17
|
+
type Size = {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
};
|
|
21
|
+
/** Pixel size from a file header, or undefined for an unknown or truncated one. */
|
|
22
|
+
export declare function imageSize(head: Uint8Array): Size | undefined;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The checklist's `Heavy files` finding: a model over 10 MB, or an image over
|
|
2
|
+
// 2048 px on its long side. Local and warning-only; nothing here can fail a
|
|
3
|
+
// publish, and nothing is sent anywhere.
|
|
4
|
+
export const HEAVY_MODEL_BYTES = 10_000_000;
|
|
5
|
+
export const HEAVY_IMAGE_PX = 2048;
|
|
6
|
+
export const IMAGE_HEAD_BYTES = 524_288;
|
|
7
|
+
const MODEL_EXT = /\.(glb|gltf)$/i;
|
|
8
|
+
const IMAGE_EXT = /\.(png|jpe?g|webp|gif)$/i;
|
|
9
|
+
const longSide = (f) => Math.max(f.width, f.height);
|
|
10
|
+
export async function heavyFileFindings(files, readHead) {
|
|
11
|
+
const models = [];
|
|
12
|
+
const images = [];
|
|
13
|
+
for (const f of files) {
|
|
14
|
+
if (MODEL_EXT.test(f.path)) {
|
|
15
|
+
if (f.size > HEAVY_MODEL_BYTES) {
|
|
16
|
+
models.push({ path: f.path, kind: "model", bytes: f.size });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
else if (IMAGE_EXT.test(f.path)) {
|
|
20
|
+
let head;
|
|
21
|
+
try {
|
|
22
|
+
head = await readHead(f.path, Math.min(f.size, IMAGE_HEAD_BYTES));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const dims = imageSize(head);
|
|
28
|
+
if (dims && longSide(dims) > HEAVY_IMAGE_PX) {
|
|
29
|
+
images.push({ path: f.path, kind: "image", ...dims });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
models.sort((a, b) => b.bytes - a.bytes);
|
|
34
|
+
images.sort((a, b) => longSide(b) - longSide(a));
|
|
35
|
+
return [...models, ...images];
|
|
36
|
+
}
|
|
37
|
+
const u16be = (b, o) => (b[o] << 8) | b[o + 1];
|
|
38
|
+
const u32be = (b, o) => ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0;
|
|
39
|
+
const u16le = (b, o) => b[o] | (b[o + 1] << 8);
|
|
40
|
+
const u24le = (b, o) => b[o] | (b[o + 1] << 8) | (b[o + 2] << 16);
|
|
41
|
+
const ascii = (b, o, s) => {
|
|
42
|
+
if (o + s.length > b.length)
|
|
43
|
+
return false;
|
|
44
|
+
for (let i = 0; i < s.length; i++)
|
|
45
|
+
if (b[o + i] !== s.charCodeAt(i))
|
|
46
|
+
return false;
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
/** Pixel size from a file header, or undefined for an unknown or truncated one. */
|
|
50
|
+
export function imageSize(head) {
|
|
51
|
+
const size = readSize(head);
|
|
52
|
+
return size && size.width > 0 && size.height > 0 ? size : undefined;
|
|
53
|
+
}
|
|
54
|
+
function readSize(b) {
|
|
55
|
+
if (b.length >= 24 && b[0] === 0x89 && ascii(b, 1, "PNG\r\n\x1a\n") && ascii(b, 12, "IHDR")) {
|
|
56
|
+
return { width: u32be(b, 16), height: u32be(b, 20) };
|
|
57
|
+
}
|
|
58
|
+
if (b.length >= 10 && (ascii(b, 0, "GIF87a") || ascii(b, 0, "GIF89a"))) {
|
|
59
|
+
return { width: u16le(b, 6), height: u16le(b, 8) };
|
|
60
|
+
}
|
|
61
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8)
|
|
62
|
+
return jpegSize(b);
|
|
63
|
+
if (b.length >= 16 && ascii(b, 0, "RIFF") && ascii(b, 8, "WEBP"))
|
|
64
|
+
return webpSize(b);
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
function jpegSize(b) {
|
|
68
|
+
let o = 2;
|
|
69
|
+
while (o + 4 <= b.length) {
|
|
70
|
+
if (b[o] !== 0xff)
|
|
71
|
+
return undefined;
|
|
72
|
+
const marker = b[o + 1];
|
|
73
|
+
if (marker === 0xff) {
|
|
74
|
+
o += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Standalone markers carry no length: TEM, RSTn, SOI, EOI.
|
|
78
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
|
|
79
|
+
o += 2;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const len = u16be(b, o + 2);
|
|
83
|
+
if (len < 2)
|
|
84
|
+
return undefined;
|
|
85
|
+
const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
86
|
+
if (isSof) {
|
|
87
|
+
if (o + 9 > b.length)
|
|
88
|
+
return undefined;
|
|
89
|
+
return { height: u16be(b, o + 5), width: u16be(b, o + 7) };
|
|
90
|
+
}
|
|
91
|
+
o += 2 + len;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
function webpSize(b) {
|
|
96
|
+
if (ascii(b, 12, "VP8X")) {
|
|
97
|
+
if (b.length < 30)
|
|
98
|
+
return undefined;
|
|
99
|
+
return { width: u24le(b, 24) + 1, height: u24le(b, 27) + 1 };
|
|
100
|
+
}
|
|
101
|
+
if (ascii(b, 12, "VP8L")) {
|
|
102
|
+
if (b.length < 25 || b[20] !== 0x2f)
|
|
103
|
+
return undefined;
|
|
104
|
+
const bits = (b[21] | (b[22] << 8) | (b[23] << 16) | (b[24] << 24)) >>> 0;
|
|
105
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
|
|
106
|
+
}
|
|
107
|
+
if (ascii(b, 12, "VP8 ")) {
|
|
108
|
+
if (b.length < 30 || b[23] !== 0x9d || b[24] !== 0x01 || b[25] !== 0x2a)
|
|
109
|
+
return undefined;
|
|
110
|
+
return { width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff };
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const CREDENTIAL_NAMES: readonly RegExp[];
|
|
2
|
+
export declare const PUBLISH_CREDENTIAL_NAMES: readonly RegExp[];
|
|
3
|
+
/** Backup endings a name may carry and still match. */
|
|
4
|
+
export declare const PREVIEW_STRIP: readonly string[];
|
|
5
|
+
/** Preview's endings plus the precompressed copies a build tool writes beside a file. */
|
|
6
|
+
export declare const PUBLISH_STRIP: readonly string[];
|
|
7
|
+
/**
|
|
8
|
+
* The name a case-insensitive or Windows file system would open: trailing dots
|
|
9
|
+
* and spaces dropped once (Windows resolves `server.pem. .` to `server.pem`),
|
|
10
|
+
* then compatibility and case forms folded.
|
|
11
|
+
*/
|
|
12
|
+
export declare function foldName(name: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Whether an already folded name matches `list`, as is or after removing one
|
|
15
|
+
* ending from `strip`, repeatedly (`credentials.json.bak.gz`).
|
|
16
|
+
*/
|
|
17
|
+
export declare function isCredentialName(name: string, list: readonly RegExp[], strip: readonly string[]): boolean;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The one credential-name list. Preview refuses to serve these names and publish
|
|
2
|
+
// refuses to upload them (publish without `*.pem`, because a public certificate
|
|
3
|
+
// is published legitimately and a private key in one is caught by content).
|
|
4
|
+
// Hard-coded, no opt-out, no exception list.
|
|
5
|
+
const PEM_GLOB = "*.pem";
|
|
6
|
+
const GLOBS = [
|
|
7
|
+
"credentials.json",
|
|
8
|
+
"service-account*.json",
|
|
9
|
+
"*firebase-adminsdk*.json",
|
|
10
|
+
PEM_GLOB,
|
|
11
|
+
"*.key",
|
|
12
|
+
"*.p12",
|
|
13
|
+
"*.pfx",
|
|
14
|
+
"*.p8",
|
|
15
|
+
"*.ppk",
|
|
16
|
+
"id_rsa*",
|
|
17
|
+
"id_dsa*",
|
|
18
|
+
"id_ed25519*",
|
|
19
|
+
"id_ecdsa*",
|
|
20
|
+
"*.jks",
|
|
21
|
+
"*.keystore",
|
|
22
|
+
"*.env",
|
|
23
|
+
"*.tfstate",
|
|
24
|
+
"*.tfvars",
|
|
25
|
+
];
|
|
26
|
+
/** Anchored; `*` is any run of characters, including none. */
|
|
27
|
+
function compileGlob(glob) {
|
|
28
|
+
return new RegExp(`^${glob
|
|
29
|
+
.split("*")
|
|
30
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
31
|
+
.join(".*")}$`, "s");
|
|
32
|
+
}
|
|
33
|
+
export const CREDENTIAL_NAMES = Object.freeze(GLOBS.map(compileGlob));
|
|
34
|
+
const PEM_SOURCE = compileGlob(PEM_GLOB).source;
|
|
35
|
+
export const PUBLISH_CREDENTIAL_NAMES = Object.freeze(CREDENTIAL_NAMES.filter((re) => re.source !== PEM_SOURCE));
|
|
36
|
+
/** Backup endings a name may carry and still match. */
|
|
37
|
+
export const PREVIEW_STRIP = Object.freeze(["~", ".bak"]);
|
|
38
|
+
/** Preview's endings plus the precompressed copies a build tool writes beside a file. */
|
|
39
|
+
export const PUBLISH_STRIP = Object.freeze([
|
|
40
|
+
"~",
|
|
41
|
+
".bak",
|
|
42
|
+
".gz",
|
|
43
|
+
".br",
|
|
44
|
+
".zst",
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* The name a case-insensitive or Windows file system would open: trailing dots
|
|
48
|
+
* and spaces dropped once (Windows resolves `server.pem. .` to `server.pem`),
|
|
49
|
+
* then compatibility and case forms folded.
|
|
50
|
+
*/
|
|
51
|
+
export function foldName(name) {
|
|
52
|
+
return name
|
|
53
|
+
.replace(/[. ]+$/, "")
|
|
54
|
+
.normalize("NFKC")
|
|
55
|
+
.toUpperCase()
|
|
56
|
+
.toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether an already folded name matches `list`, as is or after removing one
|
|
60
|
+
* ending from `strip`, repeatedly (`credentials.json.bak.gz`).
|
|
61
|
+
*/
|
|
62
|
+
export function isCredentialName(name, list, strip) {
|
|
63
|
+
let candidate = name;
|
|
64
|
+
for (;;) {
|
|
65
|
+
if (list.some((re) => re.test(candidate)))
|
|
66
|
+
return true;
|
|
67
|
+
const suffix = strip.find((s) => candidate.endsWith(s));
|
|
68
|
+
if (suffix === undefined)
|
|
69
|
+
return false;
|
|
70
|
+
candidate = candidate.slice(0, candidate.length - suffix.length);
|
|
71
|
+
}
|
|
72
|
+
}
|
package/dist/shared/http.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type IncomingMessage } from "node:http";
|
|
1
2
|
/** A request that did not finish inside its deadline. Nothing is known about its outcome. */
|
|
2
3
|
export declare class RequestTimeout extends Error {
|
|
3
4
|
readonly ms: number;
|
|
@@ -16,6 +17,39 @@ export declare function withDeadline(fetchImpl: typeof fetch, ms: number): typeo
|
|
|
16
17
|
* a test that stubs the global still reaches its stub.
|
|
17
18
|
*/
|
|
18
19
|
export declare const apiFetch: typeof fetch;
|
|
20
|
+
/** Where an api request goes: the configured API host, and the token when one is held. */
|
|
21
|
+
export interface ApiTarget {
|
|
22
|
+
apiUrl: string;
|
|
23
|
+
pat?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ApiRequestOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Whether the request carries the machine token. Declared at every call site
|
|
28
|
+
* rather than inferred from whether a token happens to be held, so a public
|
|
29
|
+
* route can never pick one up by accident.
|
|
30
|
+
*/
|
|
31
|
+
authenticated: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** A request aimed off the configured API origin. Refused before any socket opens. */
|
|
34
|
+
export declare class ApiOriginError extends Error {
|
|
35
|
+
constructor();
|
|
36
|
+
}
|
|
37
|
+
/** The absolute URL for an API path, refusing anything that would leave the API origin. */
|
|
38
|
+
export declare function apiRequestUrl(target: Pick<ApiTarget, "apiUrl">, path: string): URL;
|
|
39
|
+
/**
|
|
40
|
+
* The caller's headers plus the client tag, and the bearer only when
|
|
41
|
+
* authenticated. A caller-supplied tag or Authorization, in any casing, is
|
|
42
|
+
* dropped first, so exactly one of each reaches the wire.
|
|
43
|
+
*/
|
|
44
|
+
export declare function apiHeaders(target: ApiTarget, init: RequestInit["headers"], opts: ApiRequestOptions): Record<string, string>;
|
|
45
|
+
/** Every api request the CLI makes over fetch is built here. S3 uploads are not api requests. */
|
|
46
|
+
export declare function apiRequest(target: ApiTarget, path: string, init: RequestInit | undefined, opts: ApiRequestOptions, fetchImpl?: typeof fetch): Promise<Response>;
|
|
47
|
+
/**
|
|
48
|
+
* An unauthenticated GET whose socket is unref'd, so a pending request never
|
|
49
|
+
* holds the process open. There is no credential parameter at all: the only
|
|
50
|
+
* caller is the public versions refresh after a verb has finished.
|
|
51
|
+
*/
|
|
52
|
+
export declare function apiRequestDetached(target: Pick<ApiTarget, "apiUrl">, path: string, headers: Record<string, string>, timeoutMs: number, onResponse: (res: IncomingMessage, abort: () => void) => void): void;
|
|
19
53
|
/**
|
|
20
54
|
* A request abandoned by the network or by its deadline, as opposed to a
|
|
21
55
|
* response the server sent. Its outcome on the server is unknown.
|
package/dist/shared/http.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
// The one
|
|
2
|
-
//
|
|
1
|
+
// The one place an api request is built. apiRequest stamps the client tag and,
|
|
2
|
+
// only when asked, the bearer; apiFetch bounds every call with a deadline, so a
|
|
3
|
+
// stalled server or a dead network ends in a bounded error instead of a hang
|
|
3
4
|
// (including the Ctrl-C path, which ends the preview session with an api call).
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import https from "node:https";
|
|
7
|
+
import { CLIENT_HEADER, clientHeaderValue } from "./clientHeader.js";
|
|
4
8
|
/** A request that did not finish inside its deadline. Nothing is known about its outcome. */
|
|
5
9
|
export class RequestTimeout extends Error {
|
|
6
10
|
ms;
|
|
@@ -46,6 +50,98 @@ export function withDeadline(fetchImpl, ms) {
|
|
|
46
50
|
* a test that stubs the global still reaches its stub.
|
|
47
51
|
*/
|
|
48
52
|
export const apiFetch = withDeadline((input, init) => globalThis.fetch(input, init), API_DEADLINE_MS);
|
|
53
|
+
/** A request aimed off the configured API origin. Refused before any socket opens. */
|
|
54
|
+
export class ApiOriginError extends Error {
|
|
55
|
+
constructor() {
|
|
56
|
+
super("refusing an api request that is not a path on the configured API host");
|
|
57
|
+
this.name = "ApiOriginError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** The absolute URL for an API path, refusing anything that would leave the API origin. */
|
|
61
|
+
export function apiRequestUrl(target, path) {
|
|
62
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\")) {
|
|
63
|
+
throw new ApiOriginError();
|
|
64
|
+
}
|
|
65
|
+
let base;
|
|
66
|
+
let url;
|
|
67
|
+
try {
|
|
68
|
+
base = new URL(target.apiUrl);
|
|
69
|
+
url = new URL(`${target.apiUrl.replace(/\/+$/, "")}${path}`);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new ApiOriginError();
|
|
73
|
+
}
|
|
74
|
+
if (url.origin !== base.origin)
|
|
75
|
+
throw new ApiOriginError();
|
|
76
|
+
return url;
|
|
77
|
+
}
|
|
78
|
+
const STAMPED = new Set([CLIENT_HEADER.toLowerCase(), "authorization"]);
|
|
79
|
+
/**
|
|
80
|
+
* The caller's headers plus the client tag, and the bearer only when
|
|
81
|
+
* authenticated. A caller-supplied tag or Authorization, in any casing, is
|
|
82
|
+
* dropped first, so exactly one of each reaches the wire.
|
|
83
|
+
*/
|
|
84
|
+
export function apiHeaders(target, init, opts) {
|
|
85
|
+
const out = {};
|
|
86
|
+
new Headers(init).forEach((value, name) => {
|
|
87
|
+
if (!STAMPED.has(name))
|
|
88
|
+
out[name] = value;
|
|
89
|
+
});
|
|
90
|
+
out[CLIENT_HEADER] = clientHeaderValue();
|
|
91
|
+
if (opts.authenticated) {
|
|
92
|
+
if (!target.pat)
|
|
93
|
+
throw new Error("an authenticated api request needs a token");
|
|
94
|
+
out.authorization = `Bearer ${target.pat}`;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/** Every api request the CLI makes over fetch is built here. S3 uploads are not api requests. */
|
|
99
|
+
export function apiRequest(target, path, init = {}, opts, fetchImpl = apiFetch) {
|
|
100
|
+
let url;
|
|
101
|
+
let headers;
|
|
102
|
+
try {
|
|
103
|
+
url = apiRequestUrl(target, path);
|
|
104
|
+
headers = apiHeaders(target, init.headers, opts);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return Promise.reject(err);
|
|
108
|
+
}
|
|
109
|
+
return fetchImpl(url.toString(), { ...init, headers });
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* An unauthenticated GET whose socket is unref'd, so a pending request never
|
|
113
|
+
* holds the process open. There is no credential parameter at all: the only
|
|
114
|
+
* caller is the public versions refresh after a verb has finished.
|
|
115
|
+
*/
|
|
116
|
+
export function apiRequestDetached(target, path, headers, timeoutMs, onResponse) {
|
|
117
|
+
let url;
|
|
118
|
+
let stamped;
|
|
119
|
+
try {
|
|
120
|
+
url = apiRequestUrl(target, path);
|
|
121
|
+
stamped = apiHeaders({ apiUrl: target.apiUrl }, headers, { authenticated: false });
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
127
|
+
try {
|
|
128
|
+
const req = transport.request({
|
|
129
|
+
protocol: url.protocol,
|
|
130
|
+
hostname: url.hostname,
|
|
131
|
+
port: url.port,
|
|
132
|
+
path: `${url.pathname}${url.search}`,
|
|
133
|
+
method: "GET",
|
|
134
|
+
headers: stamped,
|
|
135
|
+
}, (res) => onResponse(res, () => req.destroy()));
|
|
136
|
+
req.on("socket", (socket) => socket.unref());
|
|
137
|
+
req.on("error", () => { });
|
|
138
|
+
req.setTimeout(timeoutMs, () => req.destroy());
|
|
139
|
+
req.end();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// A request that cannot even be started is nothing to say.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
49
145
|
/**
|
|
50
146
|
* A request abandoned by the network or by its deadline, as opposed to a
|
|
51
147
|
* response the server sent. Its outcome on the server is unknown.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export declare const UPGRADE_REQUIRED_STATUS = 426;
|
|
2
|
+
export declare const UPGRADE_REQUIRED_CODE = "cli_upgrade_required";
|
|
3
|
+
/** A constant, never built from the wire. `@latest` is always at or above any floor. */
|
|
4
|
+
export declare const INSTALL_COMMAND = "npm install -g @vincentt-xr/cli@latest";
|
|
5
|
+
/** Why the platform refused: a category, never the declared version itself. */
|
|
6
|
+
export type RefusalCategory = "below" | "missing" | "unparseable";
|
|
7
|
+
export interface UpgradeFacts {
|
|
8
|
+
/** The floor, only when it parsed as strict X.Y.Z. */
|
|
9
|
+
minimum?: string;
|
|
10
|
+
declared?: RefusalCategory;
|
|
11
|
+
}
|
|
12
|
+
export declare class UpgradeRequiredError extends Error implements UpgradeFacts {
|
|
13
|
+
readonly minimum?: string | undefined;
|
|
14
|
+
readonly declared?: RefusalCategory | undefined;
|
|
15
|
+
readonly status = 426;
|
|
16
|
+
constructor(minimum?: string | undefined, declared?: RefusalCategory | undefined);
|
|
17
|
+
}
|
|
18
|
+
/** The floor as the platform sent it, or undefined unless it is strict X.Y.Z. */
|
|
19
|
+
export declare function parseMinimum(raw: unknown): string | undefined;
|
|
20
|
+
/** A refusal category outside the closed set reads as absent. */
|
|
21
|
+
export declare function parseDeclared(raw: unknown): RefusalCategory | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Every api client calls this on a response before any other refusal arm,
|
|
24
|
+
* including rejectAuthExpired. Any 426 is the version refusal, whatever code
|
|
25
|
+
* its body carries.
|
|
26
|
+
*/
|
|
27
|
+
export declare function rejectUpgradeRequired(res: Response): Promise<void>;
|
|
28
|
+
/** Where the refusal landed, which decides what the screen says did not happen. */
|
|
29
|
+
export type RefusalArm = {
|
|
30
|
+
kind: "generic";
|
|
31
|
+
} | {
|
|
32
|
+
kind: "create";
|
|
33
|
+
dir?: string;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "login";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The exchange was admitted and the token saved, then the account check was
|
|
39
|
+
* refused. Something WAS changed: this machine is signed in, and the lines
|
|
40
|
+
* name any machine the sign-in signed out.
|
|
41
|
+
*/
|
|
42
|
+
| {
|
|
43
|
+
kind: "login_saved";
|
|
44
|
+
evictionLines: string[];
|
|
45
|
+
} | {
|
|
46
|
+
kind: "publish";
|
|
47
|
+
/** The live host, when the project was read before the refusal. */
|
|
48
|
+
address?: string;
|
|
49
|
+
/** Set only when the upload completed before the refusal. */
|
|
50
|
+
stillServing?: number;
|
|
51
|
+
} | {
|
|
52
|
+
kind: "preview_start";
|
|
53
|
+
devCommand: string;
|
|
54
|
+
localUrl: string;
|
|
55
|
+
};
|
|
56
|
+
/** Flow L22: the version refusal on any verb, in the arm that knows where it landed. */
|
|
57
|
+
export declare function upgradeRefusalLines(facts: UpgradeFacts, arm?: RefusalArm, own?: string): string[];
|
|
58
|
+
/**
|
|
59
|
+
* Flow L20 (h): a running preview the platform refused, on the heartbeat or on a
|
|
60
|
+
* reconnect dial. One screen for both arrival paths.
|
|
61
|
+
*/
|
|
62
|
+
export declare function previewTooOldLines(facts: UpgradeFacts, own?: string): string[];
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// The platform refused this CLI's version (`426 cli_upgrade_required`, or a dial
|
|
2
|
+
// answered 410 with `X-Vincentt-Refusal: cli_upgrade_required`). The CLI never
|
|
3
|
+
// compares versions itself: it renders what it was told, with its own version
|
|
4
|
+
// read from its own manifest, and it never prints the platform's `message`.
|
|
5
|
+
import { clientVersion } from "./clientHeader.js";
|
|
6
|
+
export const UPGRADE_REQUIRED_STATUS = 426;
|
|
7
|
+
export const UPGRADE_REQUIRED_CODE = "cli_upgrade_required";
|
|
8
|
+
/** A constant, never built from the wire. `@latest` is always at or above any floor. */
|
|
9
|
+
export const INSTALL_COMMAND = "npm install -g @vincentt-xr/cli@latest";
|
|
10
|
+
export class UpgradeRequiredError extends Error {
|
|
11
|
+
minimum;
|
|
12
|
+
declared;
|
|
13
|
+
status = UPGRADE_REQUIRED_STATUS;
|
|
14
|
+
constructor(minimum, declared) {
|
|
15
|
+
super("This vincentt CLI is too old for Vincentt.");
|
|
16
|
+
this.minimum = minimum;
|
|
17
|
+
this.declared = declared;
|
|
18
|
+
this.name = "UpgradeRequiredError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const STRICT_SEMVER = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
|
|
22
|
+
const CATEGORIES = new Set(["below", "missing", "unparseable"]);
|
|
23
|
+
/** The floor as the platform sent it, or undefined unless it is strict X.Y.Z. */
|
|
24
|
+
export function parseMinimum(raw) {
|
|
25
|
+
return typeof raw === "string" && STRICT_SEMVER.test(raw) ? raw : undefined;
|
|
26
|
+
}
|
|
27
|
+
/** A refusal category outside the closed set reads as absent. */
|
|
28
|
+
export function parseDeclared(raw) {
|
|
29
|
+
return typeof raw === "string" && CATEGORIES.has(raw)
|
|
30
|
+
? raw
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Every api client calls this on a response before any other refusal arm,
|
|
35
|
+
* including rejectAuthExpired. Any 426 is the version refusal, whatever code
|
|
36
|
+
* its body carries.
|
|
37
|
+
*/
|
|
38
|
+
export async function rejectUpgradeRequired(res) {
|
|
39
|
+
if (res.status !== UPGRADE_REQUIRED_STATUS)
|
|
40
|
+
return;
|
|
41
|
+
let detail = {};
|
|
42
|
+
try {
|
|
43
|
+
const body = (await res.json());
|
|
44
|
+
if (typeof body?.detail === "object" && body.detail !== null) {
|
|
45
|
+
detail = body.detail;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A refusal with no readable body is still the refusal, with no facts.
|
|
50
|
+
}
|
|
51
|
+
throw new UpgradeRequiredError(parseMinimum(detail.minimum), parseDeclared(detail.declared));
|
|
52
|
+
}
|
|
53
|
+
const WIDTH = 79;
|
|
54
|
+
/** Wrap one sentence to the CLI's line width under a two-space indent. */
|
|
55
|
+
function wrap(text, indent = " ") {
|
|
56
|
+
const out = [];
|
|
57
|
+
let line = "";
|
|
58
|
+
for (const word of text.split(" ")) {
|
|
59
|
+
const next = line === "" ? word : `${line} ${word}`;
|
|
60
|
+
if (line !== "" && indent.length + next.length > WIDTH) {
|
|
61
|
+
out.push(indent + line);
|
|
62
|
+
line = word;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
line = next;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (line !== "")
|
|
69
|
+
out.push(indent + line);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The variant comes from facts the CLI holds: its own manifest version, and the
|
|
74
|
+
* platform's refusal category. An absent or unknown category is the first row.
|
|
75
|
+
*/
|
|
76
|
+
function variantOf(facts, own) {
|
|
77
|
+
if (own === "unknown")
|
|
78
|
+
return "own_unknown";
|
|
79
|
+
if (facts.declared === "missing" || facts.declared === "unparseable") {
|
|
80
|
+
return "not_received";
|
|
81
|
+
}
|
|
82
|
+
return "too_old";
|
|
83
|
+
}
|
|
84
|
+
function needs(facts) {
|
|
85
|
+
return facts.minimum
|
|
86
|
+
? `Vincentt needs ${facts.minimum} or later.`
|
|
87
|
+
: "Vincentt needs a newer one.";
|
|
88
|
+
}
|
|
89
|
+
function factLines(facts, own, variant) {
|
|
90
|
+
switch (variant) {
|
|
91
|
+
case "own_unknown":
|
|
92
|
+
return [
|
|
93
|
+
" It couldn't read its own version, so Vincentt treats it as too old.",
|
|
94
|
+
` ${needs(facts)}`,
|
|
95
|
+
];
|
|
96
|
+
case "not_received":
|
|
97
|
+
return facts.minimum
|
|
98
|
+
? [
|
|
99
|
+
` This is ${own}. Vincentt needs ${facts.minimum} or later, so upgrading will not fix this.`,
|
|
100
|
+
]
|
|
101
|
+
: [` This is ${own}, so upgrading will not fix this.`];
|
|
102
|
+
case "too_old":
|
|
103
|
+
return [` This is ${own}. ${needs(facts)}`];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function headline(variant) {
|
|
107
|
+
switch (variant) {
|
|
108
|
+
case "own_unknown":
|
|
109
|
+
return "✗ This vincentt CLI couldn't tell Vincentt which version it is.";
|
|
110
|
+
case "not_received":
|
|
111
|
+
return "✗ Vincentt received no version from this vincentt CLI, so it refused it.";
|
|
112
|
+
case "too_old":
|
|
113
|
+
return "✗ This vincentt CLI is too old for Vincentt.";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function notDone(arm) {
|
|
117
|
+
switch (arm.kind) {
|
|
118
|
+
case "generic":
|
|
119
|
+
return [" Nothing was changed, and your sign-in is fine."];
|
|
120
|
+
case "create":
|
|
121
|
+
return wrap(arm.dir
|
|
122
|
+
? `The project is in ${arm.dir} and runs locally. No address was reserved, and your sign-in is fine.`
|
|
123
|
+
: "The project is in this folder and runs locally. No address was reserved, and your sign-in is fine.");
|
|
124
|
+
case "login":
|
|
125
|
+
return [" No sign-in was created, and no other machine was signed out."];
|
|
126
|
+
case "login_saved":
|
|
127
|
+
return [" This machine is signed in, and the sign-in is saved.", ...arm.evictionLines];
|
|
128
|
+
case "publish":
|
|
129
|
+
if (arm.address === undefined) {
|
|
130
|
+
return [" Nothing was published, and your sign-in is fine."];
|
|
131
|
+
}
|
|
132
|
+
return wrap(arm.stillServing !== undefined
|
|
133
|
+
? `Nothing was published. ${arm.address} is still serving v${arm.stillServing}, and your sign-in is fine.`
|
|
134
|
+
: `Nothing was published. ${arm.address} is unchanged, and your sign-in is fine.`);
|
|
135
|
+
case "preview_start":
|
|
136
|
+
return [" No preview was started, and your sign-in is fine."];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function thenLine(arm) {
|
|
140
|
+
switch (arm.kind) {
|
|
141
|
+
case "generic":
|
|
142
|
+
case "login_saved":
|
|
143
|
+
return " Then run the same command again.";
|
|
144
|
+
case "create":
|
|
145
|
+
return arm.dir
|
|
146
|
+
? ` Then, inside ${arm.dir}, register it: vincentt create`
|
|
147
|
+
: " Then register it: vincentt create";
|
|
148
|
+
case "login":
|
|
149
|
+
return " Then sign in again: vincentt login";
|
|
150
|
+
case "publish":
|
|
151
|
+
return " Then publish again: vincentt publish";
|
|
152
|
+
case "preview_start":
|
|
153
|
+
return " Then start it again: vincentt preview";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function remedy(variant) {
|
|
157
|
+
if (variant === "not_received")
|
|
158
|
+
return " Report it: vincentt dx --send";
|
|
159
|
+
const label = variant === "own_unknown" ? "Reinstall:" : "Upgrade:";
|
|
160
|
+
return ` ${label} ${INSTALL_COMMAND}`;
|
|
161
|
+
}
|
|
162
|
+
/** Flow L22: the version refusal on any verb, in the arm that knows where it landed. */
|
|
163
|
+
export function upgradeRefusalLines(facts, arm = { kind: "generic" }, own = clientVersion()) {
|
|
164
|
+
const variant = variantOf(facts, own);
|
|
165
|
+
const out = [
|
|
166
|
+
"",
|
|
167
|
+
headline(variant),
|
|
168
|
+
"",
|
|
169
|
+
...factLines(facts, own, variant),
|
|
170
|
+
...notDone(arm),
|
|
171
|
+
"",
|
|
172
|
+
remedy(variant),
|
|
173
|
+
];
|
|
174
|
+
if (variant !== "not_received")
|
|
175
|
+
out.push(thenLine(arm));
|
|
176
|
+
if (arm.kind === "preview_start") {
|
|
177
|
+
out.push("", ` Your local dev loop does not need an upgrade. Run \`${arm.devCommand}\` and open`, ` ${arm.localUrl} on this machine. The camera works there.`);
|
|
178
|
+
}
|
|
179
|
+
out.push("");
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Flow L20 (h): a running preview the platform refused, on the heartbeat or on a
|
|
184
|
+
* reconnect dial. One screen for both arrival paths.
|
|
185
|
+
*/
|
|
186
|
+
export function previewTooOldLines(facts, own = clientVersion()) {
|
|
187
|
+
const variant = variantOf(facts, own);
|
|
188
|
+
const facts1 = variant === "own_unknown"
|
|
189
|
+
? [
|
|
190
|
+
" It couldn't read its own version, so Vincentt treats it as too old.",
|
|
191
|
+
` ${needs(facts).replace(/\.$/, "")}, so the link no longer works.`,
|
|
192
|
+
]
|
|
193
|
+
: variant === "not_received"
|
|
194
|
+
? [
|
|
195
|
+
` This is ${own}. ${needs(facts).replace(/\.$/, "")}, so upgrading will not fix this.`,
|
|
196
|
+
" The link no longer works.",
|
|
197
|
+
]
|
|
198
|
+
: [
|
|
199
|
+
` This is ${own}. ${needs(facts).replace(/\.$/, "")}, so the link no longer works.`,
|
|
200
|
+
];
|
|
201
|
+
const out = [
|
|
202
|
+
"",
|
|
203
|
+
"✗ Preview ended. This vincentt CLI is now too old for Vincentt.",
|
|
204
|
+
"",
|
|
205
|
+
...facts1,
|
|
206
|
+
" Nothing is wrong with your project, and your sign-in is fine.",
|
|
207
|
+
"",
|
|
208
|
+
remedy(variant),
|
|
209
|
+
];
|
|
210
|
+
if (variant !== "not_received") {
|
|
211
|
+
out.push(" Then start a new one, which gets a new link: vincentt preview");
|
|
212
|
+
}
|
|
213
|
+
out.push("");
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability negotiation, the client's half (f3 §2.2.2). The client offers on
|
|
3
|
+
* every dial; the edge answers the agreed set on the 101, or omits the header
|
|
4
|
+
* when it agreed nothing. The agreed set fixes this connection's decode grammar.
|
|
5
|
+
*/
|
|
6
|
+
import { type CapSet, type TunnelCap } from "./cbor.js";
|
|
7
|
+
/** Lower-case: Node lower-cases response header names. */
|
|
8
|
+
export declare const CAPS_HEADER = "x-vincentt-tunnel-caps";
|
|
9
|
+
/** Every element this client understands, offered on every dial. */
|
|
10
|
+
export declare const OFFERED_CAPS: readonly TunnelCap[];
|
|
11
|
+
export declare const OFFERED_CAPS_VALUE: string;
|
|
12
|
+
/**
|
|
13
|
+
* The agreed set from a 101, or null for a protocol violation: the header
|
|
14
|
+
* duplicated, an array, ungrammatical, or naming a token this client did not
|
|
15
|
+
* offer. Absent is the base protocol, never a violation. `rawHeaders` is
|
|
16
|
+
* consulted because Node joins a duplicated header into one comma list, which
|
|
17
|
+
* would otherwise read as a grammatical value.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseAgreedCaps(value: unknown, rawHeaders: readonly string[] | undefined, offered?: readonly TunnelCap[]): CapSet | null;
|