@vincentt-xr/cli 0.1.5 → 0.2.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/README.md +94 -0
- package/dist/cli/args.d.ts +13 -0
- package/dist/cli/args.js +41 -0
- package/dist/cli/index.js +362 -92
- package/dist/lib/backend.d.ts +45 -5
- package/dist/lib/backend.js +99 -18
- package/dist/lib/diagnostics.d.ts +27 -2
- package/dist/lib/diagnostics.js +73 -8
- package/dist/lib/initCommand.d.ts +14 -0
- package/dist/lib/initCommand.js +75 -0
- package/dist/lib/lifecycle.d.ts +73 -15
- package/dist/lib/lifecycle.js +111 -114
- package/dist/lib/org.d.ts +29 -0
- package/dist/lib/org.js +45 -0
- package/dist/lib/projects.d.ts +82 -0
- package/dist/lib/projects.js +217 -0
- package/dist/lib/relayInfo.d.ts +41 -3
- package/dist/lib/relayInfo.js +94 -0
- package/dist/lib/whoami.d.ts +11 -0
- package/dist/lib/whoami.js +38 -0
- package/dist/login/authBackend.d.ts +30 -0
- package/dist/login/authBackend.js +80 -0
- package/dist/login/guard.d.ts +13 -0
- package/dist/login/guard.js +17 -0
- package/dist/login/loginCommand.d.ts +39 -0
- package/dist/login/loginCommand.js +72 -0
- package/dist/login/pkce.d.ts +47 -0
- package/dist/login/pkce.js +106 -0
- package/dist/login/pkceLogin.d.ts +53 -0
- package/dist/login/pkceLogin.js +152 -0
- package/dist/mcp/server.js +47 -20
- package/dist/preview/api.d.ts +84 -0
- package/dist/preview/api.js +174 -0
- package/dist/preview/command.d.ts +96 -0
- package/dist/preview/command.js +276 -0
- package/dist/preview/copy.d.ts +209 -0
- package/dist/preview/copy.js +444 -0
- package/dist/preview/index.d.ts +2 -3
- package/dist/preview/index.js +5 -6
- package/dist/preview/proxy.d.ts +12 -1
- package/dist/preview/proxy.js +17 -7
- package/dist/preview/qr.d.ts +30 -0
- package/dist/preview/qr.js +89 -0
- package/dist/preview/runner.d.ts +155 -30
- package/dist/preview/runner.js +219 -68
- package/dist/preview/stream.d.ts +52 -0
- package/dist/preview/stream.js +171 -0
- package/dist/preview/viewers.d.ts +113 -0
- package/dist/preview/viewers.js +242 -0
- package/dist/relay/server.d.ts +32 -0
- package/dist/relay/server.js +80 -12
- package/dist/relay/store.d.ts +32 -3
- package/dist/relay/store.js +76 -22
- package/dist/relay/testers.d.ts +90 -0
- package/dist/relay/testers.js +144 -0
- package/dist/shared/authError.d.ts +1 -0
- package/dist/shared/authError.js +11 -0
- package/dist/shared/config.d.ts +73 -5
- package/dist/shared/config.js +133 -20
- package/package.json +2 -3
- package/dist/login/login.d.ts +0 -34
- package/dist/login/login.js +0 -148
- package/dist/preview/cloudflared.d.ts +0 -13
- package/dist/preview/cloudflared.js +0 -46
- package/dist/preview/tunnel.d.ts +0 -14
- package/dist/preview/tunnel.js +0 -28
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../shared/config.js";
|
|
2
|
+
import { BackendError } from "../lib/backend.js";
|
|
3
|
+
/** The mint response. Its key set is the cross-repo pin (contract table 2.11). */
|
|
4
|
+
export interface MintedPreview {
|
|
5
|
+
sessionId: string;
|
|
6
|
+
/** Full `https://<label>.<apex>` — SERVER-COMPUTED, never assembled here. */
|
|
7
|
+
url: string;
|
|
8
|
+
/** `wss://…/tunnel` — server-computed for the same reason. */
|
|
9
|
+
edgeUrl: string;
|
|
10
|
+
/** An opaque JWS. Never parsed, never printed. */
|
|
11
|
+
grant: string;
|
|
12
|
+
heartbeatSec: number;
|
|
13
|
+
/** ISO8601, the hard max. */
|
|
14
|
+
expiresAt: string;
|
|
15
|
+
}
|
|
16
|
+
export interface HeartbeatResult {
|
|
17
|
+
state: string;
|
|
18
|
+
viewerCount: number;
|
|
19
|
+
expiresAt: string;
|
|
20
|
+
}
|
|
21
|
+
/** The mint rate limit (§2.16). `retryAfterSec` comes from the header, never the body. */
|
|
22
|
+
export declare class PreviewRateLimitedError extends BackendError {
|
|
23
|
+
readonly retryAfterSec: number;
|
|
24
|
+
constructor(retryAfterSec: number);
|
|
25
|
+
}
|
|
26
|
+
/** A preview already exists for this working tree and the mint lost the race. */
|
|
27
|
+
export declare class PreviewConflictError extends BackendError {
|
|
28
|
+
constructor(message: string);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The session is gone — `preview_session_ended`, the CLI's terminal branch.
|
|
32
|
+
*
|
|
33
|
+
* `endReason` is the DISCRIMINATOR the five ending screens need, and it is
|
|
34
|
+
* `undefined` against the API as built: every terminal cause answers this route
|
|
35
|
+
* with a byte-identical body (`{"code":"preview_session_ended","message":"this
|
|
36
|
+
* preview has ended"}`), verified against the running stack with an `abandoned`
|
|
37
|
+
* row and an `expired` row. QA-F3-09 names that exact condition — "if the wire
|
|
38
|
+
* carried one undifferentiated 'ended', the designer's spec would be unbuildable
|
|
39
|
+
* and that is a fork, not a test failure".
|
|
40
|
+
*
|
|
41
|
+
* It is read here anyway so the client half is complete and the seam is one
|
|
42
|
+
* field: the day the API adds `endReason` to the 410 body, the abandoned and
|
|
43
|
+
* hard-max screens separate with no further client change.
|
|
44
|
+
*/
|
|
45
|
+
export declare class PreviewEndedError extends BackendError {
|
|
46
|
+
readonly endReason?: string | undefined;
|
|
47
|
+
constructor(message: string, endReason?: string | undefined);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* workingTreeKey is sha256 of the REALPATH of the project root, and the raw path
|
|
51
|
+
* NEVER leaves the machine: it is a filesystem-layout disclosure with zero
|
|
52
|
+
* platform value, and the one legitimate use (the one-session-per-tree supersede
|
|
53
|
+
* rule) only needs a stable opaque key.
|
|
54
|
+
*
|
|
55
|
+
* realpath, not the literal argument, so `.`, a relative path and a symlink into
|
|
56
|
+
* the same tree all produce ONE key — otherwise "one preview per working tree"
|
|
57
|
+
* would be one preview per spelling of the path.
|
|
58
|
+
*/
|
|
59
|
+
export declare function workingTreeKey(projectRoot: string): Promise<string>;
|
|
60
|
+
/**
|
|
61
|
+
* parseRetryAfter reads the 429's header. The BODY carries no discriminator for
|
|
62
|
+
* which of the two mint limits fired — the header answers "how long", never
|
|
63
|
+
* "which" — so a missing or unparseable value falls back to a minute rather than
|
|
64
|
+
* inventing a number the copy would then state as fact.
|
|
65
|
+
*/
|
|
66
|
+
export declare function parseRetryAfter(raw: string | null): number;
|
|
67
|
+
/** POST /projects/{id}/preview-sessions — mint. */
|
|
68
|
+
export declare function mintPreviewSession(cfg: ResolvedConfig, projectId: string, key: string, fetchImpl?: typeof fetch): Promise<MintedPreview>;
|
|
69
|
+
/**
|
|
70
|
+
* parseMint refuses a response missing any field the CLI depends on rather than
|
|
71
|
+
* defaulting one. A defaulted `url` is a blank line where the creator expects the
|
|
72
|
+
* link; a defaulted `edgeUrl` is a tunnel dialed at the wrong host. f2's
|
|
73
|
+
* `parseProjectView` dropped its legacy tolerance for the same reason.
|
|
74
|
+
*/
|
|
75
|
+
export declare function parseMint(raw: unknown): MintedPreview;
|
|
76
|
+
/** POST /preview-sessions/{id}/heartbeat — keepalive AND re-authorization. */
|
|
77
|
+
export declare function heartbeatPreviewSession(cfg: ResolvedConfig, sessionId: string, fetchImpl?: typeof fetch): Promise<HeartbeatResult>;
|
|
78
|
+
/**
|
|
79
|
+
* DELETE /preview-sessions/{id} — end. Best-effort and never throws: the creator
|
|
80
|
+
* has already pressed Ctrl-C, and the session's own expiry reaps it regardless.
|
|
81
|
+
* Surfacing a teardown failure would turn a successful stop into an error the
|
|
82
|
+
* creator cannot act on.
|
|
83
|
+
*/
|
|
84
|
+
export declare function endPreviewSession(cfg: ResolvedConfig, sessionId: string, fetchImpl?: typeof fetch): Promise<void>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The preview-session calls against the Go API. Isolated from the runner so the
|
|
2
|
+
// wire shapes are unit-testable and the runner stays orchestration.
|
|
3
|
+
//
|
|
4
|
+
// Every field name here is pinned to `api/internal/httpapi/handlers_preview.go`.
|
|
5
|
+
// A CLI that guessed at them would pass its own suite while breaking the seam,
|
|
6
|
+
// which is the f1 `redirect`/`redirect_uri` failure.
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { realpath } from "node:fs/promises";
|
|
9
|
+
import { BackendError, ProjectUnreachableError } from "../lib/backend.js";
|
|
10
|
+
/** The mint rate limit (§2.16). `retryAfterSec` comes from the header, never the body. */
|
|
11
|
+
export class PreviewRateLimitedError extends BackendError {
|
|
12
|
+
retryAfterSec;
|
|
13
|
+
constructor(retryAfterSec) {
|
|
14
|
+
super("Too many previews started recently.", 429);
|
|
15
|
+
this.retryAfterSec = retryAfterSec;
|
|
16
|
+
this.name = "PreviewRateLimitedError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** A preview already exists for this working tree and the mint lost the race. */
|
|
20
|
+
export class PreviewConflictError extends BackendError {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message, 409);
|
|
23
|
+
this.name = "PreviewConflictError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The session is gone — `preview_session_ended`, the CLI's terminal branch.
|
|
28
|
+
*
|
|
29
|
+
* `endReason` is the DISCRIMINATOR the five ending screens need, and it is
|
|
30
|
+
* `undefined` against the API as built: every terminal cause answers this route
|
|
31
|
+
* with a byte-identical body (`{"code":"preview_session_ended","message":"this
|
|
32
|
+
* preview has ended"}`), verified against the running stack with an `abandoned`
|
|
33
|
+
* row and an `expired` row. QA-F3-09 names that exact condition — "if the wire
|
|
34
|
+
* carried one undifferentiated 'ended', the designer's spec would be unbuildable
|
|
35
|
+
* and that is a fork, not a test failure".
|
|
36
|
+
*
|
|
37
|
+
* It is read here anyway so the client half is complete and the seam is one
|
|
38
|
+
* field: the day the API adds `endReason` to the 410 body, the abandoned and
|
|
39
|
+
* hard-max screens separate with no further client change.
|
|
40
|
+
*/
|
|
41
|
+
export class PreviewEndedError extends BackendError {
|
|
42
|
+
endReason;
|
|
43
|
+
constructor(message, endReason) {
|
|
44
|
+
super(message, 410);
|
|
45
|
+
this.endReason = endReason;
|
|
46
|
+
this.name = "PreviewEndedError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* workingTreeKey is sha256 of the REALPATH of the project root, and the raw path
|
|
51
|
+
* NEVER leaves the machine: it is a filesystem-layout disclosure with zero
|
|
52
|
+
* platform value, and the one legitimate use (the one-session-per-tree supersede
|
|
53
|
+
* rule) only needs a stable opaque key.
|
|
54
|
+
*
|
|
55
|
+
* realpath, not the literal argument, so `.`, a relative path and a symlink into
|
|
56
|
+
* the same tree all produce ONE key — otherwise "one preview per working tree"
|
|
57
|
+
* would be one preview per spelling of the path.
|
|
58
|
+
*/
|
|
59
|
+
export async function workingTreeKey(projectRoot) {
|
|
60
|
+
let resolved;
|
|
61
|
+
try {
|
|
62
|
+
resolved = await realpath(projectRoot);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
resolved = projectRoot;
|
|
66
|
+
}
|
|
67
|
+
return createHash("sha256").update(resolved).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
async function errorBody(res) {
|
|
70
|
+
try {
|
|
71
|
+
const parsed = await res.json();
|
|
72
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function authHeaders(cfg) {
|
|
79
|
+
return { authorization: `Bearer ${cfg.pat}`, "content-type": "application/json" };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* parseRetryAfter reads the 429's header. The BODY carries no discriminator for
|
|
83
|
+
* which of the two mint limits fired — the header answers "how long", never
|
|
84
|
+
* "which" — so a missing or unparseable value falls back to a minute rather than
|
|
85
|
+
* inventing a number the copy would then state as fact.
|
|
86
|
+
*/
|
|
87
|
+
export function parseRetryAfter(raw) {
|
|
88
|
+
const n = Number(raw);
|
|
89
|
+
return Number.isFinite(n) && n > 0 ? Math.ceil(n) : 60;
|
|
90
|
+
}
|
|
91
|
+
/** POST /projects/{id}/preview-sessions — mint. */
|
|
92
|
+
export async function mintPreviewSession(cfg, projectId, key, fetchImpl = fetch) {
|
|
93
|
+
const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/preview-sessions`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: authHeaders(cfg),
|
|
96
|
+
body: JSON.stringify({ workingTreeKey: key }),
|
|
97
|
+
});
|
|
98
|
+
if (res.status === 429) {
|
|
99
|
+
throw new PreviewRateLimitedError(parseRetryAfter(res.headers.get("retry-after")));
|
|
100
|
+
}
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
const body = await errorBody(res);
|
|
103
|
+
if (res.status === 404) {
|
|
104
|
+
throw new ProjectUnreachableError(body.message ?? "This project is not available to you.");
|
|
105
|
+
}
|
|
106
|
+
if (res.status === 409) {
|
|
107
|
+
throw new PreviewConflictError(body.message ?? "a preview is already running for this folder");
|
|
108
|
+
}
|
|
109
|
+
throw new BackendError(body.message ?? `could not start a preview (${res.status})`, res.status);
|
|
110
|
+
}
|
|
111
|
+
return parseMint(await res.json());
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* parseMint refuses a response missing any field the CLI depends on rather than
|
|
115
|
+
* defaulting one. A defaulted `url` is a blank line where the creator expects the
|
|
116
|
+
* link; a defaulted `edgeUrl` is a tunnel dialed at the wrong host. f2's
|
|
117
|
+
* `parseProjectView` dropped its legacy tolerance for the same reason.
|
|
118
|
+
*/
|
|
119
|
+
export function parseMint(raw) {
|
|
120
|
+
const p = (raw ?? {});
|
|
121
|
+
const str = (k) => typeof p[k] === "string" ? p[k] : undefined;
|
|
122
|
+
const sessionId = str("sessionId");
|
|
123
|
+
const url = str("url");
|
|
124
|
+
const edgeUrl = str("edgeUrl");
|
|
125
|
+
const grant = str("grant");
|
|
126
|
+
const expiresAt = str("expiresAt");
|
|
127
|
+
const heartbeatSec = typeof p.heartbeatSec === "number" ? p.heartbeatSec : undefined;
|
|
128
|
+
if (!sessionId ||
|
|
129
|
+
!url ||
|
|
130
|
+
!edgeUrl ||
|
|
131
|
+
!grant ||
|
|
132
|
+
!expiresAt ||
|
|
133
|
+
heartbeatSec === undefined) {
|
|
134
|
+
throw new BackendError("mint preview: response is missing required fields");
|
|
135
|
+
}
|
|
136
|
+
return { sessionId, url, edgeUrl, grant, heartbeatSec, expiresAt };
|
|
137
|
+
}
|
|
138
|
+
/** POST /preview-sessions/{id}/heartbeat — keepalive AND re-authorization. */
|
|
139
|
+
export async function heartbeatPreviewSession(cfg, sessionId, fetchImpl = fetch) {
|
|
140
|
+
const res = await fetchImpl(`${cfg.apiUrl}/preview-sessions/${encodeURIComponent(sessionId)}/heartbeat`, { method: "POST", headers: authHeaders(cfg) });
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
const body = await errorBody(res);
|
|
143
|
+
if (res.status === 410) {
|
|
144
|
+
throw new PreviewEndedError(body.message ?? "this preview has ended", typeof body.endReason === "string" ? body.endReason : undefined);
|
|
145
|
+
}
|
|
146
|
+
if (res.status === 404 || res.status === 403) {
|
|
147
|
+
throw new ProjectUnreachableError(body.message ?? "This project is not available to you.");
|
|
148
|
+
}
|
|
149
|
+
throw new BackendError(body.message ?? `heartbeat failed (${res.status})`, res.status);
|
|
150
|
+
}
|
|
151
|
+
const b = (await res.json());
|
|
152
|
+
return {
|
|
153
|
+
state: b.state ?? "live",
|
|
154
|
+
viewerCount: b.viewerCount ?? 0,
|
|
155
|
+
expiresAt: b.expiresAt ?? "",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* DELETE /preview-sessions/{id} — end. Best-effort and never throws: the creator
|
|
160
|
+
* has already pressed Ctrl-C, and the session's own expiry reaps it regardless.
|
|
161
|
+
* Surfacing a teardown failure would turn a successful stop into an error the
|
|
162
|
+
* creator cannot act on.
|
|
163
|
+
*/
|
|
164
|
+
export async function endPreviewSession(cfg, sessionId, fetchImpl = fetch) {
|
|
165
|
+
try {
|
|
166
|
+
await fetchImpl(`${cfg.apiUrl}/preview-sessions/${encodeURIComponent(sessionId)}`, {
|
|
167
|
+
method: "DELETE",
|
|
168
|
+
headers: { authorization: `Bearer ${cfg.pat}` },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// See above.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { DiagEvent } from "@vincentt-xr/harness/events";
|
|
2
|
+
import type { TunnelState, UAClass } from "@vincentt-xr/harness/tunnel";
|
|
3
|
+
import type { EventTags } from "../relay/server.js";
|
|
4
|
+
import type { PreviewEndReason } from "./runner.js";
|
|
5
|
+
export interface PreviewUiDeps {
|
|
6
|
+
print: (line: string) => void;
|
|
7
|
+
now: () => number;
|
|
8
|
+
/** The forward target, named identically in the start block and the 502. */
|
|
9
|
+
targetUrl: string;
|
|
10
|
+
color: boolean;
|
|
11
|
+
/** Filter the stream to one tester (`--tester`) — one tab, across refreshes. */
|
|
12
|
+
filterTester?: string;
|
|
13
|
+
/** Filter the stream to one connection (`--viewer`). */
|
|
14
|
+
filterViewer?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* PreviewUi owns every line after the mint. It is a pure sink — it takes events
|
|
18
|
+
* and prints strings — so the whole transcript is assertable without a terminal,
|
|
19
|
+
* a socket or a phone.
|
|
20
|
+
*/
|
|
21
|
+
export declare class PreviewUi {
|
|
22
|
+
private readonly deps;
|
|
23
|
+
private readonly roster;
|
|
24
|
+
private readonly perf;
|
|
25
|
+
private readonly p;
|
|
26
|
+
private readonly startedAt;
|
|
27
|
+
/** L18 prints ONCE PER STRETCH of failure, not once per occurrence. */
|
|
28
|
+
private originDown;
|
|
29
|
+
/** L21's soft warning prints once per preview. */
|
|
30
|
+
private egressWarned;
|
|
31
|
+
/** L20b's 30-minute warning prints once. */
|
|
32
|
+
private hardMaxWarned;
|
|
33
|
+
private tunnelState;
|
|
34
|
+
private ended;
|
|
35
|
+
constructor(deps: PreviewUiDeps);
|
|
36
|
+
get connectedCount(): number;
|
|
37
|
+
get totalConnections(): number;
|
|
38
|
+
private line;
|
|
39
|
+
private lines;
|
|
40
|
+
/**
|
|
41
|
+
* A CONNECTION arrived. Records it and prints NOTHING: one tab opens two
|
|
42
|
+
* connections under HTTP/1.1, so a line here is what made one QR scan read as
|
|
43
|
+
* two records. The creator's line comes from `testerSeen`.
|
|
44
|
+
*
|
|
45
|
+
* The tunnel client fires onViewer on EVERY OPEN frame, not once per
|
|
46
|
+
* connection, so the roster's idempotence is what keeps a phone that requests
|
|
47
|
+
* forty assets from announcing itself forty times.
|
|
48
|
+
*/
|
|
49
|
+
viewerSeen(label: string, uaClass: UAClass): void;
|
|
50
|
+
viewerGone(label: string): void;
|
|
51
|
+
/**
|
|
52
|
+
* The harness introduced itself on a connection. This is what prints the
|
|
53
|
+
* creator's connect line — not `viewerSeen`, which now stays silent because
|
|
54
|
+
* one tab opens two connections and a line per connection is the defect this
|
|
55
|
+
* fixes.
|
|
56
|
+
*/
|
|
57
|
+
testerSeen(label: string, tester: string): void;
|
|
58
|
+
/**
|
|
59
|
+
* The tester cap, once per refused session. It names the number so the creator
|
|
60
|
+
* can act on it, and says "this connection" rather than naming a tester —
|
|
61
|
+
* because there is none, and inventing one is the silent merge this design
|
|
62
|
+
* exists to prevent.
|
|
63
|
+
*/
|
|
64
|
+
capReached(): void;
|
|
65
|
+
events(evs: DiagEvent[], tags: EventTags, opts?: {
|
|
66
|
+
attribute?: boolean;
|
|
67
|
+
}): void;
|
|
68
|
+
/**
|
|
69
|
+
* L18. Recovery is announced by the 200 that follows and by NOTHING else — a
|
|
70
|
+
* "recovered!" line would be a second interruption for a non-event.
|
|
71
|
+
*/
|
|
72
|
+
originError(): void;
|
|
73
|
+
originRecovered(): void;
|
|
74
|
+
/** L19. The tunnel's own state is what the creator's network failure looks like. */
|
|
75
|
+
tunnel(state: TunnelState): void;
|
|
76
|
+
/** L21's soft threshold. Printed once; the preview KEEPS SERVING. */
|
|
77
|
+
egressSoft(): void;
|
|
78
|
+
/**
|
|
79
|
+
* L20b's 30-minute warning. `expiresAt` is the minted ceiling and a heartbeat
|
|
80
|
+
* never extends it, so this fires exactly once per preview.
|
|
81
|
+
*/
|
|
82
|
+
checkHardMax(expiresAt: string): void;
|
|
83
|
+
/** The creator's own Ctrl-C. Prints the stopping line, then the ending. */
|
|
84
|
+
stopping(): void;
|
|
85
|
+
stopped(): void;
|
|
86
|
+
/** Every ending the creator did NOT cause. One first line per cause. */
|
|
87
|
+
endedBy(reason: PreviewEndReason, orgName: string): void;
|
|
88
|
+
following(label: string): void;
|
|
89
|
+
notFollowing(label: string): void;
|
|
90
|
+
notFollowingTester(tester: string): void;
|
|
91
|
+
/** Live testers — what `stopping`/`stopped` should be counting. */
|
|
92
|
+
get testerCount(): number;
|
|
93
|
+
}
|
|
94
|
+
export declare function endingLines(reason: PreviewEndReason, orgName: string): string[];
|
|
95
|
+
/** `21:04` — what a creator plans a demo around, in their own local time. */
|
|
96
|
+
export declare function clockTime(d: Date): string;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// `vincentt preview` — the first LONG-RUNNING command in the product.
|
|
2
|
+
//
|
|
3
|
+
// It has three zones and a creator scrolling back should be able to find each by
|
|
4
|
+
// shape alone:
|
|
5
|
+
// 1. a START BLOCK that is quiet and complete (everything needed to hand off,
|
|
6
|
+
// above the fold);
|
|
7
|
+
// 2. a STREAM that is uniform, prefixed and scannable, NEVER redrawn;
|
|
8
|
+
// 3. ONE ending line plus one remedy.
|
|
9
|
+
//
|
|
10
|
+
// The stream is never repainted, there is no spinner and no status bar. The
|
|
11
|
+
// reasons are cumulative: Ctrl-C stays clean, scrollback stays true, piping to a
|
|
12
|
+
// file works, and an agent reading the transcript gets the same information a
|
|
13
|
+
// human does.
|
|
14
|
+
import * as copy from "./copy.js";
|
|
15
|
+
import { PerfThrottle, palette, renderEvent, uaLabel } from "./stream.js";
|
|
16
|
+
import { ViewerRoster } from "./viewers.js";
|
|
17
|
+
import { MAX_TESTERS } from "../relay/testers.js";
|
|
18
|
+
/**
|
|
19
|
+
* PreviewUi owns every line after the mint. It is a pure sink — it takes events
|
|
20
|
+
* and prints strings — so the whole transcript is assertable without a terminal,
|
|
21
|
+
* a socket or a phone.
|
|
22
|
+
*/
|
|
23
|
+
export class PreviewUi {
|
|
24
|
+
deps;
|
|
25
|
+
roster = new ViewerRoster();
|
|
26
|
+
perf;
|
|
27
|
+
p;
|
|
28
|
+
startedAt;
|
|
29
|
+
/** L18 prints ONCE PER STRETCH of failure, not once per occurrence. */
|
|
30
|
+
originDown = false;
|
|
31
|
+
/** L21's soft warning prints once per preview. */
|
|
32
|
+
egressWarned = false;
|
|
33
|
+
/** L20b's 30-minute warning prints once. */
|
|
34
|
+
hardMaxWarned = false;
|
|
35
|
+
tunnelState = "connecting";
|
|
36
|
+
ended = false;
|
|
37
|
+
constructor(deps) {
|
|
38
|
+
this.deps = deps;
|
|
39
|
+
this.p = palette(deps.color);
|
|
40
|
+
this.perf = new PerfThrottle();
|
|
41
|
+
this.startedAt = deps.now();
|
|
42
|
+
}
|
|
43
|
+
get connectedCount() {
|
|
44
|
+
return this.roster.size;
|
|
45
|
+
}
|
|
46
|
+
get totalConnections() {
|
|
47
|
+
return this.roster.totalConnections;
|
|
48
|
+
}
|
|
49
|
+
line(s) {
|
|
50
|
+
this.deps.print(s);
|
|
51
|
+
}
|
|
52
|
+
lines(ls) {
|
|
53
|
+
for (const l of ls)
|
|
54
|
+
this.deps.print(l);
|
|
55
|
+
}
|
|
56
|
+
// ---- viewers --------------------------------------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* A CONNECTION arrived. Records it and prints NOTHING: one tab opens two
|
|
59
|
+
* connections under HTTP/1.1, so a line here is what made one QR scan read as
|
|
60
|
+
* two records. The creator's line comes from `testerSeen`.
|
|
61
|
+
*
|
|
62
|
+
* The tunnel client fires onViewer on EVERY OPEN frame, not once per
|
|
63
|
+
* connection, so the roster's idempotence is what keeps a phone that requests
|
|
64
|
+
* forty assets from announcing itself forty times.
|
|
65
|
+
*/
|
|
66
|
+
viewerSeen(label, uaClass) {
|
|
67
|
+
const out = this.roster.connect(label, uaClass, this.deps.now());
|
|
68
|
+
if (out.connect === undefined)
|
|
69
|
+
return;
|
|
70
|
+
this.line("");
|
|
71
|
+
this.line(` ${out.connect}`);
|
|
72
|
+
if (out.advisory)
|
|
73
|
+
this.lines(out.advisory.map((l) => ` ${l}`));
|
|
74
|
+
this.line("");
|
|
75
|
+
}
|
|
76
|
+
viewerGone(label) {
|
|
77
|
+
const out = this.roster.disconnect(label, this.deps.now());
|
|
78
|
+
this.perf.forget(label);
|
|
79
|
+
if (out.connect === undefined)
|
|
80
|
+
return;
|
|
81
|
+
this.line("");
|
|
82
|
+
this.line(` ${out.connect}`);
|
|
83
|
+
this.line("");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The harness introduced itself on a connection. This is what prints the
|
|
87
|
+
* creator's connect line — not `viewerSeen`, which now stays silent because
|
|
88
|
+
* one tab opens two connections and a line per connection is the defect this
|
|
89
|
+
* fixes.
|
|
90
|
+
*/
|
|
91
|
+
testerSeen(label, tester) {
|
|
92
|
+
const out = this.roster.attribute(label, tester);
|
|
93
|
+
if (out.connect === undefined)
|
|
94
|
+
return;
|
|
95
|
+
this.line("");
|
|
96
|
+
this.line(` ${out.connect}`);
|
|
97
|
+
if (out.advisory)
|
|
98
|
+
this.lines(out.advisory.map((l) => ` ${l}`));
|
|
99
|
+
if (out.roster) {
|
|
100
|
+
this.line("");
|
|
101
|
+
this.lines(out.roster.map((l) => ` ${l}`));
|
|
102
|
+
}
|
|
103
|
+
this.line("");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The tester cap, once per refused session. It names the number so the creator
|
|
107
|
+
* can act on it, and says "this connection" rather than naming a tester —
|
|
108
|
+
* because there is none, and inventing one is the silent merge this design
|
|
109
|
+
* exists to prevent.
|
|
110
|
+
*/
|
|
111
|
+
capReached() {
|
|
112
|
+
this.line("");
|
|
113
|
+
this.line(` ! Tester limit reached (${MAX_TESTERS}). This connection's events are not attributed.`);
|
|
114
|
+
this.line("");
|
|
115
|
+
}
|
|
116
|
+
// ---- the stream -----------------------------------------------------------
|
|
117
|
+
events(evs, tags, opts = {}) {
|
|
118
|
+
const { tester, viewer } = tags;
|
|
119
|
+
// The first batch carrying a tester is ALSO the harness introducing itself,
|
|
120
|
+
// so attribution rides the event path rather than a second callback. There
|
|
121
|
+
// is no earlier signal available: the tester does not exist until the app
|
|
122
|
+
// mounts and flushes.
|
|
123
|
+
//
|
|
124
|
+
// A FOLLOWER passes attribute:false. It is polling a relay owned by another
|
|
125
|
+
// process and never saw the connections, so its roster is empty and it must
|
|
126
|
+
// not narrate arrivals it cannot actually observe.
|
|
127
|
+
if (opts.attribute !== false && tester !== undefined && viewer !== undefined) {
|
|
128
|
+
this.testerSeen(viewer, tester);
|
|
129
|
+
}
|
|
130
|
+
// Column one is the TESTER — the coordinate that still resolves by the time
|
|
131
|
+
// anyone reads the transcript. An event with no tester cannot be attributed,
|
|
132
|
+
// and rather than invent a placeholder that would read as a real one, it is
|
|
133
|
+
// dropped from the TERMINAL. The relay still holds it, so `vincentt logs`
|
|
134
|
+
// and the MCP tools see it.
|
|
135
|
+
if (tester === undefined)
|
|
136
|
+
return;
|
|
137
|
+
if (this.deps.filterTester !== undefined && tester !== this.deps.filterTester)
|
|
138
|
+
return;
|
|
139
|
+
if (this.deps.filterViewer !== undefined &&
|
|
140
|
+
(viewer === undefined || viewer !== this.deps.filterViewer)) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
for (const e of evs) {
|
|
144
|
+
// Throttled per CONNECTION, not per tester: a tab's two sockets report
|
|
145
|
+
// independently, and gating on the tester would drop one of them.
|
|
146
|
+
const throttleKey = viewer ?? tester;
|
|
147
|
+
if (e.kind === "trace" && !this.perf.allow(throttleKey, this.deps.now()))
|
|
148
|
+
continue;
|
|
149
|
+
const rendered = renderEvent(tester, e, this.p);
|
|
150
|
+
if (rendered !== null)
|
|
151
|
+
this.line(` ${rendered}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// ---- advisories -----------------------------------------------------------
|
|
155
|
+
/**
|
|
156
|
+
* L18. Recovery is announced by the 200 that follows and by NOTHING else — a
|
|
157
|
+
* "recovered!" line would be a second interruption for a non-event.
|
|
158
|
+
*/
|
|
159
|
+
originError() {
|
|
160
|
+
if (this.originDown)
|
|
161
|
+
return;
|
|
162
|
+
this.originDown = true;
|
|
163
|
+
this.line("");
|
|
164
|
+
this.lines(copy.originDown(this.deps.targetUrl).map((l) => ` ${l}`));
|
|
165
|
+
}
|
|
166
|
+
originRecovered() {
|
|
167
|
+
this.originDown = false;
|
|
168
|
+
}
|
|
169
|
+
/** L19. The tunnel's own state is what the creator's network failure looks like. */
|
|
170
|
+
tunnel(state) {
|
|
171
|
+
const prev = this.tunnelState;
|
|
172
|
+
this.tunnelState = state;
|
|
173
|
+
if (state === "reconnecting" && prev === "live") {
|
|
174
|
+
this.line("");
|
|
175
|
+
this.lines(copy.reconnecting().map((l) => ` ${l}`));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (state === "live" && prev === "reconnecting") {
|
|
179
|
+
this.lines(copy.reconnected().map((l) => ` ${l}`));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** L21's soft threshold. Printed once; the preview KEEPS SERVING. */
|
|
183
|
+
egressSoft() {
|
|
184
|
+
if (this.egressWarned)
|
|
185
|
+
return;
|
|
186
|
+
this.egressWarned = true;
|
|
187
|
+
this.line("");
|
|
188
|
+
this.lines(copy.egressSoft().map((l) => ` ${l}`));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* L20b's 30-minute warning. `expiresAt` is the minted ceiling and a heartbeat
|
|
192
|
+
* never extends it, so this fires exactly once per preview.
|
|
193
|
+
*/
|
|
194
|
+
checkHardMax(expiresAt) {
|
|
195
|
+
if (this.hardMaxWarned)
|
|
196
|
+
return;
|
|
197
|
+
const at = Date.parse(expiresAt);
|
|
198
|
+
if (!Number.isFinite(at))
|
|
199
|
+
return;
|
|
200
|
+
const msLeft = at - this.deps.now();
|
|
201
|
+
if (msLeft > 30 * 60_000 || msLeft <= 0)
|
|
202
|
+
return;
|
|
203
|
+
this.hardMaxWarned = true;
|
|
204
|
+
const minutes = Math.max(1, Math.round(msLeft / 60_000));
|
|
205
|
+
this.line("");
|
|
206
|
+
this.lines(copy.hardMaxWarning(clockTime(new Date(at)), minutes).map((l) => ` ${l}`));
|
|
207
|
+
}
|
|
208
|
+
// ---- endings --------------------------------------------------------------
|
|
209
|
+
/** The creator's own Ctrl-C. Prints the stopping line, then the ending. */
|
|
210
|
+
stopping() {
|
|
211
|
+
this.line("");
|
|
212
|
+
this.line(copy.stopping(this.roster.size));
|
|
213
|
+
}
|
|
214
|
+
stopped() {
|
|
215
|
+
if (this.ended)
|
|
216
|
+
return;
|
|
217
|
+
this.ended = true;
|
|
218
|
+
this.lines(copy.stopped(this.deps.now() - this.startedAt, this.roster.totalConnections));
|
|
219
|
+
this.line("");
|
|
220
|
+
}
|
|
221
|
+
/** Every ending the creator did NOT cause. One first line per cause. */
|
|
222
|
+
endedBy(reason, orgName) {
|
|
223
|
+
if (this.ended)
|
|
224
|
+
return;
|
|
225
|
+
this.ended = true;
|
|
226
|
+
this.lines(endingLines(reason, orgName));
|
|
227
|
+
}
|
|
228
|
+
// ---- --viewer -------------------------------------------------------------
|
|
229
|
+
following(label) {
|
|
230
|
+
const v = this.roster.get(label);
|
|
231
|
+
this.lines(copy.followingBlock(label, v ? uaLabel(v.uaClass) : ""));
|
|
232
|
+
}
|
|
233
|
+
notFollowing(label) {
|
|
234
|
+
this.line(` ${this.roster.notFollowing(label)}`);
|
|
235
|
+
}
|
|
236
|
+
notFollowingTester(tester) {
|
|
237
|
+
this.line(` ${this.roster.notFollowingTester(tester)}`);
|
|
238
|
+
}
|
|
239
|
+
/** Live testers — what `stopping`/`stopped` should be counting. */
|
|
240
|
+
get testerCount() {
|
|
241
|
+
return this.roster.testerCount;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
export function endingLines(reason, orgName) {
|
|
245
|
+
switch (reason) {
|
|
246
|
+
case "hard_max":
|
|
247
|
+
return copy.hardMaxReached();
|
|
248
|
+
case "superseded":
|
|
249
|
+
return copy.supersededLoser();
|
|
250
|
+
case "grace_exhausted":
|
|
251
|
+
return copy.graceExhausted();
|
|
252
|
+
case "grace_cap":
|
|
253
|
+
return copy.graceCapReached();
|
|
254
|
+
case "abandoned":
|
|
255
|
+
return copy.endedAbandoned();
|
|
256
|
+
case "authz_revoked":
|
|
257
|
+
return copy.endedUnreachable(orgName);
|
|
258
|
+
case "authz_unconfirmed":
|
|
259
|
+
return copy.endedUnconfirmed();
|
|
260
|
+
case "egress_cap":
|
|
261
|
+
return copy.egressHard();
|
|
262
|
+
case "stopped":
|
|
263
|
+
// The LOCAL stop. Reached only when this process held the timer and the
|
|
264
|
+
// counter, so the stats are real. A 410 carrying endReason "stopped"
|
|
265
|
+
// maps to `stopped_elsewhere`, NOT here — see copy.stoppedElsewhere.
|
|
266
|
+
return copy.stopped(0, 0);
|
|
267
|
+
case "stopped_elsewhere":
|
|
268
|
+
return copy.stoppedElsewhere();
|
|
269
|
+
case "disconnected":
|
|
270
|
+
return copy.endedDisconnected();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** `21:04` — what a creator plans a demo around, in their own local time. */
|
|
274
|
+
export function clockTime(d) {
|
|
275
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
276
|
+
}
|