@vincentt-xr/cli 0.18.2 → 0.19.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 +22 -0
- package/dist/cli/index.js +18 -6
- package/dist/cli/usage-text.js +1 -1
- package/dist/lib/diagnostics.d.ts +2 -1
- package/dist/lib/diagnostics.js +6 -2
- package/dist/lib/feedback.js +5 -4
- package/dist/lib/lifecycle.js +3 -2
- package/dist/lib/relayInfo.d.ts +8 -2
- package/dist/lib/relayInfo.js +15 -5
- package/dist/lib/terminalText.d.ts +2 -0
- package/dist/lib/terminalText.js +7 -0
- package/dist/login/pkceLogin.d.ts +7 -1
- package/dist/login/pkceLogin.js +25 -4
- 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 +37 -3
- 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 +12 -2
- package/dist/preview/runner.js +28 -8
- package/dist/preview/stream.d.ts +1 -1
- package/dist/preview/viewers.d.ts +1 -1
- package/dist/relay/guard.d.ts +23 -0
- package/dist/relay/guard.js +73 -0
- package/dist/relay/server.d.ts +13 -0
- package/dist/relay/server.js +53 -5
- 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
package/MIGRATION.md
CHANGED
|
@@ -3,6 +3,28 @@
|
|
|
3
3
|
Per version, newest first. Read by the platform's own version surface and served to
|
|
4
4
|
creators' agents. Accurate or absent, never speculative (B-F5-2).
|
|
5
5
|
|
|
6
|
+
## 0.19.0
|
|
7
|
+
|
|
8
|
+
required: false
|
|
9
|
+
No action required for a project made from the Vincentt template. Behaviour that changed:
|
|
10
|
+
- The platform caps how many previews one person runs at once (5 by default, across every workspace; a preview that is still connecting counts, viewers never do). Re-running `vincentt preview` in a folder that already has a preview is never refused by the cap.
|
|
11
|
+
- `vincentt preview` past the preview cap exits 1, starts nothing and stops nothing, and says how many previews are running and how to stop one: Ctrl-C where it runs, `vincentt preview --stop` in its folder, or Stop on its project's Preview tab in the console.
|
|
12
|
+
- A preview refusal the CLI does not recognise prints the platform's own message instead of "A preview is already running for this folder."
|
|
13
|
+
- `vincentt preview` with no `vincentt.json`, `--port` or `--dev-command` starts the project's `dev` script on a free port, passed to it as `PORT`, instead of 5173, and never reuses a server it finds already listening. Two previews at once, or another server already on 5173, no longer share one app. The settings banner shows the chosen port. A port from `--port` or `vincentt.json` is kept, and a server already listening there is still reused.
|
|
14
|
+
- A preview answers 404 for any path segment shaped like a Windows short name (one to six characters, then `~` and a digit, such as `GIT~1` or `ENVLOC~1`), on every platform. On Windows these names open the dotted files the preview already hides. Bundler chunk names such as `vendors~main` are unaffected.
|
|
15
|
+
- The preview tunnel client is part of the CLI itself instead of being loaded from `@vincentt-xr/harness`. The preview behaves the same, and it works with any harness version a project has installed.
|
|
16
|
+
- A preview answers 404 for files and folders named like credentials, with no opt-out: `credentials.json`, `service-account*.json`, `*firebase-adminsdk*.json`, `*.pem`, `*.key`, `*.p12`, `*.pfx`, `*.p8`, `*.ppk`, `id_rsa*`, `id_dsa*`, `id_ed25519*`, `id_ecdsa*`, `*.jks`, `*.keystore`, `*.env`, `*.tfstate`, `*.tfvars`, and their `~` or `.bak` backups, in any letter case. An app asset with one of these names (for example a `.key` file) needs a new name to load in a preview.
|
|
17
|
+
- A preview also refuses a Windows stream name, a name with trailing dots or spaces, and a query or fragment carrying a parent-folder step, a backslash or an encoded slash. The 404 advisory names the path decoded, without control or bidirectional-override characters, and now ends `A preview never serves hidden or credential files from your project.`
|
|
18
|
+
- `vincentt login` and the report consent prompt open a browser only for an `https:` address, or an `http:` address on `localhost`, `127.0.0.1` or `[::1]`; anything else is printed but not opened. On Windows the browser opens through `rundll32` instead of `cmd`.
|
|
19
|
+
|
|
20
|
+
## 0.18.3
|
|
21
|
+
|
|
22
|
+
required: false
|
|
23
|
+
No action required when the preview and the commands reading it (`logs`, `feedback`, `preview --stop`) come from the same CLI version. Behaviour that changed:
|
|
24
|
+
- The local preview relay refuses web pages other than the preview itself. It answers only requests whose Host is `127.0.0.1:<port>` or `localhost:<port>`, accepts the diagnostics socket and feedback posts only from the preview's own address (or from a client that sends no Origin), and refuses a POST whose content type is not JSON with 415.
|
|
25
|
+
- The relay's diagnostics read and feedback long-poll now require a per-session token, which the preview writes to the project's relay record (now readable by the owner only). The CLI's own commands send it; a direct diagnostics or feedback request without the `x-vincentt-relay-token` header is refused with 401. A command from CLI 0.18.2 or older cannot read a preview started by this version, and the reverse still works.
|
|
26
|
+
- `vincentt relay` prints the token it requires and accepts `--origin <url>` for a page it should accept; without it, only pages served from localhost or 127.0.0.1 can connect.
|
|
27
|
+
|
|
6
28
|
## 0.18.2
|
|
7
29
|
|
|
8
30
|
required: false
|
package/dist/cli/index.js
CHANGED
|
@@ -37,13 +37,14 @@ import { USAGE_TEXT } from "./usage-text.js";
|
|
|
37
37
|
import { PreviewUi } from "../preview/command.js";
|
|
38
38
|
import { httpRelayClient } from "../lib/diagnostics.js";
|
|
39
39
|
import { readLivePreview, relayInfoDir } from "../lib/relayInfo.js";
|
|
40
|
-
import { attachedToRunning, mintRateLimited, noDiagnosticsStatic, notSignedIn, previewConflict, settingsBanner, startBlock, } from "../preview/copy.js";
|
|
40
|
+
import { attachedToRunning, mintRateLimited, noDiagnosticsStatic, notSignedIn, previewConflict, previewUserLimitRefused, settingsBanner, startBlock, } from "../preview/copy.js";
|
|
41
|
+
import { chooseDevPort } from "../preview/devPort.js";
|
|
41
42
|
import { isPortListening } from "../preview/net.js";
|
|
42
43
|
import { readSettings, resolveSettings, SettingsError, TEMPLATE_DEFAULTS, validateField, } from "../lib/settings.js";
|
|
43
44
|
import { settingsRefusal, sourceWords } from "../lib/settingsCopy.js";
|
|
44
45
|
import { listsHarness } from "../lib/template.js";
|
|
45
46
|
import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
|
|
46
|
-
import { PreviewConflictError, PreviewLimitError, PreviewRateLimitedError, } from "../preview/api.js";
|
|
47
|
+
import { PreviewConflictError, PreviewLimitError, PreviewRateLimitedError, PreviewUserLimitError, } from "../preview/api.js";
|
|
47
48
|
import { previewLimitRefused, resolveCountLimit, } from "../shared/countLimit.js";
|
|
48
49
|
import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
|
|
49
50
|
import * as feedbackCopy from "../lib/feedbackCopy.js";
|
|
@@ -531,14 +532,16 @@ async function preview() {
|
|
|
531
532
|
return;
|
|
532
533
|
}
|
|
533
534
|
const devCommand = settings.devCommand.value;
|
|
534
|
-
const devPort = settings
|
|
535
|
+
const { port: devPort, fresh: devPortFresh } = await chooseDevPort(settings);
|
|
536
|
+
// What the creator gets running the dev command by hand, without PORT.
|
|
537
|
+
const handRunUrl = `http://localhost:${settings.devPort.value}`;
|
|
535
538
|
const localUrl = `http://localhost:${devPort}`;
|
|
536
539
|
for (const l of settingsBanner({
|
|
537
540
|
devCommand,
|
|
538
541
|
devCommandSource: sourceWords(settings.devCommand),
|
|
539
542
|
devPort,
|
|
540
543
|
devPortSource: sourceWords(settings.devPort),
|
|
541
|
-
reused: await isPortListening(devPort),
|
|
544
|
+
reused: devPortFresh ? false : await isPortListening(devPort),
|
|
542
545
|
})) {
|
|
543
546
|
console.log(l);
|
|
544
547
|
}
|
|
@@ -549,7 +552,7 @@ async function preview() {
|
|
|
549
552
|
// The gate is LOCAL and NOTHING IS ATTEMPTED: no spinner, no partial mint, no
|
|
550
553
|
// session left in `pending`. The command stops before it touches the network.
|
|
551
554
|
if (!(await currentToken())) {
|
|
552
|
-
for (const l of notSignedIn(devCommand,
|
|
555
|
+
for (const l of notSignedIn(devCommand, handRunUrl))
|
|
553
556
|
console.log(l);
|
|
554
557
|
process.exitCode = EXIT.NOT_AUTHENTICATED;
|
|
555
558
|
return;
|
|
@@ -607,6 +610,7 @@ async function preview() {
|
|
|
607
610
|
try {
|
|
608
611
|
const started = await previewStart(cwd, (m) => console.error(`[preview] ${m}`), {
|
|
609
612
|
appPort: devPort,
|
|
613
|
+
appPortFresh: devPortFresh,
|
|
610
614
|
devCommand,
|
|
611
615
|
onHiddenPath: (p) => ui.hiddenPath(p),
|
|
612
616
|
appStdio: has("--verbose") ? "inherit" : "ignore",
|
|
@@ -727,7 +731,7 @@ async function followPreview(info, ui, filterTester, filterViewer) {
|
|
|
727
731
|
console.log("✓ Following this preview");
|
|
728
732
|
}
|
|
729
733
|
console.log("");
|
|
730
|
-
const relay = httpRelayClient(`http://127.0.0.1:${info.relayPort}
|
|
734
|
+
const relay = httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
|
|
731
735
|
let since = -1;
|
|
732
736
|
let warnedMissing = false;
|
|
733
737
|
let stop = false;
|
|
@@ -842,6 +846,8 @@ function previewFailureLines(err, local) {
|
|
|
842
846
|
if (err instanceof PreviewLimitError) {
|
|
843
847
|
return previewLimitRefused(resolveCountLimit(err.detail, local));
|
|
844
848
|
}
|
|
849
|
+
if (err instanceof PreviewUserLimitError)
|
|
850
|
+
return previewUserLimitRefused(err.detail);
|
|
845
851
|
if (err instanceof PreviewConflictError)
|
|
846
852
|
return previewConflict();
|
|
847
853
|
if (err instanceof ProjectUnreachableError) {
|
|
@@ -1357,13 +1363,19 @@ async function domain() {
|
|
|
1357
1363
|
// ---- servers ----------------------------------------------------------------
|
|
1358
1364
|
async function relay() {
|
|
1359
1365
|
const { startRelay } = await import("../relay/server.js");
|
|
1366
|
+
const { mintRelayToken, RELAY_TOKEN_HEADER } = await import("../relay/guard.js");
|
|
1360
1367
|
const port = numFlag("--port") ?? 7331;
|
|
1361
1368
|
const dir = flag("--dir");
|
|
1369
|
+
const origin = flag("--origin");
|
|
1370
|
+
const token = mintRelayToken();
|
|
1362
1371
|
startRelay({
|
|
1363
1372
|
port,
|
|
1364
1373
|
projectDir: dir ? path.resolve(dir) : undefined,
|
|
1374
|
+
allowedOrigins: origin ? [origin] : undefined,
|
|
1375
|
+
token,
|
|
1365
1376
|
onLog: (m) => console.log(`[relay] ${m}`),
|
|
1366
1377
|
});
|
|
1378
|
+
console.log(`[relay] /query and feedback/next require ${RELAY_TOKEN_HEADER}: ${token}`);
|
|
1367
1379
|
}
|
|
1368
1380
|
// ---- dispatch ---------------------------------------------------------------
|
|
1369
1381
|
export function usage() {
|
package/dist/cli/usage-text.js
CHANGED
|
@@ -58,7 +58,7 @@ export const USAGE_TEXT = `Vincentt CLI: the local-first AR dev loop\n\n` +
|
|
|
58
58
|
` [--port <n>] [--dev-command <cmd>] [--tester <id>]\n` +
|
|
59
59
|
` [--no-qr] [--stop]\n` +
|
|
60
60
|
` Runs dev.command and forwards dev.port from vincentt.json,\n` +
|
|
61
|
-
` else npm run dev on
|
|
61
|
+
` else npm run dev on a free port (passed as PORT).\n` +
|
|
62
62
|
` Re-running prints the live link; --restart mints a new one.\n\n` +
|
|
63
63
|
// f16. HELP TEACHES THE CANONICAL VERBS ONLY: `add` and `remove` are accepted
|
|
64
64
|
// as silent aliases and are deliberately ABSENT here, because help, docs and
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { DiagEvent, LogEvent, NetworkEvent, RelayQuery, RelayResult, TraceEvent } from "@vincentt-xr/harness/events";
|
|
2
|
+
export declare function relayAuthHeaders(token: string | undefined): Record<string, string>;
|
|
2
3
|
export interface RelayClient {
|
|
3
4
|
query(q: RelayQuery): Promise<RelayResult>;
|
|
4
5
|
}
|
|
5
6
|
/** A RelayClient backed by the relay's HTTP /query endpoint. */
|
|
6
|
-
export declare function httpRelayClient(baseUrl: string): RelayClient;
|
|
7
|
+
export declare function httpRelayClient(baseUrl: string, token?: string): RelayClient;
|
|
7
8
|
export declare function formatLog(e: LogEvent, now: number): string;
|
|
8
9
|
export declare function formatNetwork(e: NetworkEvent, now: number): string;
|
|
9
10
|
export declare function formatTrace(e: TraceEvent, now: number): string;
|
package/dist/lib/diagnostics.js
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
// the relay and format them into the compact text an agent reads. Kept separate
|
|
3
3
|
// from the SDK wiring (server.ts) so the formatting is unit-testable and the SDK
|
|
4
4
|
// version can churn without touching this.
|
|
5
|
+
import { RELAY_TOKEN_HEADER } from "../relay/guard.js";
|
|
6
|
+
export function relayAuthHeaders(token) {
|
|
7
|
+
return token === undefined ? {} : { [RELAY_TOKEN_HEADER]: token };
|
|
8
|
+
}
|
|
5
9
|
/** A RelayClient backed by the relay's HTTP /query endpoint. */
|
|
6
|
-
export function httpRelayClient(baseUrl) {
|
|
10
|
+
export function httpRelayClient(baseUrl, token) {
|
|
7
11
|
return {
|
|
8
12
|
async query(q) {
|
|
9
13
|
const res = await fetch(`${baseUrl}/query`, {
|
|
10
14
|
method: "POST",
|
|
11
|
-
headers: { "content-type": "application/json" },
|
|
15
|
+
headers: { "content-type": "application/json", ...relayAuthHeaders(token) },
|
|
12
16
|
body: JSON.stringify(q),
|
|
13
17
|
});
|
|
14
18
|
if (!res.ok)
|
package/dist/lib/feedback.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { promises as fs } from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { isProcessAlive, isRelayResponding, readRelayInfo, relayInfoDir, } from "./relayInfo.js";
|
|
14
|
+
import { relayAuthHeaders } from "./diagnostics.js";
|
|
14
15
|
const CURSOR_FILE = ".vincentt/annotations/offset";
|
|
15
16
|
/**
|
|
16
17
|
* A cursor that reads too LOW re-delivers, which is the accepted cost of
|
|
@@ -96,13 +97,13 @@ export async function fetchNext(cwd, opts) {
|
|
|
96
97
|
u.searchParams.set("timeout", String(opts.timeoutMs));
|
|
97
98
|
let res;
|
|
98
99
|
try {
|
|
99
|
-
res = await fetch(u);
|
|
100
|
+
res = await fetch(u, { headers: relayAuthHeaders(info.token) });
|
|
100
101
|
}
|
|
101
102
|
catch {
|
|
102
103
|
// The socket died under us. The pid gate + relay-identity probe is what
|
|
103
104
|
// separates "the preview ended" from "the request failed but a relay is
|
|
104
105
|
// still there" — only the latter is a fault worth an exit 1.
|
|
105
|
-
if (await relayGone(info.pid, info.relayPort)) {
|
|
106
|
+
if (await relayGone(info.pid, info.relayPort, info.token)) {
|
|
106
107
|
return { kind: "closed", reason: "unreachable" };
|
|
107
108
|
}
|
|
108
109
|
throw new Error(`Could not reach the preview relay on :${info.relayPort}. The relay is answering, so this is not a session end.`);
|
|
@@ -117,8 +118,8 @@ export async function fetchNext(cwd, opts) {
|
|
|
117
118
|
throw new Error(`relay feedback/next returned ${res.status}`);
|
|
118
119
|
return { kind: "annotation", annotation: (await res.json()) };
|
|
119
120
|
}
|
|
120
|
-
async function relayGone(pid, port) {
|
|
121
|
+
async function relayGone(pid, port, token) {
|
|
121
122
|
if (pid !== undefined && !isProcessAlive(pid))
|
|
122
123
|
return true;
|
|
123
|
-
return !(await isRelayResponding(port));
|
|
124
|
+
return !(await isRelayResponding(port, undefined, undefined, token));
|
|
124
125
|
}
|
package/dist/lib/lifecycle.js
CHANGED
|
@@ -175,6 +175,7 @@ export async function previewStart(projectCwd, onLog, extra = {}) {
|
|
|
175
175
|
harnessPath: "/__harness",
|
|
176
176
|
expiresAt: preview.expiresAt,
|
|
177
177
|
pidStartTime: await readProcessStartTime(process.pid),
|
|
178
|
+
token: preview.relayToken,
|
|
178
179
|
});
|
|
179
180
|
return { preview, started: true };
|
|
180
181
|
}
|
|
@@ -286,7 +287,7 @@ export async function previewStopSignal(projectCwd, deps = {}) {
|
|
|
286
287
|
// relay's own teardown and print a number that was true of a preview which
|
|
287
288
|
// no longer exists. A dead pid is never queried at all — the marker is stale
|
|
288
289
|
// and there is nothing on that port to ask.
|
|
289
|
-
const relay = deps.relay ?? httpRelayClient(`http://127.0.0.1:${info.relayPort}
|
|
290
|
+
const relay = deps.relay ?? httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
|
|
290
291
|
const connected = await readLiveConnections(relay);
|
|
291
292
|
try {
|
|
292
293
|
(deps.kill ?? ((pid, sig) => process.kill(pid, sig)))(info.pid, "SIGTERM");
|
|
@@ -333,7 +334,7 @@ async function relayForProject(projectCwd, override) {
|
|
|
333
334
|
throw new Error("No running preview for this project. Run `vincentt preview` first.");
|
|
334
335
|
}
|
|
335
336
|
// The relay listens on 127.0.0.1 only; `localhost` may resolve to ::1 first.
|
|
336
|
-
return httpRelayClient(`http://127.0.0.1:${info.relayPort}
|
|
337
|
+
return httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
|
|
337
338
|
}
|
|
338
339
|
/** Raw diagnostics query against the running relay (used by CLI --json). */
|
|
339
340
|
export async function diagQuery(projectCwd, kind, opts = {}, relay) {
|
package/dist/lib/relayInfo.d.ts
CHANGED
|
@@ -24,6 +24,12 @@ export interface RelayInfo {
|
|
|
24
24
|
* written by an older CLI or on a platform with no reader.
|
|
25
25
|
*/
|
|
26
26
|
pidStartTime?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The relay's per-session secret for `/query` and `feedback/next`. The marker
|
|
29
|
+
* is written 0600 because of it. Absent from markers written by an older CLI,
|
|
30
|
+
* whose relay asks for none.
|
|
31
|
+
*/
|
|
32
|
+
token?: string;
|
|
27
33
|
}
|
|
28
34
|
export declare function relayInfoPath(cwd: string): string;
|
|
29
35
|
/**
|
|
@@ -64,7 +70,7 @@ export declare function isProcessAlive(pid: number): boolean;
|
|
|
64
70
|
* runs fifty times a day, and the fallback (mint a preview) is what they asked
|
|
65
71
|
* for anyway.
|
|
66
72
|
*/
|
|
67
|
-
export declare function isRelayResponding(port: number, timeoutMs?: number, fetchImpl?: typeof fetch): Promise<boolean>;
|
|
73
|
+
export declare function isRelayResponding(port: number, timeoutMs?: number, fetchImpl?: typeof fetch, token?: string): Promise<boolean>;
|
|
68
74
|
/**
|
|
69
75
|
* readLivePreview returns the marker ONLY when it still describes a preview that
|
|
70
76
|
* is genuinely running, and CLEARS it otherwise.
|
|
@@ -81,5 +87,5 @@ export declare function isRelayResponding(port: number, timeoutMs?: number, fetc
|
|
|
81
87
|
*/
|
|
82
88
|
export declare function readLivePreview(cwd: string, deps?: {
|
|
83
89
|
processAlive?: (pid: number) => boolean;
|
|
84
|
-
relayResponding?: (port: number) => Promise<boolean>;
|
|
90
|
+
relayResponding?: (port: number, token?: string) => Promise<boolean>;
|
|
85
91
|
}): Promise<RelayInfo | undefined>;
|
package/dist/lib/relayInfo.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { promises as fs } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { locateProjectBinding } from "../shared/config.js";
|
|
8
|
+
import { relayAuthHeaders } from "./diagnostics.js";
|
|
8
9
|
const RELAY_INFO_FILE = "relay.json";
|
|
9
10
|
const VINCENTT_DIR = ".vincentt";
|
|
10
11
|
export function relayInfoPath(cwd) {
|
|
@@ -28,7 +29,15 @@ export async function relayInfoDir(cwd) {
|
|
|
28
29
|
export async function writeRelayInfo(cwd, info) {
|
|
29
30
|
const p = relayInfoPath(cwd);
|
|
30
31
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
31
|
-
|
|
32
|
+
// Removed first so an older 0644 marker is not rewritten in place: `mode`
|
|
33
|
+
// only applies when the file is created, and the token must never be
|
|
34
|
+
// readable by another user.
|
|
35
|
+
await fs.rm(p, { force: true });
|
|
36
|
+
await fs.writeFile(p, JSON.stringify(info, null, 2) + "\n", {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
mode: 0o600,
|
|
39
|
+
flag: "wx",
|
|
40
|
+
});
|
|
32
41
|
return p;
|
|
33
42
|
}
|
|
34
43
|
/** Reads the marker for the TREE containing cwd, not only the folder itself. */
|
|
@@ -83,7 +92,7 @@ export function isProcessAlive(pid) {
|
|
|
83
92
|
* runs fifty times a day, and the fallback (mint a preview) is what they asked
|
|
84
93
|
* for anyway.
|
|
85
94
|
*/
|
|
86
|
-
export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch) {
|
|
95
|
+
export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch, token) {
|
|
87
96
|
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
88
97
|
return false;
|
|
89
98
|
const ac = new AbortController();
|
|
@@ -91,7 +100,7 @@ export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch
|
|
|
91
100
|
try {
|
|
92
101
|
const res = await fetchImpl(`http://127.0.0.1:${port}/query`, {
|
|
93
102
|
method: "POST",
|
|
94
|
-
headers: { "content-type": "application/json" },
|
|
103
|
+
headers: { "content-type": "application/json", ...relayAuthHeaders(token) },
|
|
95
104
|
body: JSON.stringify({ kind: "log", limit: 0 }),
|
|
96
105
|
signal: ac.signal,
|
|
97
106
|
});
|
|
@@ -132,14 +141,15 @@ export async function readLivePreview(cwd, deps = {}) {
|
|
|
132
141
|
if (!info)
|
|
133
142
|
return undefined;
|
|
134
143
|
const processAlive = deps.processAlive ?? isProcessAlive;
|
|
135
|
-
const relayResponding = deps.relayResponding ??
|
|
144
|
+
const relayResponding = deps.relayResponding ??
|
|
145
|
+
((p, t) => isRelayResponding(p, undefined, undefined, t));
|
|
136
146
|
// The cheap gate first: a dead pid needs no network round trip.
|
|
137
147
|
if (info.pid !== undefined && !processAlive(info.pid)) {
|
|
138
148
|
await clearRelayInfo(cwd);
|
|
139
149
|
return undefined;
|
|
140
150
|
}
|
|
141
151
|
// The identity gate: alive-but-not-ours (a recycled pid) fails here.
|
|
142
|
-
if (!(await relayResponding(info.relayPort))) {
|
|
152
|
+
if (!(await relayResponding(info.relayPort, info.token))) {
|
|
143
153
|
await clearRelayInfo(cwd);
|
|
144
154
|
return undefined;
|
|
145
155
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export declare const CONTROL_CHARS: RegExp;
|
|
2
2
|
/** Each C0/C1 control character as a visible `\uXXXX`, so nothing is hidden or redrawn. */
|
|
3
3
|
export declare function escapeControl(text: string): string;
|
|
4
|
+
/** Text with every C0/C1 control and bidi override or isolate removed. */
|
|
5
|
+
export declare function terminalSafe(text: string): string;
|
package/dist/lib/terminalText.js
CHANGED
|
@@ -8,3 +8,10 @@ const CONTROL_CHARS_G = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
|
8
8
|
export function escapeControl(text) {
|
|
9
9
|
return text.replace(CONTROL_CHARS_G, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
10
10
|
}
|
|
11
|
+
// U+202A..U+202E (embeddings, overrides) and U+2066..U+2069 (isolates) can
|
|
12
|
+
// reorder a terminal line, so a crafted name could disguise what it says.
|
|
13
|
+
const BIDI_CONTROLS_G = /[\u202a-\u202e\u2066-\u2069]/g;
|
|
14
|
+
/** Text with every C0/C1 control and bidi override or isolate removed. */
|
|
15
|
+
export function terminalSafe(text) {
|
|
16
|
+
return text.replace(CONTROL_CHARS_G, "").replace(BIDI_CONTROLS_G, "");
|
|
17
|
+
}
|
|
@@ -29,11 +29,17 @@ export declare class LoginTimeoutError extends Error {
|
|
|
29
29
|
export declare class LoginCallbackError extends Error {
|
|
30
30
|
constructor(message: string);
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* The platform command + args to open `url` in the default browser. On win32
|
|
34
|
+
* the URL goes to `rundll32` as one argument, so no command interpreter parses
|
|
35
|
+
* it and `&` cannot split the command.
|
|
36
|
+
*/
|
|
33
37
|
export declare function browserOpenCommand(url: string, platform?: string): {
|
|
34
38
|
cmd: string;
|
|
35
39
|
args: string[];
|
|
36
40
|
};
|
|
41
|
+
/** Only `https:`, or `http:` on a loopback host, is ever handed to the OS opener. */
|
|
42
|
+
export declare function isLaunchableUrl(url: string): boolean;
|
|
37
43
|
type SpawnLike = (cmd: string, args: string[], opts: {
|
|
38
44
|
stdio: "ignore";
|
|
39
45
|
detached: boolean;
|
package/dist/login/pkceLogin.js
CHANGED
|
@@ -30,14 +30,35 @@ export class LoginCallbackError extends Error {
|
|
|
30
30
|
this.name = "LoginCallbackError";
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* The platform command + args to open `url` in the default browser. On win32
|
|
35
|
+
* the URL goes to `rundll32` as one argument, so no command interpreter parses
|
|
36
|
+
* it and `&` cannot split the command.
|
|
37
|
+
*/
|
|
34
38
|
export function browserOpenCommand(url, platform = process.platform) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
if (platform === "win32") {
|
|
40
|
+
return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
|
|
41
|
+
}
|
|
42
|
+
return { cmd: platform === "darwin" ? "open" : "xdg-open", args: [url] };
|
|
43
|
+
}
|
|
44
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"]);
|
|
45
|
+
/** Only `https:`, or `http:` on a loopback host, is ever handed to the OS opener. */
|
|
46
|
+
export function isLaunchableUrl(url) {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = new URL(url);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (parsed.protocol === "https:")
|
|
55
|
+
return true;
|
|
56
|
+
return parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname);
|
|
38
57
|
}
|
|
39
58
|
/** Launch the browser via an injected spawn; a throw is swallowed (fallback = the printed URL). */
|
|
40
59
|
export function launchBrowser(url, spawn, platform) {
|
|
60
|
+
if (!isLaunchableUrl(url))
|
|
61
|
+
return;
|
|
41
62
|
const { cmd, args } = browserOpenCommand(url, platform);
|
|
42
63
|
try {
|
|
43
64
|
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
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) {
|
|
@@ -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;
|