@vincentt-xr/cli 0.20.0 → 0.22.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 +30 -0
- package/dist/cli/index.js +86 -12
- package/dist/domain/api.js +16 -12
- package/dist/domain/command.js +9 -0
- package/dist/dx/command.d.ts +74 -18
- package/dist/dx/command.js +140 -86
- package/dist/dx/consent.d.ts +25 -6
- package/dist/dx/consent.js +64 -35
- package/dist/dx/enablement.d.ts +63 -11
- package/dist/dx/enablement.js +93 -21
- package/dist/dx/report.d.ts +4 -24
- package/dist/dx/report.js +32 -25
- 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/version.d.ts +28 -2
- package/dist/lib/version.js +30 -3
- 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 +10 -5
- package/dist/preview/copy.d.ts +13 -1
- package/dist/preview/copy.js +31 -3
- package/dist/preview/proxy.js +5 -39
- package/dist/preview/runner.d.ts +31 -9
- package/dist/preview/runner.js +43 -15
- package/dist/publish/api.js +14 -14
- package/dist/publish/command.d.ts +3 -0
- package/dist/publish/command.js +74 -7
- package/dist/publish/copy.d.ts +15 -0
- package/dist/publish/copy.js +109 -19
- package/dist/publish/keyScan.d.ts +25 -0
- package/dist/publish/keyScan.js +88 -0
- package/dist/publish/manifest.d.ts +60 -19
- package/dist/publish/manifest.js +290 -71
- package/dist/publish/upload.d.ts +18 -0
- package/dist/publish/upload.js +38 -0
- package/dist/publish/weight.d.ts +23 -0
- package/dist/publish/weight.js +113 -0
- package/dist/shared/config.d.ts +6 -6
- 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 +56 -2
- package/dist/tunnel/client.js +160 -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
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// GET /me Bearer <PAT> → identity + orgs (confirm)
|
|
10
10
|
// and, to end this machine's own sign-in, DELETE /access-tokens/current Bearer <PAT>.
|
|
11
11
|
import { authFailureMessage } from "../shared/authError.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { apiRequest, withDeadline } from "../shared/http.js";
|
|
13
|
+
import { rejectUpgradeRequired } from "../shared/upgradeError.js";
|
|
14
14
|
/** DELETE /access-tokens/current gives up after this long: sign-out never hangs on the api. */
|
|
15
15
|
export const REVOKE_CURRENT_TIMEOUT_MS = 5_000;
|
|
16
16
|
class AuthError extends Error {
|
|
@@ -52,12 +52,18 @@ function readEvicted(raw) {
|
|
|
52
52
|
}
|
|
53
53
|
/** The real HTTP-backed AuthBackend, pointed at a Vincentt API base URL. */
|
|
54
54
|
export function httpAuthBackend(apiBase) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
const target = { apiUrl: apiBase };
|
|
56
|
+
// The exchange is unauthenticated but judged by the CLI floor, so it carries
|
|
57
|
+
// the tag like every other api request and its 426 is read before any arm.
|
|
58
|
+
const post = async (body) => {
|
|
59
|
+
const res = await apiRequest(target, "/access-tokens/exchange", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "content-type": "application/json" },
|
|
62
|
+
body: JSON.stringify(body),
|
|
63
|
+
}, { authenticated: false });
|
|
64
|
+
await rejectUpgradeRequired(res);
|
|
65
|
+
return res;
|
|
66
|
+
};
|
|
61
67
|
return {
|
|
62
68
|
async exchange(code, codeVerifier, label) {
|
|
63
69
|
const body = { code, code_verifier: codeVerifier };
|
|
@@ -89,9 +95,10 @@ export function httpAuthBackend(apiBase) {
|
|
|
89
95
|
};
|
|
90
96
|
},
|
|
91
97
|
async me(pat) {
|
|
92
|
-
const res = await
|
|
93
|
-
|
|
98
|
+
const res = await apiRequest({ apiUrl: apiBase, pat }, "/me", {}, {
|
|
99
|
+
authenticated: true,
|
|
94
100
|
});
|
|
101
|
+
await rejectUpgradeRequired(res);
|
|
95
102
|
if (!res.ok) {
|
|
96
103
|
throw new AuthError(authFailureMessage("Identity check failed", res.status, await safeText(res)), res.status);
|
|
97
104
|
}
|
|
@@ -106,12 +113,8 @@ export function httpAuthBackend(apiBase) {
|
|
|
106
113
|
* failure, a timeout or a 5xx is "unreachable".
|
|
107
114
|
*/
|
|
108
115
|
export async function revokeCurrent(apiBase, pat, fetchImpl = (input, init) => globalThis.fetch(input, init), timeoutMs = REVOKE_CURRENT_TIMEOUT_MS) {
|
|
109
|
-
const base = apiBase.replace(/\/$/, "");
|
|
110
116
|
try {
|
|
111
|
-
const res = await
|
|
112
|
-
method: "DELETE",
|
|
113
|
-
headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
|
|
114
|
-
});
|
|
117
|
+
const res = await apiRequest({ apiUrl: apiBase, pat }, "/access-tokens/current", { method: "DELETE" }, { authenticated: true }, withDeadline(fetchImpl, timeoutMs));
|
|
115
118
|
if (res.ok || [400, 401, 403, 404, 405].includes(res.status))
|
|
116
119
|
return "done";
|
|
117
120
|
return "unreachable";
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
// L1–L4 branch is unit-testable with no real browser, socket, or config file.
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import { isAuthRefused } from "../shared/authError.js";
|
|
10
|
+
import { UpgradeRequiredError, upgradeRefusalLines } from "../shared/upgradeError.js";
|
|
10
11
|
import { EXIT } from "../lib/exitCodes.js";
|
|
11
12
|
import { dayMonth, sanitizeServerLabel } from "./machineLabel.js";
|
|
13
|
+
import { SignedInThenRefusedError } from "./pkceLogin.js";
|
|
12
14
|
/** Collapse the home dir to `~` for display. `~/.vincentt/config.json` in every transcript. */
|
|
13
15
|
export function displayConfigPath(absPath, home) {
|
|
14
16
|
if (home && absPath.startsWith(home))
|
|
@@ -58,8 +60,10 @@ export async function loginCommand(deps, opts = {}) {
|
|
|
58
60
|
// The old token stays on disk until the new one is saved: a failed login must
|
|
59
61
|
// never leave the machine with neither.
|
|
60
62
|
const old = deps.storedToken ? await deps.storedToken() : undefined;
|
|
61
|
-
const code = await
|
|
62
|
-
|
|
63
|
+
const { code, saved } = await signIn(deps);
|
|
64
|
+
// Keyed on the new token being SAVED, not on success: a version refusal
|
|
65
|
+
// after the save still replaced the old token, which must not stay live.
|
|
66
|
+
if (saved && old && deps.revokeToken) {
|
|
63
67
|
try {
|
|
64
68
|
await deps.revokeToken(old);
|
|
65
69
|
}
|
|
@@ -78,6 +82,8 @@ export async function loginCommand(deps, opts = {}) {
|
|
|
78
82
|
who = await deps.whoAmI();
|
|
79
83
|
}
|
|
80
84
|
catch (err) {
|
|
85
|
+
if (err instanceof UpgradeRequiredError)
|
|
86
|
+
return refusedVersion(deps, err);
|
|
81
87
|
if (!isTokenRejected(err)) {
|
|
82
88
|
// A down api or a dead network says nothing about the token, so it stays.
|
|
83
89
|
deps.print("✗ Could not check the account for the token on this machine.");
|
|
@@ -102,6 +108,10 @@ function isTokenRejected(err) {
|
|
|
102
108
|
(err instanceof Error && err.status === 403));
|
|
103
109
|
}
|
|
104
110
|
async function runLoginFlow(deps) {
|
|
111
|
+
return (await signIn(deps)).code;
|
|
112
|
+
}
|
|
113
|
+
/** The flow's exit code, and whether a new token was written to this machine. */
|
|
114
|
+
async function signIn(deps) {
|
|
105
115
|
// L1 / L4 — run the loopback + PKCE flow.
|
|
106
116
|
try {
|
|
107
117
|
const { identity, configPath, label, evicted } = await deps.runLogin();
|
|
@@ -116,16 +126,33 @@ async function runLoginFlow(deps) {
|
|
|
116
126
|
deps.print(` Token saved to ${displayConfigPath(configPath, deps.home)}`);
|
|
117
127
|
deps.print("");
|
|
118
128
|
deps.print("Run `vincentt --help` to see what you can do.");
|
|
119
|
-
return
|
|
129
|
+
return { code: EXIT.OK, saved: true };
|
|
120
130
|
}
|
|
121
131
|
catch (err) {
|
|
132
|
+
// Refused after the exchange: the token is on disk, so the screen says so
|
|
133
|
+
// and names any machine this sign-in signed out.
|
|
134
|
+
if (err instanceof SignedInThenRefusedError) {
|
|
135
|
+
const arm = { kind: "login_saved", evictionLines: err.evicted.map(evictionLine) };
|
|
136
|
+
for (const l of upgradeRefusalLines(err, arm))
|
|
137
|
+
deps.print(l);
|
|
138
|
+
return { code: EXIT.UPGRADE_REQUIRED, saved: true };
|
|
139
|
+
}
|
|
140
|
+
// The exchange is judged before anything is minted, so no token was written.
|
|
141
|
+
if (err instanceof UpgradeRequiredError) {
|
|
142
|
+
return { code: refusedVersion(deps, err), saved: false };
|
|
143
|
+
}
|
|
122
144
|
// L4 — timeout / callback failure: ✗ line + the exact next command. No token written.
|
|
123
145
|
const message = err instanceof Error ? err.message : "Sign-in failed.";
|
|
124
146
|
deps.print(`✗ ${message}`);
|
|
125
147
|
deps.print(" Run `vincentt login` to try again.");
|
|
126
|
-
return
|
|
148
|
+
return { code: EXIT.FAILED, saved: false };
|
|
127
149
|
}
|
|
128
150
|
}
|
|
151
|
+
function refusedVersion(deps, err) {
|
|
152
|
+
for (const l of upgradeRefusalLines(err, { kind: "login" }))
|
|
153
|
+
deps.print(l);
|
|
154
|
+
return EXIT.UPGRADE_REQUIRED;
|
|
155
|
+
}
|
|
129
156
|
/**
|
|
130
157
|
* `vincentt logout` (Flow L7): the platform sign-out was already attempted; the
|
|
131
158
|
* local token is ALWAYS deleted, and only an api that could not be reached adds
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AuthBackend, type EvictedMachine, type Identity } from "./authBackend.js";
|
|
2
|
+
import { UpgradeRequiredError } from "../shared/upgradeError.js";
|
|
2
3
|
export interface PkceLoginOptions {
|
|
3
4
|
/** Console origin that serves /cli-auth (browser target). */
|
|
4
5
|
consoleUrl: string;
|
|
@@ -31,6 +32,15 @@ export interface PkceLoginResult {
|
|
|
31
32
|
export declare class LoginTimeoutError extends Error {
|
|
32
33
|
constructor();
|
|
33
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The exchange was admitted and the token SAVED, then the identity check was
|
|
37
|
+
* refused for this CLI's version. Distinct from a refused exchange: a sign-in
|
|
38
|
+
* now exists on this machine, and machines may have been signed out for it.
|
|
39
|
+
*/
|
|
40
|
+
export declare class SignedInThenRefusedError extends UpgradeRequiredError {
|
|
41
|
+
readonly evicted: EvictedMachine[];
|
|
42
|
+
constructor(refused: UpgradeRequiredError, evicted?: EvictedMachine[]);
|
|
43
|
+
}
|
|
34
44
|
/** Thrown when the loopback callback fails state-verify or is cancelled (QA-F1-C3). */
|
|
35
45
|
export declare class LoginCallbackError extends Error {
|
|
36
46
|
constructor(message: string);
|
package/dist/login/pkceLogin.js
CHANGED
|
@@ -17,6 +17,7 @@ import { writeMachineConfig } from "../shared/config.js";
|
|
|
17
17
|
import { makePkceMaterial, buildCliAuthUrl, parseCallback, } from "./pkce.js";
|
|
18
18
|
import { httpAuthBackend, } from "./authBackend.js";
|
|
19
19
|
import { waitForLoopbackCallback } from "./loopback.js";
|
|
20
|
+
import { UpgradeRequiredError } from "../shared/upgradeError.js";
|
|
20
21
|
/** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
|
|
21
22
|
export class LoginTimeoutError extends Error {
|
|
22
23
|
constructor() {
|
|
@@ -24,6 +25,19 @@ export class LoginTimeoutError extends Error {
|
|
|
24
25
|
this.name = "LoginTimeoutError";
|
|
25
26
|
}
|
|
26
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
|
+
}
|
|
27
41
|
/** Thrown when the loopback callback fails state-verify or is cancelled (QA-F1-C3). */
|
|
28
42
|
export class LoginCallbackError extends Error {
|
|
29
43
|
constructor(message) {
|
|
@@ -170,6 +184,15 @@ export async function runPkceLogin(opts) {
|
|
|
170
184
|
const exchanged = await backend.exchange(code, material.codeVerifier, opts.label);
|
|
171
185
|
const pat = exchanged.token;
|
|
172
186
|
const configPath = await saveToken(opts.apiUrl.replace(/\/$/, ""), pat);
|
|
173
|
-
|
|
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
|
+
}
|
|
174
197
|
return { identity, configPath, label: exchanged.label, evicted: exchanged.evicted };
|
|
175
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,10 +332,14 @@ 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();
|
|
341
|
+
case "protocol_violation":
|
|
342
|
+
return copy.endedProtocolViolation();
|
|
338
343
|
case "egress_cap":
|
|
339
344
|
return copy.egressHard();
|
|
340
345
|
case "stopped":
|
package/dist/preview/copy.d.ts
CHANGED
|
@@ -211,7 +211,19 @@ 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[];
|
|
222
|
+
/**
|
|
223
|
+
* The edge broke the agreed tunnel wire. Only an edge bug causes it, so the
|
|
224
|
+
* remedy is a newer CLI or a later attempt, never anything in the project.
|
|
225
|
+
*/
|
|
226
|
+
export declare function endedProtocolViolation(): string[];
|
|
215
227
|
/**
|
|
216
228
|
* e. Abandoned — this machine stopped checking in (the 30-minute heartbeat-
|
|
217
229
|
* silence flip, `D-Abandoned-sessions-are-judged-on-heartbeat-silence-alone`).
|
package/dist/preview/copy.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// product, so f1's "a token is never printed" extends to it verbatim.
|
|
19
19
|
/** Flow L13 — the start block. */
|
|
20
20
|
import { escapeControl, terminalSafe } from "../lib/terminalText.js";
|
|
21
|
+
import { INSTALL_COMMAND } from "../shared/upgradeError.js";
|
|
21
22
|
export function startBlock(i) {
|
|
22
23
|
const out = [
|
|
23
24
|
"",
|
|
@@ -455,12 +456,39 @@ export function endedAuthExpired() {
|
|
|
455
456
|
"",
|
|
456
457
|
];
|
|
457
458
|
}
|
|
458
|
-
|
|
459
|
+
/**
|
|
460
|
+
* The `authzUntil` drain: the edge ended the session because the platform
|
|
461
|
+
* stopped confirming it, and told this CLI so. It names no access, permission or
|
|
462
|
+
* outage, because the wire carries none of those and the cause may be on either
|
|
463
|
+
* side. "so one isn't started for you" is load-bearing: a new preview is a new
|
|
464
|
+
* link, and a silent re-mint would break every link already shared.
|
|
465
|
+
*/
|
|
466
|
+
export function endedAuthzLapsed() {
|
|
467
|
+
return [
|
|
468
|
+
"",
|
|
469
|
+
"✗ Preview ended. Vincentt stopped confirming it, and the link no longer works.",
|
|
470
|
+
"",
|
|
471
|
+
" This window can't tell whether this machine lost touch with Vincentt or",
|
|
472
|
+
" the cause was on our side. Nothing is wrong with your project.",
|
|
473
|
+
"",
|
|
474
|
+
" A new preview gets a new link, so one isn't started for you. Any phone on",
|
|
475
|
+
" the old link will need the new one.",
|
|
476
|
+
"",
|
|
477
|
+
" Start a new one: vincentt preview",
|
|
478
|
+
"",
|
|
479
|
+
];
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* The edge broke the agreed tunnel wire. Only an edge bug causes it, so the
|
|
483
|
+
* remedy is a newer CLI or a later attempt, never anything in the project.
|
|
484
|
+
*/
|
|
485
|
+
export function endedProtocolViolation() {
|
|
459
486
|
return [
|
|
460
487
|
"",
|
|
461
|
-
"✗ Preview
|
|
488
|
+
"✗ Preview stopped: the preview server sent an invalid reply. Update the CLI or try again later.",
|
|
462
489
|
"",
|
|
463
|
-
|
|
490
|
+
` Upgrade: ${INSTALL_COMMAND}`,
|
|
491
|
+
" Then try again: vincentt preview",
|
|
464
492
|
"",
|
|
465
493
|
];
|
|
466
494
|
}
|
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")
|