@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.
Files changed (66) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +13 -0
  3. package/dist/cli/args.js +41 -0
  4. package/dist/cli/index.js +362 -92
  5. package/dist/lib/backend.d.ts +45 -5
  6. package/dist/lib/backend.js +99 -18
  7. package/dist/lib/diagnostics.d.ts +27 -2
  8. package/dist/lib/diagnostics.js +73 -8
  9. package/dist/lib/initCommand.d.ts +14 -0
  10. package/dist/lib/initCommand.js +75 -0
  11. package/dist/lib/lifecycle.d.ts +73 -15
  12. package/dist/lib/lifecycle.js +111 -114
  13. package/dist/lib/org.d.ts +29 -0
  14. package/dist/lib/org.js +45 -0
  15. package/dist/lib/projects.d.ts +82 -0
  16. package/dist/lib/projects.js +217 -0
  17. package/dist/lib/relayInfo.d.ts +41 -3
  18. package/dist/lib/relayInfo.js +94 -0
  19. package/dist/lib/whoami.d.ts +11 -0
  20. package/dist/lib/whoami.js +38 -0
  21. package/dist/login/authBackend.d.ts +30 -0
  22. package/dist/login/authBackend.js +80 -0
  23. package/dist/login/guard.d.ts +13 -0
  24. package/dist/login/guard.js +17 -0
  25. package/dist/login/loginCommand.d.ts +39 -0
  26. package/dist/login/loginCommand.js +72 -0
  27. package/dist/login/pkce.d.ts +47 -0
  28. package/dist/login/pkce.js +106 -0
  29. package/dist/login/pkceLogin.d.ts +53 -0
  30. package/dist/login/pkceLogin.js +152 -0
  31. package/dist/mcp/server.js +47 -20
  32. package/dist/preview/api.d.ts +84 -0
  33. package/dist/preview/api.js +174 -0
  34. package/dist/preview/command.d.ts +96 -0
  35. package/dist/preview/command.js +276 -0
  36. package/dist/preview/copy.d.ts +209 -0
  37. package/dist/preview/copy.js +444 -0
  38. package/dist/preview/index.d.ts +2 -3
  39. package/dist/preview/index.js +5 -6
  40. package/dist/preview/proxy.d.ts +12 -1
  41. package/dist/preview/proxy.js +17 -7
  42. package/dist/preview/qr.d.ts +30 -0
  43. package/dist/preview/qr.js +89 -0
  44. package/dist/preview/runner.d.ts +155 -30
  45. package/dist/preview/runner.js +219 -68
  46. package/dist/preview/stream.d.ts +52 -0
  47. package/dist/preview/stream.js +171 -0
  48. package/dist/preview/viewers.d.ts +113 -0
  49. package/dist/preview/viewers.js +242 -0
  50. package/dist/relay/server.d.ts +32 -0
  51. package/dist/relay/server.js +80 -12
  52. package/dist/relay/store.d.ts +32 -3
  53. package/dist/relay/store.js +76 -22
  54. package/dist/relay/testers.d.ts +90 -0
  55. package/dist/relay/testers.js +144 -0
  56. package/dist/shared/authError.d.ts +1 -0
  57. package/dist/shared/authError.js +11 -0
  58. package/dist/shared/config.d.ts +73 -5
  59. package/dist/shared/config.js +133 -20
  60. package/package.json +2 -3
  61. package/dist/login/login.d.ts +0 -34
  62. package/dist/login/login.js +0 -148
  63. package/dist/preview/cloudflared.d.ts +0 -13
  64. package/dist/preview/cloudflared.js +0 -46
  65. package/dist/preview/tunnel.d.ts +0 -14
  66. 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
+ }