@vincentt-xr/cli 0.1.5 → 0.3.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 (92) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +14 -0
  3. package/dist/cli/args.js +51 -0
  4. package/dist/cli/index.js +416 -100
  5. package/dist/lib/backend.d.ts +44 -20
  6. package/dist/lib/backend.js +98 -51
  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 +85 -19
  12. package/dist/lib/lifecycle.js +133 -130
  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 +52 -25
  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/publish/api.d.ts +67 -0
  51. package/dist/publish/api.js +96 -0
  52. package/dist/publish/bounds.d.ts +25 -0
  53. package/dist/publish/bounds.js +53 -0
  54. package/dist/publish/classify.d.ts +51 -0
  55. package/dist/publish/classify.js +138 -0
  56. package/dist/publish/command.d.ts +27 -0
  57. package/dist/publish/command.js +328 -0
  58. package/dist/publish/consoleVerbs.d.ts +17 -0
  59. package/dist/publish/consoleVerbs.js +26 -0
  60. package/dist/publish/copy.d.ts +180 -0
  61. package/dist/publish/copy.js +606 -0
  62. package/dist/publish/defaults.d.ts +16 -0
  63. package/dist/publish/defaults.js +52 -0
  64. package/dist/publish/manifest.d.ts +27 -0
  65. package/dist/publish/manifest.js +81 -0
  66. package/dist/publish/paths.d.ts +25 -0
  67. package/dist/publish/paths.js +78 -0
  68. package/dist/publish/progress.d.ts +17 -0
  69. package/dist/publish/progress.js +45 -0
  70. package/dist/publish/prompt.d.ts +4 -0
  71. package/dist/publish/prompt.js +31 -0
  72. package/dist/publish/qa.f4.fixtures.d.ts +25 -0
  73. package/dist/publish/qa.f4.fixtures.js +140 -0
  74. package/dist/publish/upload.d.ts +30 -0
  75. package/dist/publish/upload.js +93 -0
  76. package/dist/relay/server.d.ts +32 -0
  77. package/dist/relay/server.js +80 -12
  78. package/dist/relay/store.d.ts +32 -3
  79. package/dist/relay/store.js +76 -22
  80. package/dist/relay/testers.d.ts +90 -0
  81. package/dist/relay/testers.js +144 -0
  82. package/dist/shared/authError.d.ts +1 -0
  83. package/dist/shared/authError.js +11 -0
  84. package/dist/shared/config.d.ts +73 -5
  85. package/dist/shared/config.js +133 -20
  86. package/package.json +2 -3
  87. package/dist/login/login.d.ts +0 -34
  88. package/dist/login/login.js +0 -148
  89. package/dist/preview/cloudflared.d.ts +0 -13
  90. package/dist/preview/cloudflared.js +0 -46
  91. package/dist/preview/tunnel.d.ts +0 -14
  92. package/dist/preview/tunnel.js +0 -28
@@ -1,50 +1,63 @@
1
- // The whole preview stack behind one call. Stands up, in order: the app dev serve
2
- // (on an internal port), the harness relay (WS event sink + localhost /query), a
3
- // front proxy unifying them on one origin (`/__harness` → relay, else → app), and
4
- // a cloudflared tunnel to that origin so a phone gets ONE https URL that serves
5
- // both the app (secure context → live camera) and the diagnostics socket.
1
+ // The whole preview stack behind one call. Stands up, in order: the app dev
2
+ // serve (on an internal port), the harness relay (WS event sink + localhost
3
+ // /query), a front proxy unifying them on one origin (`/__harness` → relay,
4
+ // else → app), and the EDGE TUNNEL CLIENT dialing wss://…/tunnel — so a phone
5
+ // gets ONE https URL that serves both the app (secure context → live camera)
6
+ // and the diagnostics socket.
7
+ //
8
+ // The tunnel is the platform's own edge (D-Self-hosted-edge). The Cloudflare
9
+ // named-tunnel implementation this replaced put a third party in the middle of
10
+ // the creator's console output, which a feature whose central promise is "we
11
+ // relay and retain nothing" cannot do and remain honest.
6
12
  //
