@vincentt-xr/cli 0.19.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 +25 -0
- package/README.md +3 -2
- package/dist/cli/index.js +95 -13
- package/dist/cli/usage-text.js +3 -2
- 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.d.ts +27 -2
- package/dist/login/authBackend.js +86 -17
- package/dist/login/loginCommand.d.ts +35 -3
- package/dist/login/loginCommand.js +81 -5
- package/dist/login/machineLabel.d.ts +46 -0
- package/dist/login/machineLabel.js +119 -0
- package/dist/login/pkceLogin.d.ts +17 -1
- package/dist/login/pkceLogin.js +30 -5
- 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 +17 -4
- 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/authError.d.ts +1 -1
- package/dist/shared/authError.js +3 -1
- 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
package/dist/login/pkceLogin.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// 2. Mint state + PKCE code_verifier/code_challenge (S256).
|
|
6
6
|
// 3. Open the browser to /cli-auth?redirect_uri=<loopback>&state=…&code_challenge=….
|
|
7
7
|
// 4. On the loopback callback: verify state constant-time (reject mismatch → no exchange).
|
|
8
|
-
// 5. POST /access-tokens/exchange {code, code_verifier} → raw PAT (in body, once)
|
|
8
|
+
// 5. POST /access-tokens/exchange {code, code_verifier, label} → raw PAT (in body, once),
|
|
9
|
+
// the stored label, and any machines the 10-machine cap signed out.
|
|
9
10
|
// 6. Write ~/.vincentt/config.json, then GET /me Bearer <PAT> to confirm identity + orgs.
|
|
10
11
|
//
|
|
11
12
|
// Every external boundary is injected (getFreePort, listen, openBrowser, backend, clock/
|
|
@@ -14,8 +15,9 @@
|
|
|
14
15
|
import { getFreePort } from "../preview/net.js";
|
|
15
16
|
import { writeMachineConfig } from "../shared/config.js";
|
|
16
17
|
import { makePkceMaterial, buildCliAuthUrl, parseCallback, } from "./pkce.js";
|
|
17
|
-
import { httpAuthBackend } from "./authBackend.js";
|
|
18
|
+
import { httpAuthBackend, } from "./authBackend.js";
|
|
18
19
|
import { waitForLoopbackCallback } from "./loopback.js";
|
|
20
|
+
import { UpgradeRequiredError } from "../shared/upgradeError.js";
|
|
19
21
|
/** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
|
|
20
22
|
export class LoginTimeoutError extends Error {
|
|
21
23
|
constructor() {
|
|
@@ -23,6 +25,19 @@ export class LoginTimeoutError extends Error {
|
|
|
23
25
|
this.name = "LoginTimeoutError";
|
|
24
26
|
}
|
|
25
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* The exchange was admitted and the token SAVED, then the identity check was
|
|
30
|
+
* refused for this CLI's version. Distinct from a refused exchange: a sign-in
|
|
31
|
+
* now exists on this machine, and machines may have been signed out for it.
|
|
32
|
+
*/
|
|
33
|
+
export class SignedInThenRefusedError extends UpgradeRequiredError {
|
|
34
|
+
evicted;
|
|
35
|
+
constructor(refused, evicted = []) {
|
|
36
|
+
super(refused.minimum, refused.declared);
|
|
37
|
+
this.evicted = evicted;
|
|
38
|
+
this.name = "SignedInThenRefusedError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
26
41
|
/** Thrown when the loopback callback fails state-verify or is cancelled (QA-F1-C3). */
|
|
27
42
|
export class LoginCallbackError extends Error {
|
|
28
43
|
constructor(message) {
|
|
@@ -166,8 +181,18 @@ export async function runPkceLogin(opts) {
|
|
|
166
181
|
},
|
|
167
182
|
});
|
|
168
183
|
// (5) redeem the code for the raw PAT; (6) persist then confirm identity.
|
|
169
|
-
const
|
|
184
|
+
const exchanged = await backend.exchange(code, material.codeVerifier, opts.label);
|
|
185
|
+
const pat = exchanged.token;
|
|
170
186
|
const configPath = await saveToken(opts.apiUrl.replace(/\/$/, ""), pat);
|
|
171
|
-
|
|
172
|
-
|
|
187
|
+
let identity;
|
|
188
|
+
try {
|
|
189
|
+
identity = await backend.me(pat);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (err instanceof UpgradeRequiredError) {
|
|
193
|
+
throw new SignedInThenRefusedError(err, exchanged.evicted);
|
|
194
|
+
}
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
return { identity, configPath, label: exchanged.label, evicted: exchanged.evicted };
|
|
173
198
|
}
|
|
@@ -11,9 +11,7 @@
|
|
|
11
11
|
* The 2s budget is f7's `FETCH_TIMEOUT_MS`, reused rather than re-chosen. A
|
|
12
12
|
* second timeout constant is a second thing to tune and the one nobody finds.
|
|
13
13
|
*/
|
|
14
|
-
import
|
|
15
|
-
import https from "node:https";
|
|
16
|
-
import { CLIENT_HEADER, clientHeaderValue } from "../shared/clientHeader.js";
|
|
14
|
+
import { apiRequest, apiRequestDetached } from "../shared/http.js";
|
|
17
15
|
import { FETCH_TIMEOUT_MS } from "../dx/updateCheck.js";
|
|
18
16
|
import { readPlatformRelease } from "./releaseCache.js";
|
|
19
17
|
import { platformCheckEnabled } from "./offSwitch.js";
|
|
@@ -35,18 +33,14 @@ export async function fetchRelease(opts) {
|
|
|
35
33
|
const onAbort = () => controller.abort();
|
|
36
34
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
37
35
|
try {
|
|
38
|
-
|
|
36
|
+
// The client tag rides along (apiRequest stamps it) because the middleware
|
|
37
|
+
// LOGS it, and logging is not branching: the handler reads no header but
|
|
38
|
+
// `If-None-Match`, so this cannot change the answer. No credential: the
|
|
39
|
+
// route is public and never judged by the CLI floor.
|
|
40
|
+
const res = await apiRequest({ apiUrl: opts.apiUrl }, VERSIONS_PATH, {
|
|
39
41
|
signal: controller.signal,
|
|
40
|
-
headers: {
|
|
41
|
-
|
|
42
|
-
// and logging is not branching: the handler reads no header but
|
|
43
|
-
// `If-None-Match`, so this cannot change the answer. Omitting it would
|
|
44
|
-
// make this one route's requests distinguishable from every other
|
|
45
|
-
// request the CLI makes, which is the opposite of what is wanted.
|
|
46
|
-
[CLIENT_HEADER]: clientHeaderValue(),
|
|
47
|
-
...(opts.etag ? { "if-none-match": opts.etag } : {}),
|
|
48
|
-
},
|
|
49
|
-
});
|
|
42
|
+
headers: opts.etag ? { "if-none-match": opts.etag } : {},
|
|
43
|
+
}, { authenticated: false }, fetchImpl);
|
|
50
44
|
if (!res.ok)
|
|
51
45
|
return undefined;
|
|
52
46
|
const release = readPlatformRelease(await res.json());
|
|
@@ -90,61 +84,34 @@ export async function fetchRelease(opts) {
|
|
|
90
84
|
export function fetchReleaseDetached(opts, onResult) {
|
|
91
85
|
if (!platformCheckEnabled())
|
|
92
86
|
return;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
res.
|
|
115
|
-
|
|
87
|
+
// The socket is unref'd inside apiRequestDetached: without that a pending
|
|
88
|
+
// request is an active handle and the process waits on it.
|
|
89
|
+
apiRequestDetached({ apiUrl: opts.apiUrl }, VERSIONS_PATH, opts.etag ? { "if-none-match": opts.etag } : {}, FETCH_TIMEOUT_MS, (res, abort) => {
|
|
90
|
+
if (res.statusCode !== 200) {
|
|
91
|
+
res.resume();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let body = "";
|
|
95
|
+
res.setEncoding("utf8");
|
|
96
|
+
res.on("data", (chunk) => {
|
|
97
|
+
body += chunk;
|
|
98
|
+
// Bounded: this is parsed on a creator's machine off a public route,
|
|
99
|
+
// and an endless body must not grow without limit.
|
|
100
|
+
if (body.length > 256_000)
|
|
101
|
+
abort();
|
|
102
|
+
});
|
|
103
|
+
res.on("end", () => {
|
|
104
|
+
try {
|
|
105
|
+
const release = readPlatformRelease(JSON.parse(body));
|
|
106
|
+
if (release === undefined)
|
|
107
|
+
return;
|
|
108
|
+
const etag = res.headers.etag;
|
|
109
|
+
onResult(typeof etag === "string" ? { release, etag } : { release });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// A malformed body is nothing to say.
|
|
116
113
|
}
|
|
117
|
-
let body = "";
|
|
118
|
-
res.setEncoding("utf8");
|
|
119
|
-
res.on("data", (chunk) => {
|
|
120
|
-
body += chunk;
|
|
121
|
-
// Bounded: this is parsed on a creator's machine off a public route,
|
|
122
|
-
// and an endless body must not grow without limit.
|
|
123
|
-
if (body.length > 256_000)
|
|
124
|
-
req.destroy();
|
|
125
|
-
});
|
|
126
|
-
res.on("end", () => {
|
|
127
|
-
try {
|
|
128
|
-
const release = readPlatformRelease(JSON.parse(body));
|
|
129
|
-
if (release === undefined)
|
|
130
|
-
return;
|
|
131
|
-
const etag = res.headers.etag;
|
|
132
|
-
onResult(typeof etag === "string" ? { release, etag } : { release });
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
// A malformed body is nothing to say.
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
res.on("error", () => { });
|
|
139
114
|
});
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
req.on("socket", (socket) => socket.unref());
|
|
143
|
-
req.on("error", () => { });
|
|
144
|
-
req.setTimeout(FETCH_TIMEOUT_MS, () => req.destroy());
|
|
145
|
-
req.end();
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
// A refresh that cannot even be started is nothing to say.
|
|
149
|
-
}
|
|
115
|
+
res.on("error", () => { });
|
|
116
|
+
});
|
|
150
117
|
}
|
package/dist/preview/api.js
CHANGED
|
@@ -8,7 +8,8 @@ import { createHash } from "node:crypto";
|
|
|
8
8
|
import { realpath } from "node:fs/promises";
|
|
9
9
|
import { BackendError, ProjectUnreachableError } from "../lib/backend.js";
|
|
10
10
|
import { rejectAuthExpired } from "../shared/authError.js";
|
|
11
|
-
import { apiFetch } from "../shared/http.js";
|
|
11
|
+
import { apiFetch, apiRequest } from "../shared/http.js";
|
|
12
|
+
import { rejectUpgradeRequired } from "../shared/upgradeError.js";
|
|
12
13
|
/** The mint rate limit (§2.16). `retryAfterSec` comes from the header, never the body. */
|
|
13
14
|
export class PreviewRateLimitedError extends BackendError {
|
|
14
15
|
retryAfterSec;
|
|
@@ -106,9 +107,6 @@ async function errorBody(res) {
|
|
|
106
107
|
return {};
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
|
-
function authHeaders(cfg) {
|
|
110
|
-
return { authorization: `Bearer ${cfg.pat}`, "content-type": "application/json" };
|
|
111
|
-
}
|
|
112
110
|
/**
|
|
113
111
|
* parseRetryAfter reads the 429's header. The BODY carries no discriminator for
|
|
114
112
|
* which of the two mint limits fired — the header answers "how long", never
|
|
@@ -121,11 +119,12 @@ export function parseRetryAfter(raw) {
|
|
|
121
119
|
}
|
|
122
120
|
/** POST /projects/{id}/preview-sessions — mint. */
|
|
123
121
|
export async function mintPreviewSession(cfg, projectId, key, fetchImpl = apiFetch) {
|
|
124
|
-
const res = await
|
|
122
|
+
const res = await apiRequest(cfg, `/projects/${encodeURIComponent(projectId)}/preview-sessions`, {
|
|
125
123
|
method: "POST",
|
|
126
|
-
headers:
|
|
124
|
+
headers: { "content-type": "application/json" },
|
|
127
125
|
body: JSON.stringify({ workingTreeKey: key }),
|
|
128
|
-
});
|
|
126
|
+
}, { authenticated: true }, fetchImpl);
|
|
127
|
+
await rejectUpgradeRequired(res);
|
|
129
128
|
if (res.status === 429) {
|
|
130
129
|
throw new PreviewRateLimitedError(parseRetryAfter(res.headers.get("retry-after")));
|
|
131
130
|
}
|
|
@@ -189,8 +188,9 @@ export function parseMint(raw) {
|
|
|
189
188
|
}
|
|
190
189
|
/** POST /preview-sessions/{id}/heartbeat — keepalive AND re-authorization. */
|
|
191
190
|
export async function heartbeatPreviewSession(cfg, sessionId, fetchImpl = apiFetch) {
|
|
192
|
-
const res = await
|
|
191
|
+
const res = await apiRequest(cfg, `/preview-sessions/${encodeURIComponent(sessionId)}/heartbeat`, { method: "POST", headers: { "content-type": "application/json" } }, { authenticated: true }, fetchImpl);
|
|
193
192
|
if (!res.ok) {
|
|
193
|
+
await rejectUpgradeRequired(res);
|
|
194
194
|
rejectAuthExpired(res);
|
|
195
195
|
const body = await errorBody(res);
|
|
196
196
|
if (res.status === 410) {
|
|
@@ -216,10 +216,7 @@ export async function heartbeatPreviewSession(cfg, sessionId, fetchImpl = apiFet
|
|
|
216
216
|
*/
|
|
217
217
|
export async function endPreviewSession(cfg, sessionId, fetchImpl = apiFetch) {
|
|
218
218
|
try {
|
|
219
|
-
await
|
|
220
|
-
method: "DELETE",
|
|
221
|
-
headers: { authorization: `Bearer ${cfg.pat}` },
|
|
222
|
-
});
|
|
219
|
+
await apiRequest(cfg, `/preview-sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }, { authenticated: true }, fetchImpl);
|
|
223
220
|
}
|
|
224
221
|
catch {
|
|
225
222
|
// See above.
|
|
@@ -2,6 +2,7 @@ import type { DiagEvent } from "@vincentt-xr/harness/events";
|
|
|
2
2
|
import type { TunnelState, UAClass } from "../tunnel/index.js";
|
|
3
3
|
import type { EventTags } from "../relay/server.js";
|
|
4
4
|
import type { PreviewEndReason } from "./runner.js";
|
|
5
|
+
import { type UpgradeFacts } from "../shared/upgradeError.js";
|
|
5
6
|
export interface PreviewUiDeps {
|
|
6
7
|
print: (line: string) => void;
|
|
7
8
|
now: () => number;
|
|
@@ -124,13 +125,13 @@ export declare class PreviewUi {
|
|
|
124
125
|
stopping(): void;
|
|
125
126
|
stopped(): void;
|
|
126
127
|
/** Every ending the creator did NOT cause. One first line per cause. */
|
|
127
|
-
endedBy(reason: PreviewEndReason, orgName: string): void;
|
|
128
|
+
endedBy(reason: PreviewEndReason, orgName: string, refusal?: UpgradeFacts): void;
|
|
128
129
|
following(label: string): void;
|
|
129
130
|
notFollowing(label: string): void;
|
|
130
131
|
notFollowingTester(tester: string): void;
|
|
131
132
|
/** Live testers — what `stopping`/`stopped` should be counting. */
|
|
132
133
|
get testerCount(): number;
|
|
133
134
|
}
|
|
134
|
-
export declare function endingLines(reason: PreviewEndReason, orgName: string): string[];
|
|
135
|
+
export declare function endingLines(reason: PreviewEndReason, orgName: string, refusal?: UpgradeFacts): string[];
|
|
135
136
|
/** `21:04` — what a creator plans a demo around, in their own local time. */
|
|
136
137
|
export declare function clockTime(d: Date): string;
|
package/dist/preview/command.js
CHANGED
|
@@ -15,6 +15,7 @@ import * as copy from "./copy.js";
|
|
|
15
15
|
import { PerfThrottle, palette, renderAnnotation, renderEvent, uaLabel, } from "./stream.js";
|
|
16
16
|
import { ViewerRoster } from "./viewers.js";
|
|
17
17
|
import { MAX_TESTERS } from "../relay/testers.js";
|
|
18
|
+
import { previewTooOldLines } from "../shared/upgradeError.js";
|
|
18
19
|
/** How long a viewer may be present with no harness hello before the runtime line. */
|
|
19
20
|
export const NO_DIAGNOSTICS_WINDOW_MS = 10_000;
|
|
20
21
|
function defaultSchedule(fn, ms) {
|
|
@@ -295,11 +296,11 @@ export class PreviewUi {
|
|
|
295
296
|
this.line("");
|
|
296
297
|
}
|
|
297
298
|
/** Every ending the creator did NOT cause. One first line per cause. */
|
|
298
|
-
endedBy(reason, orgName) {
|
|
299
|
+
endedBy(reason, orgName, refusal) {
|
|
299
300
|
if (this.ended)
|
|
300
301
|
return;
|
|
301
302
|
this.ended = true;
|
|
302
|
-
this.lines(endingLines(reason, orgName));
|
|
303
|
+
this.lines(endingLines(reason, orgName, refusal));
|
|
303
304
|
}
|
|
304
305
|
// ---- --viewer -------------------------------------------------------------
|
|
305
306
|
following(label) {
|
|
@@ -317,7 +318,7 @@ export class PreviewUi {
|
|
|
317
318
|
return this.roster.testerCount;
|
|
318
319
|
}
|
|
319
320
|
}
|
|
320
|
-
export function endingLines(reason, orgName) {
|
|
321
|
+
export function endingLines(reason, orgName, refusal = {}) {
|
|
321
322
|
switch (reason) {
|
|
322
323
|
case "hard_max":
|
|
323
324
|
return copy.hardMaxReached();
|
|
@@ -331,8 +332,10 @@ export function endingLines(reason, orgName) {
|
|
|
331
332
|
return copy.endedAbandoned();
|
|
332
333
|
case "authz_revoked":
|
|
333
334
|
return copy.endedUnreachable(orgName);
|
|
334
|
-
case "
|
|
335
|
-
return copy.
|
|
335
|
+
case "authz_lapsed":
|
|
336
|
+
return copy.endedAuthzLapsed();
|
|
337
|
+
case "upgrade_required":
|
|
338
|
+
return previewTooOldLines(refusal);
|
|
336
339
|
case "auth_expired":
|
|
337
340
|
return copy.endedAuthExpired();
|
|
338
341
|
case "egress_cap":
|
package/dist/preview/copy.d.ts
CHANGED
|
@@ -211,7 +211,14 @@ export declare function endedUnreachable(orgName: string): string[];
|
|
|
211
211
|
* project and the access are fine, and the one thing to do is sign in again.
|
|
212
212
|
*/
|
|
213
213
|
export declare function endedAuthExpired(): string[];
|
|
214
|
-
|
|
214
|
+
/**
|
|
215
|
+
* The `authzUntil` drain: the edge ended the session because the platform
|
|
216
|
+
* stopped confirming it, and told this CLI so. It names no access, permission or
|
|
217
|
+
* outage, because the wire carries none of those and the cause may be on either
|
|
218
|
+
* side. "so one isn't started for you" is load-bearing: a new preview is a new
|
|
219
|
+
* link, and a silent re-mint would break every link already shared.
|
|
220
|
+
*/
|
|
221
|
+
export declare function endedAuthzLapsed(): string[];
|
|
215
222
|
/**
|
|
216
223
|
* e. Abandoned — this machine stopped checking in (the 30-minute heartbeat-
|
|
217
224
|
* silence flip, `D-Abandoned-sessions-are-judged-on-heartbeat-silence-alone`).
|
package/dist/preview/copy.js
CHANGED
|
@@ -448,19 +448,32 @@ export function endedUnreachable(orgName) {
|
|
|
448
448
|
export function endedAuthExpired() {
|
|
449
449
|
return [
|
|
450
450
|
"",
|
|
451
|
-
"✗ Preview ended.
|
|
451
|
+
"✗ Preview ended. This machine is no longer signed in to Vincentt.",
|
|
452
452
|
"",
|
|
453
453
|
" Sign in again: vincentt login",
|
|
454
454
|
" Then start a new preview: vincentt preview",
|
|
455
455
|
"",
|
|
456
456
|
];
|
|
457
457
|
}
|
|
458
|
-
|
|
458
|
+
/**
|
|
459
|
+
* The `authzUntil` drain: the edge ended the session because the platform
|
|
460
|
+
* stopped confirming it, and told this CLI so. It names no access, permission or
|
|
461
|
+
* outage, because the wire carries none of those and the cause may be on either
|
|
462
|
+
* side. "so one isn't started for you" is load-bearing: a new preview is a new
|
|
463
|
+
* link, and a silent re-mint would break every link already shared.
|
|
464
|
+
*/
|
|
465
|
+
export function endedAuthzLapsed() {
|
|
459
466
|
return [
|
|
460
467
|
"",
|
|
461
|
-
"✗ Preview ended.
|
|
468
|
+
"✗ Preview ended. Vincentt stopped confirming it, and the link no longer works.",
|
|
469
|
+
"",
|
|
470
|
+
" This window can't tell whether this machine lost touch with Vincentt or",
|
|
471
|
+
" the cause was on our side. Nothing is wrong with your project.",
|
|
462
472
|
"",
|
|
463
|
-
"
|
|
473
|
+
" A new preview gets a new link, so one isn't started for you. Any phone on",
|
|
474
|
+
" the old link will need the new one.",
|
|
475
|
+
"",
|
|
476
|
+
" Start a new one: vincentt preview",
|
|
464
477
|
"",
|
|
465
478
|
];
|
|
466
479
|
}
|
package/dist/preview/proxy.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { connect } from "node:net";
|
|
5
5
|
import { request, } from "node:http";
|
|
6
6
|
import { createGzip } from "node:zlib";
|
|
7
|
+
import { CREDENTIAL_NAMES, PREVIEW_STRIP, foldName, isCredentialName, } from "../shared/credentialNames.js";
|
|
7
8
|
// Rewrite Host to the loopback target. cloudflared forwards the public Host, and
|
|
8
9
|
// esbuild's `serve` 403s any Host it doesn't recognize — so the phone would get a
|
|
9
10
|
// 403. The origin only needs a Host it accepts; the browser never sees this value.
|
|
@@ -187,8 +188,10 @@ export function isHiddenAppPath(url, projectRoot) {
|
|
|
187
188
|
const name = normalizedName(s);
|
|
188
189
|
if (name === REFUSE)
|
|
189
190
|
return true;
|
|
190
|
-
if (SHORT_NAME.test(name) ||
|
|
191
|
+
if (SHORT_NAME.test(name) ||
|
|
192
|
+
isCredentialName(name, CREDENTIAL_NAMES, PREVIEW_STRIP)) {
|
|
191
193
|
return true;
|
|
194
|
+
}
|
|
192
195
|
}
|
|
193
196
|
}
|
|
194
197
|
return false;
|
|
@@ -222,8 +225,7 @@ const REFUSE = Symbol("refuse");
|
|
|
222
225
|
function normalizedName(segment) {
|
|
223
226
|
if (segment.includes(":$"))
|
|
224
227
|
return REFUSE;
|
|
225
|
-
|
|
226
|
-
return beforeColon.replace(/[. ]+$/, "").normalize("NFKC").toUpperCase().toLowerCase();
|
|
228
|
+
return foldName(segment.split(":")[0] ?? "");
|
|
227
229
|
}
|
|
228
230
|
/**
|
|
229
231
|
* A Windows 8.3 short name (`GIT~1`, `CREDEN~1.JSO`) opens the long-named file
|
|
@@ -232,42 +234,6 @@ function normalizedName(segment) {
|
|
|
232
234
|
* proxy can front a Windows file system from any client.
|
|
233
235
|
*/
|
|
234
236
|
const SHORT_NAME = /^[^~]{1,6}~[0-9]/;
|
|
235
|
-
/** D-Preview-hides-credential-files: hard-coded, no opt-out. `*` is any run of characters. */
|
|
236
|
-
const CREDENTIAL_NAMES = Object.freeze([
|
|
237
|
-
"credentials.json",
|
|
238
|
-
"service-account*.json",
|
|
239
|
-
"*firebase-adminsdk*.json",
|
|
240
|
-
"*.pem",
|
|
241
|
-
"*.key",
|
|
242
|
-
"*.p12",
|
|
243
|
-
"*.pfx",
|
|
244
|
-
"*.p8",
|
|
245
|
-
"*.ppk",
|
|
246
|
-
"id_rsa*",
|
|
247
|
-
"id_dsa*",
|
|
248
|
-
"id_ed25519*",
|
|
249
|
-
"id_ecdsa*",
|
|
250
|
-
"*.jks",
|
|
251
|
-
"*.keystore",
|
|
252
|
-
"*.env",
|
|
253
|
-
"*.tfstate",
|
|
254
|
-
"*.tfvars",
|
|
255
|
-
].map((glob) => new RegExp(`^${glob
|
|
256
|
-
.split("*")
|
|
257
|
-
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
258
|
-
.join(".*")}$`, "s")));
|
|
259
|
-
/** A listed name, or one with backup endings (`~`, `.bak`) added, any number of times. */
|
|
260
|
-
function isCredentialName(name) {
|
|
261
|
-
let candidate = name;
|
|
262
|
-
for (;;) {
|
|
263
|
-
if (CREDENTIAL_NAMES.some((re) => re.test(candidate)))
|
|
264
|
-
return true;
|
|
265
|
-
const stripped = candidate.replace(/(?:~|\.bak)$/, "");
|
|
266
|
-
if (stripped === candidate)
|
|
267
|
-
return false;
|
|
268
|
-
candidate = stripped;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
237
|
function checkedSegmentIndexes(segments, projectRoot) {
|
|
272
238
|
const all = segments.map((_, i) => i);
|
|
273
239
|
if (segments[0] !== "@fs")
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type ControlKind, type TunnelState, type UAClass } from "../tunnel/inde
|
|
|
2
2
|
import type { DiagEvent } from "@vincentt-xr/harness/events";
|
|
3
3
|
import { type EventTags } from "../relay/server.js";
|
|
4
4
|
import { type ResolvedConfig } from "../shared/config.js";
|
|
5
|
+
import { type UpgradeFacts } from "../shared/upgradeError.js";
|
|
5
6
|
/** The path a 404 advisory names: the request's own path, without its query. */
|
|
6
7
|
export declare function requestPathOf(url: string | undefined): string;
|
|
7
8
|
/**
|
|
@@ -31,7 +32,19 @@ export type PreviewEndReason = "stopped"
|
|
|
31
32
|
* elapsed without the client getting back): same remedy, different diagnosis,
|
|
32
33
|
* and the copy explains the bound only at the moment it bites.
|
|
33
34
|
*/
|
|
34
|
-
| "grace_cap" | "authz_revoked"
|
|
35
|
+
| "grace_cap" | "authz_revoked"
|
|
36
|
+
/**
|
|
37
|
+
* The edge drained the session because the platform stopped confirming it,
|
|
38
|
+
* and said so (CONTROL `authz_expired`). Final: a new preview is a new link,
|
|
39
|
+
* so nothing is re-minted and nothing redials.
|
|
40
|
+
*/
|
|
41
|
+
| "authz_lapsed"
|
|
42
|
+
/**
|
|
43
|
+
* The platform refused this CLI's version, on the heartbeat (426) or on a
|
|
44
|
+
* dial (410 + `X-Vincentt-Refusal: cli_upgrade_required`). One screen for
|
|
45
|
+
* both. No further platform call is made, the DELETE included.
|
|
46
|
+
*/
|
|
47
|
+
| "upgrade_required"
|
|
35
48
|
/**
|
|
36
49
|
* The 30-minute heartbeat-silence flip. NEVER reported as `hard_max`: a
|
|
37
50
|
* session ended at minute 30 has not reached its 12-hour ceiling, and saying
|
|
@@ -130,8 +143,11 @@ export interface StartPreviewOptions {
|
|
|
130
143
|
onOriginError?: (label: string, code: string) => void;
|
|
131
144
|
/** Their dev server answered again. Re-arms the advisory; prints NOTHING. */
|
|
132
145
|
onOriginRecovered?: () => void;
|
|
133
|
-
/**
|
|
134
|
-
|
|
146
|
+
/**
|
|
147
|
+
* The session ended for a reason the creator did not cause. `refusal` carries
|
|
148
|
+
* the validated floor facts on `upgrade_required` only.
|
|
149
|
+
*/
|
|
150
|
+
onEnded?: (reason: PreviewEndReason, refusal?: UpgradeFacts) => void;
|
|
135
151
|
/** The hard max is approaching. A WARNING, never an ending (L20b). */
|
|
136
152
|
onExpiring?: () => void;
|
|
137
153
|
/** Injected for tests. */
|
|
@@ -154,11 +170,11 @@ export interface RunningPreview {
|
|
|
154
170
|
}
|
|
155
171
|
export declare function startPreview(opts: StartPreviewOptions): Promise<RunningPreview>;
|
|
156
172
|
/**
|
|
157
|
-
*
|
|
158
|
-
* hard-max warning, and it arrives on the same channel. Mapping it to
|
|
159
|
-
* would take away a link that has half an hour left, at exactly the
|
|
160
|
-
* creator is being warned to plan around it
|
|
161
|
-
* renders the warning instead.
|
|
173
|
+
* Three of the four kinds that reach onControl are endings. `expiring` is NOT:
|
|
174
|
+
* it is the hard-max warning, and it arrives on the same channel. Mapping it to
|
|
175
|
+
* an ending would take away a link that has half an hour left, at exactly the
|
|
176
|
+
* moment the creator is being warned to plan around it, so it returns null and
|
|
177
|
+
* the caller renders the warning instead. (`viewer_gone` never reaches here.)
|
|
162
178
|
*/
|
|
163
179
|
export declare function controlToReason(kind: ControlKind): PreviewEndReason | null;
|
|
164
180
|
/**
|
|
@@ -199,7 +215,7 @@ export declare function refusalToReason(status: number): PreviewEndReason | null
|
|
|
199
215
|
* reason maps to its own screen. A 410 with no reason gets the cause-free
|
|
200
216
|
* ending. See the note on PreviewEndedError.
|
|
201
217
|
*/
|
|
202
|
-
export declare function runHeartbeat(config: ResolvedConfig, sessionId: string, fetchImpl: typeof fetch, onEnded?: (reason: PreviewEndReason) => void): Promise<void>;
|
|
218
|
+
export declare function runHeartbeat(config: ResolvedConfig, sessionId: string, fetchImpl: typeof fetch, onEnded?: (reason: PreviewEndReason, refusal?: UpgradeFacts) => void): Promise<void>;
|
|
203
219
|
/**
|
|
204
220
|
* The API's `endReason` → the CLI's ending screen.
|
|
205
221
|
*
|
package/dist/preview/runner.js
CHANGED
|
@@ -26,6 +26,7 @@ import { withoutCliCredentials } from "../shared/config.js";
|
|
|
26
26
|
import { ProjectUnreachableError } from "../lib/backend.js";
|
|
27
27
|
import { AuthExpiredError } from "../shared/authError.js";
|
|
28
28
|
import { apiFetch } from "../shared/http.js";
|
|
29
|
+
import { UpgradeRequiredError, parseDeclared, parseMinimum, } from "../shared/upgradeError.js";
|
|
29
30
|
import { terminalSafe } from "../lib/terminalText.js";
|
|
30
31
|
/** The path a 404 advisory names: the request's own path, without its query. */
|
|
31
32
|
export function requestPathOf(url) {
|
|
@@ -63,6 +64,17 @@ export async function startPreview(opts) {
|
|
|
63
64
|
let heartbeat;
|
|
64
65
|
let minted;
|
|
65
66
|
let stopped = false;
|
|
67
|
+
// Set once the platform refuses this CLI's version: every later platform
|
|
68
|
+
// call would be refused too, so none is made.
|
|
69
|
+
let versionRefused = false;
|
|
70
|
+
const ended = (reason, refusal) => {
|
|
71
|
+
if (reason === "upgrade_required") {
|
|
72
|
+
versionRefused = true;
|
|
73
|
+
if (heartbeat !== undefined)
|
|
74
|
+
clearInterval(heartbeat);
|
|
75
|
+
}
|
|
76
|
+
opts.onEnded?.(reason, refusal);
|
|
77
|
+
};
|
|
66
78
|
const stop = async () => {
|
|
67
79
|
if (stopped)
|
|
68
80
|
return;
|
|
@@ -84,8 +96,9 @@ export async function startPreview(opts) {
|
|
|
84
96
|
// best-effort in intent even though it is in effect: the 12-hour ceiling
|
|
85
97
|
// and the edge's 60-second fail-closed deadline both reap a session whose
|
|
86
98
|
// DELETE never landed.
|
|
87
|
-
if (minted)
|
|
99
|
+
if (minted && !versionRefused) {
|
|
88
100
|
await endPreviewSession(config, minted.sessionId, fetchImpl);
|
|
101
|
+
}
|
|
89
102
|
};
|
|
90
103
|
try {
|
|
91
104
|
// The MINT comes first. Everything below it costs the creator a dev-server
|
|
@@ -231,7 +244,7 @@ export async function startPreview(opts) {
|
|
|
231
244
|
opts.onExpiring?.();
|
|
232
245
|
return;
|
|
233
246
|
}
|
|
234
|
-
|
|
247
|
+
ended(reason);
|
|
235
248
|
},
|
|
236
249
|
onOriginError: opts.onOriginError,
|
|
237
250
|
onStateChange: opts.onTunnelState,
|
|
@@ -241,8 +254,12 @@ export async function startPreview(opts) {
|
|
|
241
254
|
onReconnectRefused: (status) => {
|
|
242
255
|
const reason = refusalToReason(status);
|
|
243
256
|
if (reason !== null)
|
|
244
|
-
|
|
257
|
+
ended(reason);
|
|
245
258
|
},
|
|
259
|
+
onRefused: (_reason, minimum, declared) => ended("upgrade_required", {
|
|
260
|
+
minimum: parseMinimum(minimum),
|
|
261
|
+
declared: parseDeclared(declared),
|
|
262
|
+
}),
|
|
246
263
|
});
|
|
247
264
|
tunnel.start();
|
|
248
265
|
// The heartbeat is keepalive AND re-authorization: every beat re-runs
|
|
@@ -252,10 +269,10 @@ export async function startPreview(opts) {
|
|
|
252
269
|
// One beat in flight at a time: a slow api must not stack up requests.
|
|
253
270
|
let beating = false;
|
|
254
271
|
heartbeat = setInterval(() => {
|
|
255
|
-
if (beating)
|
|
272
|
+
if (beating || versionRefused)
|
|
256
273
|
return;
|
|
257
274
|
beating = true;
|
|
258
|
-
void runHeartbeat(config, minted.sessionId, fetchImpl,
|
|
275
|
+
void runHeartbeat(config, minted.sessionId, fetchImpl, ended).finally(() => {
|
|
259
276
|
beating = false;
|
|
260
277
|
});
|
|
261
278
|
}, minted.heartbeatSec * 1000);
|
|
@@ -284,18 +301,24 @@ export async function startPreview(opts) {
|
|
|
284
301
|
}
|
|
285
302
|
}
|
|
286
303
|
/**
|
|
287
|
-
*
|
|
288
|
-
* hard-max warning, and it arrives on the same channel. Mapping it to
|
|
289
|
-
* would take away a link that has half an hour left, at exactly the
|
|
290
|
-
* creator is being warned to plan around it
|
|
291
|
-
* renders the warning instead.
|
|
304
|
+
* Three of the four kinds that reach onControl are endings. `expiring` is NOT:
|
|
305
|
+
* it is the hard-max warning, and it arrives on the same channel. Mapping it to
|
|
306
|
+
* an ending would take away a link that has half an hour left, at exactly the
|
|
307
|
+
* moment the creator is being warned to plan around it, so it returns null and
|
|
308
|
+
* the caller renders the warning instead. (`viewer_gone` never reaches here.)
|
|
292
309
|
*/
|
|
293
310
|
export function controlToReason(kind) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
311
|
+
switch (kind) {
|
|
312
|
+
case "superseded":
|
|
313
|
+
return "superseded";
|
|
314
|
+
case "revoke":
|
|
315
|
+
return "authz_revoked";
|
|
316
|
+
case "authz_expired":
|
|
317
|
+
return "authz_lapsed";
|
|
318
|
+
case "expiring":
|
|
319
|
+
case "viewer_gone":
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
299
322
|
}
|
|
300
323
|
/**
|
|
301
324
|
* A refused RECONNECT dial → the ending screen it means.
|
|
@@ -346,6 +369,10 @@ export async function runHeartbeat(config, sessionId, fetchImpl, onEnded) {
|
|
|
346
369
|
await heartbeatPreviewSession(config, sessionId, fetchImpl);
|
|
347
370
|
}
|
|
348
371
|
catch (err) {
|
|
372
|
+
if (err instanceof UpgradeRequiredError) {
|
|
373
|
+
onEnded?.("upgrade_required", { minimum: err.minimum, declared: err.declared });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
349
376
|
if (err instanceof AuthExpiredError) {
|
|
350
377
|
onEnded?.("auth_expired");
|
|
351
378
|
return;
|