@vincentt-xr/cli 0.18.3 → 0.20.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 +26 -2
- package/README.md +3 -2
- package/dist/cli/index.js +43 -12
- package/dist/cli/usage-text.js +4 -3
- package/dist/lib/terminalText.d.ts +2 -0
- package/dist/lib/terminalText.js +7 -0
- package/dist/login/authBackend.d.ts +27 -2
- package/dist/login/authBackend.js +79 -13
- package/dist/login/loginCommand.d.ts +35 -3
- package/dist/login/loginCommand.js +52 -3
- package/dist/login/machineLabel.d.ts +46 -0
- package/dist/login/machineLabel.js +119 -0
- package/dist/login/pkceLogin.d.ts +14 -2
- package/dist/login/pkceLogin.js +31 -8
- package/dist/preview/api.d.ts +15 -0
- package/dist/preview/api.js +27 -1
- package/dist/preview/command.d.ts +1 -1
- package/dist/preview/copy.d.ts +14 -1
- package/dist/preview/copy.js +38 -4
- package/dist/preview/devPort.d.ts +17 -0
- package/dist/preview/devPort.js +14 -0
- package/dist/preview/proxy.d.ts +16 -9
- package/dist/preview/proxy.js +102 -20
- package/dist/preview/runner.d.ts +10 -2
- package/dist/preview/runner.js +21 -6
- package/dist/preview/stream.d.ts +1 -1
- package/dist/preview/viewers.d.ts +1 -1
- package/dist/relay/server.js +1 -1
- package/dist/shared/authError.d.ts +1 -1
- package/dist/shared/authError.js +3 -1
- package/dist/tunnel/cbor.d.ts +71 -0
- package/dist/tunnel/cbor.js +403 -0
- package/dist/tunnel/client.d.ts +225 -0
- package/dist/tunnel/client.js +764 -0
- package/dist/tunnel/forwardTarget.d.ts +69 -0
- package/dist/tunnel/forwardTarget.js +179 -0
- package/dist/tunnel/frame.d.ts +126 -0
- package/dist/tunnel/frame.js +244 -0
- package/dist/tunnel/index.d.ts +9 -0
- package/dist/tunnel/index.js +9 -0
- package/package.json +2 -2
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The machine label `vincentt login` sends with the exchange: a name for this
|
|
2
|
+
// machine's sign-in that the console lists and the eviction line prints.
|
|
3
|
+
//
|
|
4
|
+
// It is built from the OS, the architecture and today's date, NEVER the hostname:
|
|
5
|
+
// a hostname often carries a person's name or an employer's asset tag, and the
|
|
6
|
+
// label is shown back to the person. `--label` overrides it.
|
|
7
|
+
//
|
|
8
|
+
// validateMachineLabel applies the api's exact rules, counting CODE POINTS after
|
|
9
|
+
// NFC (not UTF-16 units), so `--label` is refused here, before any browser or
|
|
10
|
+
// network, for exactly what the api would refuse. The shared corpus
|
|
11
|
+
// (__fixtures__/machine_label_corpus.json, copied from the api) pins agreement.
|
|
12
|
+
export const MACHINE_LABEL_MAX_CODE_POINTS = 64;
|
|
13
|
+
export const MACHINE_LABEL_MAX_MARK_RUN = 4;
|
|
14
|
+
export const LEGACY_MACHINE_LABEL = "Signed in before machine names";
|
|
15
|
+
export const DEFAULT_MACHINE_LABEL_PREFIX = "Unnamed machine,";
|
|
16
|
+
const MONTHS = [
|
|
17
|
+
"Jan",
|
|
18
|
+
"Feb",
|
|
19
|
+
"Mar",
|
|
20
|
+
"Apr",
|
|
21
|
+
"May",
|
|
22
|
+
"Jun",
|
|
23
|
+
"Jul",
|
|
24
|
+
"Aug",
|
|
25
|
+
"Sep",
|
|
26
|
+
"Oct",
|
|
27
|
+
"Nov",
|
|
28
|
+
"Dec",
|
|
29
|
+
];
|
|
30
|
+
export const MACHINE_LABEL_REFUSED_LINES = [
|
|
31
|
+
"✗ A machine label must be 1 to 64 letters, numbers, spaces, punctuation or symbols.",
|
|
32
|
+
' Run `vincentt login --label "<name>"` with a shorter name, or leave out --label.',
|
|
33
|
+
];
|
|
34
|
+
/** "d Mon" in the given date's own calendar fields (local time unless the caller says otherwise). */
|
|
35
|
+
export function dayMonth(d, utc = false) {
|
|
36
|
+
const day = utc ? d.getUTCDate() : d.getDate();
|
|
37
|
+
const month = utc ? d.getUTCMonth() : d.getMonth();
|
|
38
|
+
return `${day} ${MONTHS[month]}`;
|
|
39
|
+
}
|
|
40
|
+
function osName(platform) {
|
|
41
|
+
if (platform === "darwin")
|
|
42
|
+
return "macOS";
|
|
43
|
+
if (platform === "win32")
|
|
44
|
+
return "Windows";
|
|
45
|
+
if (platform === "linux")
|
|
46
|
+
return "Linux";
|
|
47
|
+
return platform;
|
|
48
|
+
}
|
|
49
|
+
/** The default label, "<OS> <arch>, <d Mon>", from injected platform, arch and clock. */
|
|
50
|
+
export function defaultMachineLabel(platform = process.platform, arch = process.arch, now = new Date()) {
|
|
51
|
+
return `${osName(platform)} ${arch}, ${dayMonth(now)}`;
|
|
52
|
+
}
|
|
53
|
+
const ALLOWED = /^[\p{L}\p{M}\p{N}\p{P}\p{S} ]$/u;
|
|
54
|
+
const MARK = /^\p{M}$/u;
|
|
55
|
+
function trimSpaces(s) {
|
|
56
|
+
return s.replace(/^ +| +$/g, "");
|
|
57
|
+
}
|
|
58
|
+
function fold(s) {
|
|
59
|
+
return s.normalize("NFKC").toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
const FOLDED_LEGACY = fold(LEGACY_MACHINE_LABEL);
|
|
62
|
+
const FOLDED_DEFAULT_PREFIX = fold(DEFAULT_MACHINE_LABEL_PREFIX);
|
|
63
|
+
/**
|
|
64
|
+
* The api's label rules, for a label the person typed: NFC, trim U+0020 only,
|
|
65
|
+
* then 1 to 64 code points, each a letter, mark, number, punctuation, symbol or
|
|
66
|
+
* plain space, no run of more than 4 marks, and not a platform-only string. An
|
|
67
|
+
* empty label is refused here (the api would store its default, but `--label ""`
|
|
68
|
+
* is a mistake, not a request for the default).
|
|
69
|
+
*/
|
|
70
|
+
export function validateMachineLabel(raw) {
|
|
71
|
+
const label = trimSpaces(raw.normalize("NFC"));
|
|
72
|
+
const points = [...label];
|
|
73
|
+
if (points.length < 1 || points.length > MACHINE_LABEL_MAX_CODE_POINTS)
|
|
74
|
+
return { ok: false };
|
|
75
|
+
let markRun = 0;
|
|
76
|
+
for (const cp of points) {
|
|
77
|
+
if (!ALLOWED.test(cp))
|
|
78
|
+
return { ok: false };
|
|
79
|
+
if (MARK.test(cp)) {
|
|
80
|
+
markRun++;
|
|
81
|
+
if (markRun > MACHINE_LABEL_MAX_MARK_RUN)
|
|
82
|
+
return { ok: false };
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
markRun = 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Case folding approximated by NFKC + toLowerCase; the shared corpus pins
|
|
89
|
+
// agreement with the api's NFKC + Unicode case fold.
|
|
90
|
+
const folded = trimSpaces(fold(label));
|
|
91
|
+
if (folded === FOLDED_LEGACY || folded.startsWith(FOLDED_DEFAULT_PREFIX))
|
|
92
|
+
return { ok: false };
|
|
93
|
+
return { ok: true, label };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Drop every code point outside the label allowlist before a server-sent label
|
|
97
|
+
* is printed. The api already refuses such labels; this keeps a later
|
|
98
|
+
* validation bug from writing escape sequences or bidi controls to a terminal.
|
|
99
|
+
*/
|
|
100
|
+
export function sanitizeServerLabel(label) {
|
|
101
|
+
if (typeof label !== "string")
|
|
102
|
+
return "";
|
|
103
|
+
return [...label].filter((cp) => ALLOWED.test(cp)).join("");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The label a `vincentt login` sends. Without --label it is the default; with it,
|
|
107
|
+
* the typed value must pass the api's rules, or the refusal lines are returned
|
|
108
|
+
* and the login must stop before any browser or network.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveLoginLabel(given, raw, defaults = {}) {
|
|
111
|
+
if (!given) {
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
label: defaultMachineLabel(defaults.platform, defaults.arch, defaults.now),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const check = validateMachineLabel(raw ?? "");
|
|
118
|
+
return check.ok ? check : { ok: false, lines: MACHINE_LABEL_REFUSED_LINES };
|
|
119
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AuthBackend, type Identity } from "./authBackend.js";
|
|
1
|
+
import { type AuthBackend, type EvictedMachine, type Identity } from "./authBackend.js";
|
|
2
2
|
export interface PkceLoginOptions {
|
|
3
3
|
/** Console origin that serves /cli-auth (browser target). */
|
|
4
4
|
consoleUrl: string;
|
|
@@ -6,6 +6,8 @@ export interface PkceLoginOptions {
|
|
|
6
6
|
apiUrl: string;
|
|
7
7
|
/** Abort if the browser round-trip doesn't complete in time. Default 5 min. */
|
|
8
8
|
timeoutMs?: number;
|
|
9
|
+
/** The machine label sent with the exchange (already validated). */
|
|
10
|
+
label?: string;
|
|
9
11
|
/** Backend calls (exchange + me). Default: real HTTP against apiUrl. */
|
|
10
12
|
backend?: AuthBackend;
|
|
11
13
|
/** Pick a free loopback port. Default: the OS ephemeral picker. */
|
|
@@ -20,6 +22,10 @@ export interface PkceLoginOptions {
|
|
|
20
22
|
export interface PkceLoginResult {
|
|
21
23
|
identity: Identity;
|
|
22
24
|
configPath: string;
|
|
25
|
+
/** The label the api stored, echoed back; absent from an older api. */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** Machines this login signed out at the 10-machine cap. */
|
|
28
|
+
evicted?: EvictedMachine[];
|
|
23
29
|
}
|
|
24
30
|
/** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
|
|
25
31
|
export declare class LoginTimeoutError extends Error {
|
|
@@ -29,11 +35,17 @@ export declare class LoginTimeoutError extends Error {
|
|
|
29
35
|
export declare class LoginCallbackError extends Error {
|
|
30
36
|
constructor(message: string);
|
|
31
37
|
}
|
|
32
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The platform command + args to open `url` in the default browser. On win32
|
|
40
|
+
* the URL goes to `rundll32` as one argument, so no command interpreter parses
|
|
41
|
+
* it and `&` cannot split the command.
|
|
42
|
+
*/
|
|
33
43
|
export declare function browserOpenCommand(url: string, platform?: string): {
|
|
34
44
|
cmd: string;
|
|
35
45
|
args: string[];
|
|
36
46
|
};
|
|
47
|
+
/** Only `https:`, or `http:` on a loopback host, is ever handed to the OS opener. */
|
|
48
|
+
export declare function isLaunchableUrl(url: string): boolean;
|
|
37
49
|
type SpawnLike = (cmd: string, args: string[], opts: {
|
|
38
50
|
stdio: "ignore";
|
|
39
51
|
detached: boolean;
|
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,7 +15,7 @@
|
|
|
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";
|
|
19
20
|
/** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
|
|
20
21
|
export class LoginTimeoutError extends Error {
|
|
@@ -30,14 +31,35 @@ export class LoginCallbackError extends Error {
|
|
|
30
31
|
this.name = "LoginCallbackError";
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The platform command + args to open `url` in the default browser. On win32
|
|
36
|
+
* the URL goes to `rundll32` as one argument, so no command interpreter parses
|
|
37
|
+
* it and `&` cannot split the command.
|
|
38
|
+
*/
|
|
34
39
|
export function browserOpenCommand(url, platform = process.platform) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
if (platform === "win32") {
|
|
41
|
+
return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
|
|
42
|
+
}
|
|
43
|
+
return { cmd: platform === "darwin" ? "open" : "xdg-open", args: [url] };
|
|
44
|
+
}
|
|
45
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"]);
|
|
46
|
+
/** Only `https:`, or `http:` on a loopback host, is ever handed to the OS opener. */
|
|
47
|
+
export function isLaunchableUrl(url) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = new URL(url);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (parsed.protocol === "https:")
|
|
56
|
+
return true;
|
|
57
|
+
return parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname);
|
|
38
58
|
}
|
|
39
59
|
/** Launch the browser via an injected spawn; a throw is swallowed (fallback = the printed URL). */
|
|
40
60
|
export function launchBrowser(url, spawn, platform) {
|
|
61
|
+
if (!isLaunchableUrl(url))
|
|
62
|
+
return;
|
|
41
63
|
const { cmd, args } = browserOpenCommand(url, platform);
|
|
42
64
|
try {
|
|
43
65
|
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
@@ -145,8 +167,9 @@ export async function runPkceLogin(opts) {
|
|
|
145
167
|
},
|
|
146
168
|
});
|
|
147
169
|
// (5) redeem the code for the raw PAT; (6) persist then confirm identity.
|
|
148
|
-
const
|
|
170
|
+
const exchanged = await backend.exchange(code, material.codeVerifier, opts.label);
|
|
171
|
+
const pat = exchanged.token;
|
|
149
172
|
const configPath = await saveToken(opts.apiUrl.replace(/\/$/, ""), pat);
|
|
150
173
|
const identity = await backend.me(pat);
|
|
151
|
-
return { identity, configPath };
|
|
174
|
+
return { identity, configPath, label: exchanged.label, evicted: exchanged.evicted };
|
|
152
175
|
}
|
package/dist/preview/api.d.ts
CHANGED
|
@@ -46,6 +46,21 @@ export declare class PreviewLimitError extends BackendError {
|
|
|
46
46
|
readonly detail: CountLimitDetail;
|
|
47
47
|
constructor(message: string, detail: CountLimitDetail);
|
|
48
48
|
}
|
|
49
|
+
/** The refusal body's detail on `preview_user_limit`. */
|
|
50
|
+
export interface PreviewUserLimitDetail {
|
|
51
|
+
limit?: string;
|
|
52
|
+
count?: number;
|
|
53
|
+
ceiling?: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The caller already runs as many previews as one person may, across every
|
|
57
|
+
* workspace (`409 preview_user_limit`). A THIRD 409 on the mint route, told
|
|
58
|
+
* apart by its code. Nothing was ended; the remedy is stopping a preview.
|
|
59
|
+
*/
|
|
60
|
+
export declare class PreviewUserLimitError extends BackendError {
|
|
61
|
+
readonly detail: PreviewUserLimitDetail;
|
|
62
|
+
constructor(message: string, detail: PreviewUserLimitDetail);
|
|
63
|
+
}
|
|
49
64
|
/**
|
|
50
65
|
* The session is gone — `preview_session_ended`, the CLI's terminal branch.
|
|
51
66
|
*
|
package/dist/preview/api.js
CHANGED
|
@@ -47,6 +47,19 @@ export class PreviewLimitError extends BackendError {
|
|
|
47
47
|
this.name = "PreviewLimitError";
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The caller already runs as many previews as one person may, across every
|
|
52
|
+
* workspace (`409 preview_user_limit`). A THIRD 409 on the mint route, told
|
|
53
|
+
* apart by its code. Nothing was ended; the remedy is stopping a preview.
|
|
54
|
+
*/
|
|
55
|
+
export class PreviewUserLimitError extends BackendError {
|
|
56
|
+
detail;
|
|
57
|
+
constructor(message, detail) {
|
|
58
|
+
super(message, 409);
|
|
59
|
+
this.detail = detail;
|
|
60
|
+
this.name = "PreviewUserLimitError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
50
63
|
/**
|
|
51
64
|
* The session is gone — `preview_session_ended`, the CLI's terminal branch.
|
|
52
65
|
*
|
|
@@ -130,7 +143,20 @@ export async function mintPreviewSession(cfg, projectId, key, fetchImpl = apiFet
|
|
|
130
143
|
if (body.code === "preview_limit") {
|
|
131
144
|
throw new PreviewLimitError(body.message ?? "this organization is at its preview limit", body.detail ?? {});
|
|
132
145
|
}
|
|
133
|
-
|
|
146
|
+
if (body.code === "preview_user_limit") {
|
|
147
|
+
const d = body.detail ?? {};
|
|
148
|
+
throw new PreviewUserLimitError(body.message ?? "you already have the most previews one person can run at once", {
|
|
149
|
+
limit: typeof d.limit === "string" ? d.limit : undefined,
|
|
150
|
+
count: typeof d.count === "number" ? d.count : undefined,
|
|
151
|
+
ceiling: typeof d.ceiling === "number" ? d.ceiling : undefined,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (body.code === "preview_session_conflict" || body.code === undefined) {
|
|
155
|
+
throw new PreviewConflictError(body.message ?? "a preview is already running for this folder");
|
|
156
|
+
}
|
|
157
|
+
// An unrecognised refusal prints the server's own words rather than the
|
|
158
|
+
// folder-conflict screen, so a future 409 is never misread as a conflict.
|
|
159
|
+
throw new BackendError(body.message ?? `could not start a preview (409)`, 409);
|
|
134
160
|
}
|
|
135
161
|
throw new BackendError(body.message ?? `could not start a preview (${res.status})`, res.status);
|
|
136
162
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DiagEvent } from "@vincentt-xr/harness/events";
|
|
2
|
-
import type { TunnelState, UAClass } from "
|
|
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
5
|
export interface PreviewUiDeps {
|
package/dist/preview/copy.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export declare function deviceArrived(descriptor: string): string;
|
|
|
51
51
|
* The window's length is deliberately not printed.
|
|
52
52
|
*/
|
|
53
53
|
export declare function noDiagnosticsRuntime(descriptor: string): string[];
|
|
54
|
-
/** Flow P1 — the proxy refused a hidden path. Once per preview, naming the first. */
|
|
54
|
+
/** Flow P1 — the proxy refused a hidden or credential path. Once per preview, naming the first. */
|
|
55
55
|
export declare function hiddenPathRefused(requestPath: string): string;
|
|
56
56
|
/** Flow L16 — `--viewer`, a second terminal, not a second preview. */
|
|
57
57
|
export declare function followingBlock(label: string, descriptor: string): string[];
|
|
@@ -267,3 +267,16 @@ export declare function mintRateLimited(retryAfterSec: number): string[];
|
|
|
267
267
|
export declare function approxWait(seconds: number): string;
|
|
268
268
|
/** The conflict a supersede lost (409). Rare: the mint supersedes first. */
|
|
269
269
|
export declare function previewConflict(): string[];
|
|
270
|
+
/**
|
|
271
|
+
* The per-person preview cap, refused at mint (`409 preview_user_limit`, B-F3-9).
|
|
272
|
+
*
|
|
273
|
+
* The count and the cap come only from the refusal body, never from a number
|
|
274
|
+
* compiled in: the cap is an operator setting. It names no workspace, plan or
|
|
275
|
+
* price, because no plan raises it. "Still running" comes first among the facts
|
|
276
|
+
* for the same reason as the org-ceiling screen: the command that refused is the
|
|
277
|
+
* one that starts previews, so a creator assumes it took something down.
|
|
278
|
+
*/
|
|
279
|
+
export declare function previewUserLimitRefused(d: {
|
|
280
|
+
count?: number;
|
|
281
|
+
ceiling?: number;
|
|
282
|
+
}): string[];
|
package/dist/preview/copy.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// sharpest — it transits an agent's tool-output transcript by default in this
|
|
18
18
|
// product, so f1's "a token is never printed" extends to it verbatim.
|
|
19
19
|
/** Flow L13 — the start block. */
|
|
20
|
-
import { escapeControl } from "../lib/terminalText.js";
|
|
20
|
+
import { escapeControl, terminalSafe } from "../lib/terminalText.js";
|
|
21
21
|
export function startBlock(i) {
|
|
22
22
|
const out = [
|
|
23
23
|
"",
|
|
@@ -128,9 +128,9 @@ export function noDiagnosticsRuntime(descriptor) {
|
|
|
128
128
|
" stopped before that line ran.",
|
|
129
129
|
];
|
|
130
130
|
}
|
|
131
|
-
/** Flow P1 — the proxy refused a hidden path. Once per preview, naming the first. */
|
|
131
|
+
/** Flow P1 — the proxy refused a hidden or credential path. Once per preview, naming the first. */
|
|
132
132
|
export function hiddenPathRefused(requestPath) {
|
|
133
|
-
return `! Answered 404 for ${requestPath}. A preview never serves hidden files from your project.`;
|
|
133
|
+
return `! Answered 404 for ${terminalSafe(requestPath)}. A preview never serves hidden or credential files from your project.`;
|
|
134
134
|
}
|
|
135
135
|
/** Flow L16 — `--viewer`, a second terminal, not a second preview. */
|
|
136
136
|
export function followingBlock(label, descriptor) {
|
|
@@ -448,7 +448,7 @@ 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",
|
|
@@ -572,3 +572,37 @@ export function previewConflict() {
|
|
|
572
572
|
"",
|
|
573
573
|
];
|
|
574
574
|
}
|
|
575
|
+
/**
|
|
576
|
+
* The per-person preview cap, refused at mint (`409 preview_user_limit`, B-F3-9).
|
|
577
|
+
*
|
|
578
|
+
* The count and the cap come only from the refusal body, never from a number
|
|
579
|
+
* compiled in: the cap is an operator setting. It names no workspace, plan or
|
|
580
|
+
* price, because no plan raises it. "Still running" comes first among the facts
|
|
581
|
+
* for the same reason as the org-ceiling screen: the command that refused is the
|
|
582
|
+
* one that starts previews, so a creator assumes it took something down.
|
|
583
|
+
*/
|
|
584
|
+
export function previewUserLimitRefused(d) {
|
|
585
|
+
const { count, ceiling } = d;
|
|
586
|
+
const known = typeof count === "number" && typeof ceiling === "number" && ceiling > 0;
|
|
587
|
+
let first = "✗ You already have the most previews one person can run at once.";
|
|
588
|
+
if (known && count > ceiling) {
|
|
589
|
+
first = `✗ You already have ${count} previews running. One person can run ${ceiling} at once.`;
|
|
590
|
+
}
|
|
591
|
+
else if (known && count === ceiling) {
|
|
592
|
+
first = `✗ You already have ${count} previews running, the most one person can run at once.`;
|
|
593
|
+
}
|
|
594
|
+
return [
|
|
595
|
+
"",
|
|
596
|
+
first,
|
|
597
|
+
"",
|
|
598
|
+
" Every preview that was running is still running. This one did not start.",
|
|
599
|
+
" A preview that is still connecting counts too. Viewers never count.",
|
|
600
|
+
"",
|
|
601
|
+
" To start this one, stop a preview you no longer need:",
|
|
602
|
+
" press Ctrl-C where it runs, or run vincentt preview --stop in its folder,",
|
|
603
|
+
" or press Stop on its project's Preview tab in the console.",
|
|
604
|
+
"",
|
|
605
|
+
" Nothing was started.",
|
|
606
|
+
"",
|
|
607
|
+
];
|
|
608
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ResolvedSettings } from "../lib/settings.js";
|
|
2
|
+
export interface DevPortChoice {
|
|
3
|
+
port: number;
|
|
4
|
+
/**
|
|
5
|
+
* The port was picked for this preview. Nothing else may answer there, so a
|
|
6
|
+
* listener found on it is never reused as the app.
|
|
7
|
+
*/
|
|
8
|
+
fresh: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The port the preview's dev server runs on. Only the template default is
|
|
12
|
+
* replaced with a free port (passed to the dev command as PORT), so two
|
|
13
|
+
* previews never share one. A port from `--port` or `vincentt.json`, or any
|
|
14
|
+
* port paired with a custom dev command, is kept: many outside dev servers
|
|
15
|
+
* ignore PORT and listen where they were told.
|
|
16
|
+
*/
|
|
17
|
+
export declare function chooseDevPort(settings: Pick<ResolvedSettings, "devCommand" | "devPort">, pickPort?: () => Promise<number>): Promise<DevPortChoice>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getFreePort } from "./net.js";
|
|
2
|
+
/**
|
|
3
|
+
* The port the preview's dev server runs on. Only the template default is
|
|
4
|
+
* replaced with a free port (passed to the dev command as PORT), so two
|
|
5
|
+
* previews never share one. A port from `--port` or `vincentt.json`, or any
|
|
6
|
+
* port paired with a custom dev command, is kept: many outside dev servers
|
|
7
|
+
* ignore PORT and listen where they were told.
|
|
8
|
+
*/
|
|
9
|
+
export async function chooseDevPort(settings, pickPort = getFreePort) {
|
|
10
|
+
if (settings.devPort.source === "default" && settings.devCommand.source === "default") {
|
|
11
|
+
return { port: await pickPort(), fresh: true };
|
|
12
|
+
}
|
|
13
|
+
return { port: settings.devPort.value, fresh: false };
|
|
14
|
+
}
|
package/dist/preview/proxy.d.ts
CHANGED
|
@@ -36,17 +36,24 @@ export declare function proxyWs(req: IncomingMessage, socket: Duplex, head: Buff
|
|
|
36
36
|
export declare function relayRoute(url: string, harnessPath: string, kind: "http" | "upgrade"): "relay" | "refuse" | "app";
|
|
37
37
|
/**
|
|
38
38
|
* Whether the front proxy must answer 404 itself for this app-route URL, so a
|
|
39
|
-
* file-server dev tool serving the repo root cannot hand `/.env`, `/.git/config
|
|
40
|
-
*
|
|
39
|
+
* file-server dev tool serving the repo root cannot hand `/.env`, `/.git/config`,
|
|
40
|
+
* the binding or a credential file to any holder of a preview link.
|
|
41
41
|
*
|
|
42
42
|
* The path is decoded once, and an encoded `/` or `\` is refused outright (a
|
|
43
|
-
* separator the dev server would split on and this check would not).
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* separator the dev server would split on and this check would not). The query
|
|
44
|
+
* and fragment may not carry a traversal or a separator either, because some
|
|
45
|
+
* dev servers resolve a file from a query parameter. A segment still carrying
|
|
46
|
+
* an encoded character after that one decode is judged as the dev server would
|
|
47
|
+
* read it after a second decode, so `%252eenv` cannot slip past; a second decode
|
|
48
|
+
* that throws is done leniently, never treated as "not hidden". `.` and `..`
|
|
49
|
+
* segments are always refused, which is what makes the `/@fs/` text containment
|
|
50
|
+
* below real containment.
|
|
48
51
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
52
|
+
* Three rules read the checked segments: the dot rule (raw name), and the
|
|
53
|
+
* short-name and credential-name rules (normalized name, see `normalizedName`).
|
|
54
|
+
*
|
|
55
|
+
* `projectRoot` is the realpath of the binding directory with forward slashes,
|
|
56
|
+
* taken once at preview start; inside-ness is a segment-aligned text prefix,
|
|
57
|
+
* with no disk call.
|
|
51
58
|
*/
|
|
52
59
|
export declare function isHiddenAppPath(url: string, projectRoot: string): boolean;
|
package/dist/preview/proxy.js
CHANGED
|
@@ -138,23 +138,34 @@ export function relayRoute(url, harnessPath, kind) {
|
|
|
138
138
|
}
|
|
139
139
|
/**
|
|
140
140
|
* Whether the front proxy must answer 404 itself for this app-route URL, so a
|
|
141
|
-
* file-server dev tool serving the repo root cannot hand `/.env`, `/.git/config
|
|
142
|
-
*
|
|
141
|
+
* file-server dev tool serving the repo root cannot hand `/.env`, `/.git/config`,
|
|
142
|
+
* the binding or a credential file to any holder of a preview link.
|
|
143
143
|
*
|
|
144
144
|
* The path is decoded once, and an encoded `/` or `\` is refused outright (a
|
|
145
|
-
* separator the dev server would split on and this check would not).
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
145
|
+
* separator the dev server would split on and this check would not). The query
|
|
146
|
+
* and fragment may not carry a traversal or a separator either, because some
|
|
147
|
+
* dev servers resolve a file from a query parameter. A segment still carrying
|
|
148
|
+
* an encoded character after that one decode is judged as the dev server would
|
|
149
|
+
* read it after a second decode, so `%252eenv` cannot slip past; a second decode
|
|
150
|
+
* that throws is done leniently, never treated as "not hidden". `.` and `..`
|
|
151
|
+
* segments are always refused, which is what makes the `/@fs/` text containment
|
|
152
|
+
* below real containment.
|
|
150
153
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
154
|
+
* Three rules read the checked segments: the dot rule (raw name), and the
|
|
155
|
+
* short-name and credential-name rules (normalized name, see `normalizedName`).
|
|
156
|
+
*
|
|
157
|
+
* `projectRoot` is the realpath of the binding directory with forward slashes,
|
|
158
|
+
* taken once at preview start; inside-ness is a segment-aligned text prefix,
|
|
159
|
+
* with no disk call.
|
|
153
160
|
*/
|
|
154
161
|
export function isHiddenAppPath(url, projectRoot) {
|
|
155
|
-
const
|
|
162
|
+
const [beforeHash = "", ...hash] = url.split("#");
|
|
163
|
+
const [raw = "", ...query] = beforeHash.split("?");
|
|
156
164
|
if (/%(2f|5c)/i.test(raw) || raw.includes("\\"))
|
|
157
165
|
return true;
|
|
166
|
+
const tail = [...query, ...hash].join("?");
|
|
167
|
+
if (/%(2f|5c)/i.test(tail) || tail.includes("\\") || tail.includes("/.."))
|
|
168
|
+
return true;
|
|
158
169
|
let decoded;
|
|
159
170
|
try {
|
|
160
171
|
decoded = decodeURIComponent(raw);
|
|
@@ -168,22 +179,93 @@ export function isHiddenAppPath(url, projectRoot) {
|
|
|
168
179
|
const checked = checkedSegmentIndexes(segments, projectRoot);
|
|
169
180
|
for (const i of checked) {
|
|
170
181
|
const seg = segments[i];
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
182
|
+
const spellings = [seg, secondDecode(seg)];
|
|
183
|
+
if (spellings.some((s) => s.startsWith(".")) && !isAllowedDotSegment(segments, i)) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
for (const s of spellings) {
|
|
187
|
+
const name = normalizedName(s);
|
|
188
|
+
if (name === REFUSE)
|
|
189
|
+
return true;
|
|
190
|
+
if (SHORT_NAME.test(name) || isCredentialName(name))
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
176
193
|
}
|
|
177
194
|
return false;
|
|
178
195
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
196
|
+
/** The segment as a second decode would read it; lenient when that decode throws. */
|
|
197
|
+
function secondDecode(segment) {
|
|
198
|
+
if (!/%[0-9a-f]{2}/i.test(segment))
|
|
199
|
+
return segment;
|
|
182
200
|
try {
|
|
183
|
-
return decodeURIComponent(segment)
|
|
201
|
+
return decodeURIComponent(segment);
|
|
184
202
|
}
|
|
185
203
|
catch {
|
|
186
|
-
return
|
|
204
|
+
return segment.replace(/(?:%[0-9a-f]{2})+/gi, (run) => {
|
|
205
|
+
try {
|
|
206
|
+
return decodeURIComponent(run);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return run.replace(/%([0-9a-f]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const REFUSE = Symbol("refuse");
|
|
215
|
+
/**
|
|
216
|
+
* The name a Windows or case-insensitive file system would open for this
|
|
217
|
+
* segment: a stream suffix (`:$`) is refused outright, the rest is cut at the
|
|
218
|
+
* first colon (so `/@fs/C:/...` and `virtual:` modules still work), trailing
|
|
219
|
+
* dots and spaces are dropped (Windows resolves `server.pem. .` to `server.pem`),
|
|
220
|
+
* and compatibility and case forms are folded.
|
|
221
|
+
*/
|
|
222
|
+
function normalizedName(segment) {
|
|
223
|
+
if (segment.includes(":$"))
|
|
224
|
+
return REFUSE;
|
|
225
|
+
const beforeColon = segment.split(":")[0] ?? "";
|
|
226
|
+
return beforeColon.replace(/[. ]+$/, "").normalize("NFKC").toUpperCase().toLowerCase();
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* A Windows 8.3 short name (`GIT~1`, `CREDEN~1.JSO`) opens the long-named file
|
|
230
|
+
* it stands for, and its short form carries no leading dot, so `/GIT~1/config`
|
|
231
|
+
* would read `.git/config` past the dot rule. Refused on every platform: the
|
|
232
|
+
* proxy can front a Windows file system from any client.
|
|
233
|
+
*/
|
|
234
|
+
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;
|
|
187
269
|
}
|
|
188
270
|
}
|
|
189
271
|
function checkedSegmentIndexes(segments, projectRoot) {
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { type ControlKind, type TunnelState, type UAClass } from "
|
|
1
|
+
import { type ControlKind, type TunnelState, type UAClass } from "../tunnel/index.js";
|
|
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
5
|
/** The path a 404 advisory names: the request's own path, without its query. */
|
|
6
6
|
export declare function requestPathOf(url: string | undefined): string;
|
|
7
|
+
/**
|
|
8
|
+
* The project root as the `/@fs/` check compares it: Vite writes `/@fs/C:/...`
|
|
9
|
+
* with forward slashes, so a Windows root holding backslashes would never match
|
|
10
|
+
* as inside, and a project under a dotted parent folder would get false 404s.
|
|
11
|
+
*/
|
|
12
|
+
export declare function hiddenPathRoot(realRoot: string): string;
|
|
7
13
|
/** Why a preview ended. Each maps to exactly one screen (Flow L20/L21). */
|
|
8
14
|
export type PreviewEndReason = "stopped"
|
|
9
15
|
/**
|
|
@@ -57,8 +63,10 @@ export interface StartPreviewOptions {
|
|
|
57
63
|
/** The API host + PAT. Resolved by the caller so the runner makes no policy. */
|
|
58
64
|
config: ResolvedConfig;
|
|
59
65
|
projectId: string;
|
|
60
|
-
/** App dev-serve port (default 5173). A server already here is REUSED
|
|
66
|
+
/** App dev-serve port (default 5173). A server already here is REUSED unless `appPortFresh`. */
|
|
61
67
|
appPort?: number;
|
|
68
|
+
/** appPort was picked for this preview, so the dev command always starts there. */
|
|
69
|
+
appPortFresh?: boolean;
|
|
62
70
|
relayPort?: number;
|
|
63
71
|
frontPort?: number;
|
|
64
72
|
/** Path routed to the relay instead of the app (default /__harness). */
|