7
13
  // Returns the public URL plus a `stop()` that reaps everything. A failure at any
8
14
  // step tears down what was already started, so the caller never leaks a child
9
- // process or a live backend tunnel. Used by both the CLI (`npm run preview`) and
10
- // the MCP verbs (preview_start / preview_stop).
15
+ // process or a live session.
11
16
  import { spawn } from "node:child_process";
12
17
  import { createServer } from "node:http";
18
+ import { TunnelClient, resolveForwardTarget, } from "@vincentt-xr/harness/tunnel";
13
19
  import { startRelay } from "../relay/server.js";
14
- import { ensureCloudflared } from "./cloudflared.js";
15
- import { startSessionTunnel } from "./tunnel.js";
16
20
  import { proxyWeb, proxyWs } from "./proxy.js";
17
21
  import { getFreePort, isPortListening, waitForApp } from "./net.js";
22
+ import { endPreviewSession, heartbeatPreviewSession, mintPreviewSession, workingTreeKey, PreviewEndedError, } from "./api.js";
23
+ import { ProjectUnreachableError } from "../lib/backend.js";
18
24
  export async function startPreview(opts) {
19
- const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 90_000, appReadyTimeoutMs = 8_000, onLog = () => undefined, } = opts;
20
- // Resolve ports. The app port defaults to 5173; the relay + front ports are
21
- // harness-internal, so auto-pick free ones (the relay port is returned for the
22
- // MCP diag_* tools to point at) instead of colliding on fixed defaults.
25
+ const { projectCwd, config, projectId, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", appReadyTimeoutMs = 8_000, onLog = () => undefined, fetchImpl = fetch, } = opts;
23
26
  const appPort = opts.appPort ?? 5173;
24
27
  const relayPort = opts.relayPort ?? (await getFreePort());
25
28
  const frontPort = opts.frontPort ?? (await getFreePort());
26
- // If a dev server is already up on appPort, reuse it — don't spawn a second one
27
- // that would fail to bind and leave the tunnel serving a broken origin.
28
29
  const reuseApp = await isPortListening(appPort);
29
- // Track every resource so any failure below can unwind exactly what started.
30
30
  let app;
31
31
  let relay;
32
32
  let front;
33
- let session;
34
33
  let tunnel;
34
+ let heartbeat;
35
+ let minted;
36
+ let stopped = false;
35
37
  const stop = async () => {
36
- tunnel?.kill();
38
+ if (stopped)
39
+ return;
40
+ stopped = true;
41
+ if (heartbeat !== undefined)
42
+ clearInterval(heartbeat);
43
+ tunnel?.close();
37
44
  app?.kill();
38
45
  front?.close();
39
46
  if (relay)
40
47
  await relay.close();
41
- if (session)
42
- await session.reap();
48
+ // Ending the session is what makes the LINK stop working, so it is not
49
+ // best-effort in intent even though it is in effect: the 12-hour ceiling
50
+ // and the edge's 60-second fail-closed deadline both reap a session whose
51
+ // DELETE never landed.
52
+ if (minted)
53
+ await endPreviewSession(config, minted.sessionId, fetchImpl);
43
54
  };
44
55
  try {
45
- // Resolve cloudflared up front so a fresh host provisions it before we mint a
46
- // tunnel we couldn't otherwise run.
47
- const cloudflaredBin = await ensureCloudflared({ onLog });
56
+ // The MINT comes first. Everything below it costs the creator a dev-server
57
+ // start and a port; if the mint is going to fail — rate limit, revoked
58
+ // membership, no binding — it should fail before any of that happens.
59
+ const key = await workingTreeKey(projectCwd);
60
+ minted = await mintPreviewSession(config, projectId, key, fetchImpl);
48
61
  if (reuseApp) {
49
62
  onLog(`reusing the dev server already on :${appPort}`);
50
63
  }
@@ -62,22 +75,82 @@ export async function startPreview(opts) {
62
75
  path: harnessPath,
63
76
  projectDir: projectCwd,
64
77
  onLog: (m) => onLog(`[relay] ${m}`),
78
+ onEvents: opts.onEvents,
79
+ onCapReached: opts.onCapReached,
80
+ });
81
+ front = createServer((req, res) => {
82
+ const toRelay = req.url?.startsWith(harnessPath) === true;
83
+ // Recovery is signalled by normal output resuming, so the re-arm has to
84
+ // happen on a SUCCESSFUL response rather than on a timer. A relay request
85
+ // is not the creator's app and must not clear an app failure.
86
+ if (!toRelay) {
87
+ res.on("finish", () => {
88
+ if (res.statusCode < 500)
89
+ opts.onOriginRecovered?.();
90
+ });
91
+ }
92
+ proxyWeb(req, res, toRelay ? relayPort : appPort, () => opts.onOriginError?.("", "origin_unreachable"));
65
93
  });
66
- front = createServer((req, res) => proxyWeb(req, res, req.url?.startsWith(harnessPath) ? relayPort : appPort));
67
94
  front.on("upgrade", (req, socket, head) => proxyWs(req, socket, head, req.url?.startsWith(harnessPath) ? relayPort : appPort));
68
95
  await new Promise((resolve) => front.listen(frontPort, resolve));
69
- session = await startSessionTunnel(projectCwd, frontPort);
70
- tunnel = spawn(cloudflaredBin, ["tunnel", "run", "--token", session.runToken], {
71
- stdio: ["ignore", "pipe", "pipe"],
96
+ // ONE configured target, captured HERE from the creator's own config and
97
+ // never from a request (2.15.4 rule 1). The loopback assertion runs at
98
+ // start and is re-checked at every connect.
99
+ const target = await resolveForwardTarget("127.0.0.1", frontPort);
100
+ tunnel = new TunnelClient({
101
+ edgeUrl: minted.edgeUrl,
102
+ grant: minted.grant,
103
+ // The reconnect dial carries the session id as its own header, because
104
+ // that path carries NO GRANT and the grant's signed `sid` claim is what
105
+ // used to name the row (D-The-reconnect-path-carries-no-grant).
106
+ sessionId: minted.sessionId,
107
+ target,
108
+ onViewer: opts.onViewer,
109
+ onViewerGone: opts.onViewerGone,
110
+ onControl: (kind) => {
111
+ const reason = controlToReason(kind);
112
+ if (reason === null) {
113
+ opts.onExpiring?.();
114
+ return;
115
+ }
116
+ opts.onEnded?.(reason);
117
+ },
118
+ onOriginError: opts.onOriginError,
119
+ onStateChange: opts.onTunnelState,
120
+ onTunnelError: (code) => opts.onLog?.(`tunnel dial failed: ${code}`),
121
+ // A REFUSED reconnect is an ending, not noise to retry through. Each
122
+ // status is a different creator action, so they are not collapsed.
123
+ onReconnectRefused: (status) => {
124
+ const reason = refusalToReason(status);
125
+ if (reason !== null)
126
+ opts.onEnded?.(reason);
127
+ },
72
128
  });
73
- await waitForRegister(tunnel, registerTimeoutMs, (line) => onLog(`[cloudflared] ${line}`));
74
- // Don't hand back a live URL that serves a blank page: wait for the app to
75
- // actually respond. Non-fatal — a slow build still returns, flagged not-ready.
129
+ tunnel.start();
130
+ // The heartbeat is keepalive AND re-authorization: every beat re-runs
131
+ // RequireMembership on the API side, so a project transferred out from
132
+ // under a live preview, or a member removed from the org, ends it on the
133
+ // next beat rather than at the 12-hour ceiling.
134
+ heartbeat = setInterval(() => {
135
+ void runHeartbeat(config, minted.sessionId, fetchImpl, opts.onEnded);
136
+ }, minted.heartbeatSec * 1000);
137
+ // Node must not be held alive by the heartbeat alone — the tunnel and the
138
+ // relay are what keep the process up, and a stray interval would outlive a
139
+ // teardown that failed to clear it.
140
+ heartbeat.unref?.();
76
141
  const appReady = reuseApp ? true : await waitForApp(appPort, appReadyTimeoutMs);
77
142
  if (!appReady) {
78
143
  onLog(`app on :${appPort} isn't responding yet — the URL may be blank until it builds`);
79
144
  }
80
- return { url: session.url, relayPort, appPort, tunnelPid: tunnel.pid, appReady, stop };
145
+ return {
146
+ url: minted.url,
147
+ sessionId: minted.sessionId,
148
+ expiresAt: minted.expiresAt,
149
+ relayPort,
150
+ appPort,
151
+ appReady,
152
+ stop,
153
+ };
81
154
  }
82
155
  catch (err) {
83
156
  await stop();
@@ -85,42 +158,120 @@ export async function startPreview(opts) {
85
158
  }
86
159
  }
87
160
  /**
88
- * Resolve once cloudflared reports a live edge connection, reject if it exits
89
- * first or never registers. On failure the rejection carries cloudflared's own
90
- * recent output — otherwise "did not register" hides the real cause (an invalid
91
- * run token, or a network that blocks the QUIC edge, the two common culprits).
92
- * `onLine` streams each log line out live. Exported for unit testing.
161
+ * Two of the three CONTROL kinds are endings. `expiring` is NOT: it is the
162
+ * hard-max warning, and it arrives on the same channel. Mapping it to an ending
163
+ * would take away a link that has half an hour left, at exactly the moment the
164
+ * creator is being warned to plan around it — so it returns null and the caller
165
+ * renders the warning instead.
93
166
  */
94
- export function waitForRegister(tunnel, timeoutMs, onLine) {
95
- return new Promise((resolve, reject) => {
96
- let settled = false;
97
- const recent = [];
98
- const finish = (fn) => {
99
- if (settled)
100
- return;
101
- settled = true;
102
- clearTimeout(timer);
103
- fn();
104
- };
105
- const tail = () => {
106
- const lines = recent.slice(-15).join("\n").trim();
107
- return lines ? `\ncloudflared output:\n${lines}` : "";
108
- };
109
- const scan = (buf) => {
110
- const text = String(buf);
111
- for (const line of text.split(/\r?\n/)) {
112
- if (!line.trim())
113
- continue;
114
- recent.push(line);
115
- onLine?.(line);
116
- }
117
- if (/Registered tunnel connection|Connection [^ ]+ registered/.test(text)) {
118
- finish(resolve);
119
- }
120
- };
121
- tunnel.stdout?.on("data", scan);
122
- tunnel.stderr?.on("data", scan);
123
- tunnel.on("exit", (code) => finish(() => reject(new Error(`cloudflared exited before registering (code ${code}).${tail()}`))));
124
- const timer = setTimeout(() => finish(() => reject(new Error(`cloudflared did not register within ${timeoutMs}ms.${tail()}`))), timeoutMs);
125
- });
167
+ export function controlToReason(kind) {
168
+ if (kind === "superseded")
169
+ return "superseded";
170
+ if (kind === "revoke")
171
+ return "authz_revoked";
172
+ return null;
173
+ }
174
+ /**
175
+ * A refused RECONNECT dial → the ending screen it means.
176
+ *
177
+ * The edge answers a reattach with three distinguishable statuses and they are
178
+ * deliberately not collapsed (`ingress.go:299-310`): the consume is what
179
+ * separates them, and collapsing would cost the CLI its ability to say "another
180
+ * tunnel holds this preview" versus "your preview ended" — different creator
181
+ * actions, and the debugging creator is the common case.
182
+ *
183
+ * A 409 is the grace cap being spent, which is the one ending that explains the
184
+ * bound. A 401 (the token was refused) and a 410 (the session is gone) both mean
185
+ * the link is not coming back, and `grace_exhausted` is that screen.
186
+ *
187
+ * Anything else — 502, 503, a transient edge fault — returns null and keeps the
188
+ * backoff. Ending a preview over a momentarily unavailable edge would take away
189
+ * a link that is about to work again.
190
+ */
191
+ export function refusalToReason(status) {
192
+ if (status === 409)
193
+ return "grace_cap";
194
+ if (status === 401 || status === 410)
195
+ return "grace_exhausted";
196
+ return null;
197
+ }
198
+ /**
199
+ * One heartbeat. A 410 is the session ending on the server's terms; a 404/403
200
+ * is authorization lost. They are DELIBERATELY NOT MERGED, because the remedies
201
+ * differ: one is "find out who you are", the other is "try again".
202
+ *
203
+ * A transport failure is neither — the API being briefly unreachable must not
204
+ * end a healthy preview, because the edge already fails closed on its own
205
+ * 60-second deadline and will drain if the outage is real.
206
+ *
207
+ * THE 410 IS NOT REPORTED AS `hard_max`. It previously was, unconditionally, and
208
+ * that is a FALSE STATEMENT about the product's own behaviour for every terminal
209
+ * cause that is not the ceiling: a session flipped `abandoned` at minute 30, or
210
+ * superseded, or stopped elsewhere, all answer 410, and the hard-max screen is a
211
+ * `✓` telling the creator the preview did exactly what it promised over 12 hours.
212
+ *
213
+ * The API's 410 body carries ONE opaque code (`preview_session_ended`) for every
214
+ * terminal reason — verified against the running stack: an `abandoned` row and an
215
+ * `expired` row return byte-identical bodies — so the CLI CANNOT tell them apart
216
+ * and must not guess. `session_ended` is the honest, cause-free rendering until
217
+ * the wire carries the discriminator. See the note on PreviewEndedError.
218
+ */
219
+ export async function runHeartbeat(config, sessionId, fetchImpl, onEnded) {
220
+ try {
221
+ await heartbeatPreviewSession(config, sessionId, fetchImpl);
222
+ }
223
+ catch (err) {
224
+ if (err instanceof ProjectUnreachableError) {
225
+ onEnded?.("authz_revoked");
226
+ return;
227
+ }
228
+ if (err instanceof PreviewEndedError) {
229
+ onEnded?.(endReasonToPreviewEnd(err.endReason));
230
+ return;
231
+ }
232
+ // A network blip. See above.
233
+ }
234
+ }
235
+ /**
236
+ * The API's `endReason` → the CLI's ending screen.
237
+ *
238
+ * `hard_max` is returned ONLY for an explicit `expired`. An UNLABELLED 410 —
239
+ * which is every 410 the API sends today — maps to `grace_exhausted`, the one
240
+ * ending whose copy states only what is actually known: the preview has ended
241
+ * and the link no longer works. It asserts no cause.
242
+ *
243
+ * That default is the conservative choice and it is deliberate. `hard_max` is a
244
+ * `✓` claiming the preview ran its full 12 hours; rendering it for a session the
245
+ * platform reaped at minute 30 tells the creator something untrue about their own
246
+ * session. `grace_exhausted` is a `✗` that says the link is gone and gives the
247
+ * next command, which is true of EVERY terminal cause. Under-claiming a cause
248
+ * costs the creator one sentence of diagnosis; over-claiming one states a fact
249
+ * only the product can see, and states it wrong.
250
+ *
251
+ * EVERY endReason the domain defines must have a case here. The `default` is for
252
+ * a reason a NEWER api invented, not for one this client forgot: a fall-through
253
+ * renders "your network dropped" for a cause that may be nothing of the kind.
254
+ * That is not hypothetical — `stopped` and `disconnected` fell through for a
255
+ * build, so a creator who stopped their own preview from another terminal was
256
+ * told their connection failed. The set is enumerated from the domain by
257
+ * QA-F3-C9(f), which fails when a new member is added rather than mapping it
258
+ * silently to a wrong screen.
259
+ */
260
+ export function endReasonToPreviewEnd(reason) {
261
+ switch (reason) {
262
+ case "abandoned":
263
+ return "abandoned";
264
+ case "expired":
265
+ return "hard_max";
266
+ case "superseded":
267
+ return "superseded";
268
+ case "revoked":
269
+ return "authz_revoked";
270
+ case "stopped":
271
+ return "stopped_elsewhere";
272
+ case "disconnected":
273
+ return "disconnected";
274
+ default:
275
+ return "grace_exhausted";
276
+ }
126
277
  }
@@ -0,0 +1,52 @@
1
+ import type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "@vincentt-xr/harness/events";
2
+ import type { UAClass } from "@vincentt-xr/harness/tunnel";
3
+ /** One line per viewer per 5s on the terminal. The agent's pull is unthrottled. */
4
+ export declare const PERF_THROTTLE_MS = 5000;
5
+ export declare function uaLabel(c: UAClass): string;
6
+ export declare function uaShort(c: UAClass): string;
7
+ /**
8
+ * Color carries meaning, never identity: a stream where four labels are four
9
+ * colors is a stream where nothing stands out, and it degrades to unreadable in
10
+ * a piped log — which is where an agent reads it. Output must be fully readable
11
+ * with color stripped, so every colorizer here is a pure wrapper the caller can
12
+ * disable wholesale.
13
+ */
14
+ export interface Palette {
15
+ red(s: string): string;
16
+ yellow(s: string): string;
17
+ dim(s: string): string;
18
+ }
19
+ export declare function palette(color: boolean): Palette;
20
+ export declare function formatBytes(n: number): string;
21
+ export declare function formatDuration(ms: number): string;
22
+ /**
23
+ * The record draws `200 1.2 MB 1.9s`. The event type carries no byte count,
24
+ * so the caller supplies one when it has it and the column stays blank rather
25
+ * than showing a zero that would read as an empty response.
26
+ */
27
+ export declare function renderNetworkWithSize(label: string, e: NetworkEvent, bytes: number | undefined, p: Palette): string;
28
+ /**
29
+ * displayPath keeps the path and drops the origin. The origin is the preview's
30
+ * own capability URL on every same-origin request, and printing it on every line
31
+ * would put the capability in the creator's scrollback dozens of times a minute
32
+ * — the same reason 2.18 sets Referrer-Policy: no-referrer.
33
+ */
34
+ export declare function displayPath(url: string): string;
35
+ export declare function renderLog(label: string, e: LogEvent, p: Palette): string;
36
+ export declare function renderTrace(label: string, e: TraceEvent): string;
37
+ /** The dispatcher. Returns null for an event kind the terminal does not print. */
38
+ export declare function renderEvent(label: string, e: DiagEvent, p: Palette): string | null;
39
+ /**
40
+ * A 60 Hz sampler printed raw would bury logs and network under the one signal a
41
+ * creator is least often looking for. This is a per-VIEWER gate, not a global
42
+ * one: four phones each get their own line every 5s, because suppressing the
43
+ * fourth phone's perf because the first phone just reported would hide the exact
44
+ * comparison the creator opened four phones to make.
45
+ */
46
+ export declare class PerfThrottle {
47
+ private readonly windowMs;
48
+ private readonly last;
49
+ constructor(windowMs?: number);
50
+ allow(label: string, now: number): boolean;
51
+ forget(label: string): void;
52
+ }
@@ -0,0 +1,171 @@
1
+ // The live stream's rendering (Flow L15). Pure string functions, no I/O — the
2
+ // terminal is written by the runner, so every line's shape is unit-testable.
3
+ //
4
+ // The design target is not "read four devices", it is "notice the one that is
5
+ // wrong". Every rule below serves that:
6
+ // 1. the label is column one, always, fixed width — the eye scans one column
7
+ // to separate devices, and the shape does NOT change when a second phone
8
+ // arrives;
9
+ // 2. status, size and duration right-align in fixed columns, so a slow asset
10
+ // is found by SHAPE rather than by reading;
11
+ // 3. color carries exactly three things and nothing else;
12
+ // 4. perf is throttled to one line per viewer per 5s.
13
+ /** One line per viewer per 5s on the terminal. The agent's pull is unthrottled. */
14
+ export const PERF_THROTTLE_MS = 5_000;
15
+ /** The colored duration threshold (rule 3). */
16
+ const SLOW_MS = 3_000;
17
+ /** Column one. `[viewer-1] ` — wide enough for `viewer-9999`. */
18
+ const LABEL_COL = 14;
19
+ const STATUS_COL = 5;
20
+ const SIZE_COL = 9;
21
+ const DURATION_COL = 8;
22
+ /** The path column, before the right-aligned trio. */
23
+ const PATH_COL = 40;
24
+ /**
25
+ * The closed enum's six values map to exactly six strings and there is NO
26
+ * SEVENTH. `unknown` renders as the empty string and the caller OMITS the
27
+ * descriptor entirely — a device we cannot classify gets no descriptor rather
28
+ * than the word "unknown", which would read as a fault.
29
+ */
30
+ const UA_LABELS = {
31
+ ios_safari: "iPhone · Safari",
32
+ android_chrome: "Android · Chrome",
33
+ desktop_chrome: "Desktop · Chrome",
34
+ desktop_safari: "Desktop · Safari",
35
+ desktop_firefox: "Desktop · Firefox",
36
+ unknown: "",
37
+ };
38
+ /** The short form used in the roster, where the browser is redundant. */
39
+ const UA_SHORT = {
40
+ ios_safari: "iPhone",
41
+ android_chrome: "Android",
42
+ desktop_chrome: "Desktop",
43
+ desktop_safari: "Desktop",
44
+ desktop_firefox: "Desktop",
45
+ unknown: "",
46
+ };
47
+ export function uaLabel(c) {
48
+ return UA_LABELS[c] ?? "";
49
+ }
50
+ export function uaShort(c) {
51
+ return UA_SHORT[c] ?? "";
52
+ }
53
+ const ANSI = {
54
+ red: (s) => `${s}`,
55
+ yellow: (s) => `${s}`,
56
+ dim: (s) => `${s}`,
57
+ };
58
+ const PLAIN = { red: (s) => s, yellow: (s) => s, dim: (s) => s };
59
+ export function palette(color) {
60
+ return color ? ANSI : PLAIN;
61
+ }
62
+ // ---- formatting primitives --------------------------------------------------
63
+ export function formatBytes(n) {
64
+ if (!Number.isFinite(n) || n < 0)
65
+ return "";
66
+ if (n < 1024)
67
+ return `${n} B`;
68
+ if (n < 1024 * 1024)
69
+ return `${(n / 1024).toFixed(0)} KB`;
70
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
71
+ }
72
+ export function formatDuration(ms) {
73
+ if (!Number.isFinite(ms) || ms < 0)
74
+ return "";
75
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
76
+ }
77
+ function padEnd(s, n) {
78
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
79
+ }
80
+ function padStart(s, n) {
81
+ return s.length >= n ? s : " ".repeat(n - s.length) + s;
82
+ }
83
+ /**
84
+ * The label is column one, ALWAYS — including when `--viewer` has filtered the
85
+ * stream to one device. Dropping it there would produce a transcript that is
86
+ * ambiguous the moment it is pasted into an agent's context, and pasting is the
87
+ * whole point.
88
+ */
89
+ function labelCol(label) {
90
+ return padEnd(`[${label}]`, LABEL_COL);
91
+ }
92
+ // ---- the three line kinds ---------------------------------------------------
93
+ /**
94
+ * The record draws `200 1.2 MB 1.9s`. The event type carries no byte count,
95
+ * so the caller supplies one when it has it and the column stays blank rather
96
+ * than showing a zero that would read as an empty response.
97
+ */
98
+ export function renderNetworkWithSize(label, e, bytes, p) {
99
+ const status = e.status === 0 ? "ERR" : String(e.status);
100
+ const statusCol = e.status === 0 || e.status >= 400
101
+ ? p.red(padStart(status, STATUS_COL))
102
+ : padStart(status, STATUS_COL);
103
+ const duration = formatDuration(e.durationMs);
104
+ const durationCol = e.durationMs >= SLOW_MS
105
+ ? p.red(padStart(duration, DURATION_COL))
106
+ : padStart(duration, DURATION_COL);
107
+ return (labelCol(label) +
108
+ padEnd(`${e.method} ${displayPath(e.url)}`, PATH_COL) +
109
+ statusCol +
110
+ padStart(bytes === undefined ? "" : formatBytes(bytes), SIZE_COL) +
111
+ durationCol);
112
+ }
113
+ /**
114
+ * displayPath keeps the path and drops the origin. The origin is the preview's
115
+ * own capability URL on every same-origin request, and printing it on every line
116
+ * would put the capability in the creator's scrollback dozens of times a minute
117
+ * — the same reason 2.18 sets Referrer-Policy: no-referrer.
118
+ */
119
+ export function displayPath(url) {
120
+ try {
121
+ const u = new URL(url);
122
+ return u.pathname + u.search;
123
+ }
124
+ catch {
125
+ return url;
126
+ }
127
+ }
128
+ export function renderLog(label, e, p) {
129
+ const level = padEnd(e.level, STATUS_COL);
130
+ const colored = e.level === "error" ? p.red(level) : e.level === "warn" ? p.yellow(level) : level;
131
+ return `${labelCol(label)}${colored} ${e.message}`;
132
+ }
133
+ export function renderTrace(label, e) {
134
+ const fps = e.fps.toFixed(0);
135
+ return `${labelCol(label)}${padEnd("perf", STATUS_COL)} ${fps} fps · ${e.longTaskCount} long tasks · ${e.longestTaskMs}ms peak`;
136
+ }
137
+ /** The dispatcher. Returns null for an event kind the terminal does not print. */
138
+ export function renderEvent(label, e, p) {
139
+ if (e.kind === "network")
140
+ return renderNetworkWithSize(label, e, undefined, p);
141
+ if (e.kind === "log")
142
+ return renderLog(label, e, p);
143
+ if (e.kind === "trace")
144
+ return renderTrace(label, e);
145
+ return null;
146
+ }
147
+ // ---- perf throttling --------------------------------------------------------
148
+ /**
149
+ * A 60 Hz sampler printed raw would bury logs and network under the one signal a
150
+ * creator is least often looking for. This is a per-VIEWER gate, not a global
151
+ * one: four phones each get their own line every 5s, because suppressing the
152
+ * fourth phone's perf because the first phone just reported would hide the exact
153
+ * comparison the creator opened four phones to make.
154
+ */
155
+ export class PerfThrottle {
156
+ windowMs;
157
+ last = new Map();
158
+ constructor(windowMs = PERF_THROTTLE_MS) {
159
+ this.windowMs = windowMs;
160
+ }
161
+ allow(label, now) {
162
+ const prev = this.last.get(label);
163
+ if (prev !== undefined && now - prev < this.windowMs)
164
+ return false;
165
+ this.last.set(label, now);
166
+ return true;
167
+ }
168
+ forget(label) {
169
+ this.last.delete(label);
170
+ }
171
+ }