@gravitylabsllc/porthole 0.1.0 → 0.2.1

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 (55) hide show
  1. package/README.md +123 -0
  2. package/dist/adb.js +430 -21
  3. package/dist/adb.js.map +1 -1
  4. package/dist/args.js +144 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/capture.js +139 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.js +221 -62
  9. package/dist/cli.js.map +1 -1
  10. package/dist/device.js +337 -4
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.js +2030 -377
  13. package/dist/index.js.map +1 -1
  14. package/dist/moment.js +240 -0
  15. package/dist/moment.js.map +1 -0
  16. package/dist/perfetto.js +826 -0
  17. package/dist/perfetto.js.map +1 -0
  18. package/dist/report.js +68 -7
  19. package/dist/report.js.map +1 -1
  20. package/dist/save.js +252 -0
  21. package/dist/save.js.map +1 -0
  22. package/dist/sessions.js +704 -0
  23. package/dist/sessions.js.map +1 -0
  24. package/dist/system.js +169 -0
  25. package/dist/system.js.map +1 -0
  26. package/dist/systrace.js +198 -0
  27. package/dist/systrace.js.map +1 -0
  28. package/dist/timeline.js +731 -29
  29. package/dist/timeline.js.map +1 -1
  30. package/dist/trace.js +317 -27
  31. package/dist/trace.js.map +1 -1
  32. package/dist/watermark.js +220 -0
  33. package/dist/watermark.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/adb.ts +583 -0
  36. package/src/args.ts +177 -0
  37. package/src/capture.ts +292 -0
  38. package/src/cli.ts +367 -0
  39. package/src/device.ts +635 -0
  40. package/src/index.ts +2545 -0
  41. package/src/moment.ts +306 -0
  42. package/src/perfetto.ts +972 -0
  43. package/src/report.ts +285 -0
  44. package/src/save.ts +322 -0
  45. package/src/sessions.ts +894 -0
  46. package/src/system.ts +221 -0
  47. package/src/systrace.ts +258 -0
  48. package/src/timeline.ts +1036 -0
  49. package/src/trace.ts +769 -0
  50. package/src/watermark.ts +337 -0
  51. package/ui/dist/assets/index-DtnyBXCM.css +1 -0
  52. package/ui/dist/assets/index-h7VNB9Fl.js +70 -0
  53. package/ui/dist/index.html +2 -2
  54. package/ui/dist/assets/index--1mlZuNZ.css +0 -1
  55. package/ui/dist/assets/index-BeVGHRFm.js +0 -68
package/src/cli.ts ADDED
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+ // Copyright 2026 Gravity Labs
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { spawn } from "node:child_process";
5
+ import { DeviceClient, type ConnectionState } from "./device.js";
6
+ import { TimelineServer, type PortInUse } from "./timeline.js";
7
+ import { capture, compare, parseCapture, report } from "./capture.js";
8
+ import { resolveProjectRoot, runAdb } from "./adb.js";
9
+ import { bootPortholeServer } from "./index.js";
10
+ import { parseDuration, parseMillis, parsePort, requiredValue } from "./args.js";
11
+ import { sessionsRoot } from "./sessions.js";
12
+ import { listSessionsText, saveFromSessions, type SaveFromSessionsOptions } from "./save.js";
13
+
14
+ /**
15
+ * The human entry point.
16
+ *
17
+ * The MCP server exists for an agent and talks stdio; this exists for a person
18
+ * and talks to a browser. Opening the timeline should not require asking an
19
+ * agent to call a tool on your behalf.
20
+ */
21
+
22
+ interface Options {
23
+ port: number;
24
+ uiPort: number;
25
+ serial?: string;
26
+ forward: boolean;
27
+ open: boolean;
28
+ }
29
+
30
+ const USAGE = `
31
+ porthole — a window into a running Android app
32
+
33
+ porthole ui open the live timeline
34
+ porthole capture --scenario <name> -- <command> record a run to a trace
35
+ porthole save (--since <dur> | --from <ms> --to <ms>) save what already happened
36
+ porthole sessions list what is recorded on disk
37
+ porthole report <trace.json> what the run is worth looking at
38
+ porthole compare <base> <trace> regressions against a baseline
39
+ porthole mcp the MCP server (stdio)
40
+
41
+ porthole ui — open the live timeline for a running debug build
42
+
43
+ npx @gravitylabsllc/porthole ui [options]
44
+
45
+ --port <n> device port the porthole is listening on (default 8677)
46
+ --ui-port <n> port to serve the timeline on (default 8678)
47
+ --serial <id> adb device serial, when more than one is attached
48
+ --no-forward skip 'adb forward'; use it if you set the bridge up yourself
49
+ --no-open do not launch a browser, just print the URL
50
+
51
+ Needs a device or emulator with the debug build running: the porthole lives inside
52
+ the app process, and adb forward is what makes its socket reachable from here.
53
+ `;
54
+
55
+ /**
56
+ * Exported so a test can drive the argv loop itself, not just the pure
57
+ * validators it calls. A test that only calls `parsePort` directly cannot
58
+ * tell the difference between this loop checking its result and ignoring it —
59
+ * deleting the `process.exit(2)` branches below left every prior test green.
60
+ */
61
+ export function parse(argv: string[]): Options {
62
+ const options: Options = {
63
+ port: 8677,
64
+ uiPort: 8678,
65
+ forward: true,
66
+ open: true,
67
+ };
68
+ for (let i = 0; i < argv.length; i++) {
69
+ const arg = argv[i];
70
+ if (arg === "--port") {
71
+ const value = parsePort(argv[++i], "--port");
72
+ if (typeof value !== "number") {
73
+ process.stderr.write(`${value.message}\n`);
74
+ process.exit(2);
75
+ }
76
+ options.port = value;
77
+ } else if (arg === "--ui-port") {
78
+ const value = parsePort(argv[++i], "--ui-port");
79
+ if (typeof value !== "number") {
80
+ process.stderr.write(`${value.message}\n`);
81
+ process.exit(2);
82
+ }
83
+ options.uiPort = value;
84
+ } else if (arg === "--serial") {
85
+ // Previously `argv[++i]` raw: a missing value was consumed silently, and
86
+ // `--serial --port 8677` swallowed "--port" as the serial and left
87
+ // "8677" to be rejected next as a nonsense option — blaming the wrong
88
+ // token for the actual mistake. requiredValue names --serial instead.
89
+ const value = requiredValue(argv[++i], "--serial");
90
+ if (typeof value !== "string") {
91
+ process.stderr.write(`${value.message}\n`);
92
+ process.exit(2);
93
+ }
94
+ options.serial = value;
95
+ } else if (arg === "--no-forward") options.forward = false;
96
+ else if (arg === "--no-open") options.open = false;
97
+ else if (arg === "--help" || arg === "-h") {
98
+ process.stdout.write(USAGE);
99
+ process.exit(0);
100
+ } else {
101
+ process.stderr.write(`unknown option: ${arg}\n${USAGE}`);
102
+ process.exit(2);
103
+ }
104
+ }
105
+ return options;
106
+ }
107
+
108
+ export const SAVE_USAGE = `
109
+ porthole save — turn a window of what already happened into a trace file
110
+
111
+ porthole save (--since <duration> | --from <ms> --to <ms>) [options]
112
+
113
+ --since <duration> how far back to look: 10m, 90s, 2h, or a millisecond count
114
+ --from <ms> absolute start, device uptime clock (quote a finding's window)
115
+ --to <ms> absolute end, same clock
116
+ --scenario <name> what to call it. Defaults to moment-<from>-<to>
117
+ --out <file> where to write the trace. Defaults to .porthole/traces/<scenario>.json
118
+
119
+ Resolves against whichever session on disk was most recently written to —
120
+ there is no running MCP server here to ask "what counts as now" of.
121
+
122
+ porthole sessions list every session recorded on disk
123
+ `;
124
+
125
+ export interface SaveCliOptions {
126
+ scenario?: string;
127
+ sinceMs?: number;
128
+ from?: number;
129
+ to?: number;
130
+ out?: string;
131
+ }
132
+
133
+ /**
134
+ * GRA-54. Same discipline as `parseCapture`/`parse()` above: every value
135
+ * goes through a validator that names the option and refuses rather than
136
+ * silently accepting `NaN` or swallowing the next flag as its own value.
137
+ * Exported, like `parse()`, so a test can drive the argv loop itself and not
138
+ * just the pure validators it calls (deleting the `process.exit(2)`
139
+ * branches below would otherwise leave every prior test green).
140
+ */
141
+ export function parseSave(argv: string[]): SaveCliOptions {
142
+ const options: SaveCliOptions = {};
143
+ for (let i = 0; i < argv.length; i++) {
144
+ const arg = argv[i];
145
+ if (arg === "--scenario") {
146
+ const value = requiredValue(argv[++i], "--scenario");
147
+ if (typeof value !== "string") {
148
+ process.stderr.write(`${value.message}\n`);
149
+ process.exit(2);
150
+ }
151
+ options.scenario = value;
152
+ } else if (arg === "--since") {
153
+ const value = parseDuration(argv[++i], "--since");
154
+ if (typeof value !== "number") {
155
+ process.stderr.write(`${value.message}\n`);
156
+ process.exit(2);
157
+ }
158
+ options.sinceMs = value;
159
+ } else if (arg === "--from") {
160
+ const value = parseMillis(argv[++i], "--from");
161
+ if (typeof value !== "number") {
162
+ process.stderr.write(`${value.message}\n`);
163
+ process.exit(2);
164
+ }
165
+ options.from = value;
166
+ } else if (arg === "--to") {
167
+ const value = parseMillis(argv[++i], "--to");
168
+ if (typeof value !== "number") {
169
+ process.stderr.write(`${value.message}\n`);
170
+ process.exit(2);
171
+ }
172
+ options.to = value;
173
+ } else if (arg === "--out") {
174
+ const value = requiredValue(argv[++i], "--out");
175
+ if (typeof value !== "string") {
176
+ process.stderr.write(`${value.message}\n`);
177
+ process.exit(2);
178
+ }
179
+ options.out = value;
180
+ } else if (arg === "--help" || arg === "-h") {
181
+ process.stdout.write(SAVE_USAGE);
182
+ process.exit(0);
183
+ } else {
184
+ process.stderr.write(`unknown option: ${arg}\n${SAVE_USAGE}`);
185
+ process.exit(2);
186
+ }
187
+ }
188
+
189
+ const hasSince = options.sinceMs !== undefined;
190
+ const hasFrom = options.from !== undefined;
191
+ const hasTo = options.to !== undefined;
192
+ if (hasSince && (hasFrom || hasTo)) {
193
+ process.stderr.write(`--since cannot be combined with --from/--to\n${SAVE_USAGE}`);
194
+ process.exit(2);
195
+ }
196
+ if (hasFrom !== hasTo) {
197
+ process.stderr.write(`--from and --to must be given together\n${SAVE_USAGE}`);
198
+ process.exit(2);
199
+ }
200
+ if (!hasSince && !hasFrom) {
201
+ process.stderr.write(`give either --since <duration> or both --from and --to\n${SAVE_USAGE}`);
202
+ process.exit(2);
203
+ }
204
+ return options;
205
+ }
206
+
207
+ function openBrowser(url: string): void {
208
+ const [command, args] =
209
+ process.platform === "win32"
210
+ ? ["cmd", ["/c", "start", "", url]]
211
+ : process.platform === "darwin"
212
+ ? ["open", [url]]
213
+ : ["xdg-open", [url]];
214
+ try {
215
+ spawn(command, args, { detached: true, stdio: "ignore" }).unref();
216
+ } catch {
217
+ // Printing the URL is the fallback, and it is already printed.
218
+ }
219
+ }
220
+
221
+ async function ui(argv: string[]): Promise<void> {
222
+ const options = parse(argv);
223
+ if (options.forward) {
224
+ // Same call `porthole capture` makes, through the same runAdb: this used to
225
+ // be a second copy of adb discovery and a second copy of the advice to pass
226
+ // --serial, and the copy here was the one that could not find the SDK.
227
+ const forwarded = runAdb(
228
+ ["forward", `tcp:${options.port}`, `tcp:${options.port}`],
229
+ options.serial,
230
+ );
231
+ if (forwarded.ok) {
232
+ console.error(`forwarded 127.0.0.1:${options.port} to the device`);
233
+ } else {
234
+ console.error(forwarded.output);
235
+ console.error("Pass --no-forward if the bridge is already up.");
236
+ }
237
+ }
238
+
239
+ // GRA-53: the same sessions root `createPortholeServer` uses (index.ts),
240
+ // so a session started here — `porthole ui` is a real entry point someone
241
+ // launches directly, not only through an agent's MCP server — persists to
242
+ // disk the same as any other. Without this, `porthole ui` silently wrote
243
+ // to nothing: `DeviceClient`'s sessions root defaults to disabled when
244
+ // omitted, and nobody watching a browser tab would notice a feature that
245
+ // fails silent.
246
+ const device = new DeviceClient("127.0.0.1", options.port, sessionsRoot(resolveProjectRoot().directory));
247
+ const timeline = new TimelineServer(device, options.uiPort, options.serial);
248
+ device.start();
249
+
250
+ let url: string;
251
+ try {
252
+ url = await timeline.start();
253
+ } catch (error) {
254
+ const problem = error as PortInUse;
255
+ console.error(problem.message);
256
+ // An instance already serving this device is not a failure — it is the
257
+ // thing that was asked for. Point at it and stop.
258
+ if (problem.portholeAlreadyRunning) {
259
+ if (options.open) openBrowser(problem.url);
260
+ process.exit(0);
261
+ }
262
+ console.error(`Pass --ui-port to use a port other than ${options.uiPort}.`);
263
+ process.exit(1);
264
+ }
265
+
266
+ console.error(`timeline at ${url}`);
267
+ if (options.open) openBrowser(url);
268
+
269
+ device.on("state", (state: ConnectionState) => {
270
+ // GRA-162: was an if/else-if chain with no final else, so "connecting"
271
+ // printed nothing — silently correct, but silently, and a fifth state
272
+ // would have joined it there without tsc ever noticing. A switch with
273
+ // an explicit (still silent) "connecting" case and a never-guarded
274
+ // default gives that same behaviour a name and makes the next state
275
+ // addition fail here instead of joining "connecting" by accident. Same
276
+ // precedent as pendingMessage() in device.ts.
277
+ switch (state) {
278
+ case "handshaking":
279
+ // GRA-157: this fires exactly when the socket comes up, which is the
280
+ // real event the 2-second setTimeout below used to guess at. Printing
281
+ // here instead means the CLI says something true immediately on a
282
+ // slow device and does not need a fixed wait on a fast one — the
283
+ // opposite of what a timer can do.
284
+ console.error("connected, waiting on the app's first check-in...");
285
+ break;
286
+ case "connected": {
287
+ // hello is guaranteed non-null here — DeviceClient does not enter
288
+ // "connected" until it is (see device.ts's setState()) — so this no
289
+ // longer hedges with a ternary the way it had to before that was true.
290
+ const hello = device.hello as NonNullable<typeof device.hello>;
291
+ console.error(`connected to ${hello.packageName} on ${hello.device}`);
292
+ break;
293
+ }
294
+ case "disconnected":
295
+ // Expected constantly during development: the app gets reinstalled and
296
+ // relaunched, and the client reconnects on its own.
297
+ console.error("waiting for the app...");
298
+ break;
299
+ case "connecting":
300
+ // No behaviour change (GRA-162 AC3): the original chain had no
301
+ // branch for "connecting" either, so this stays deliberately silent.
302
+ break;
303
+ default: {
304
+ const exhaustive: never = state;
305
+ throw new Error(`porthole ui: unhandled ConnectionState '${exhaustive as string}'`);
306
+ }
307
+ }
308
+ });
309
+
310
+ const shutdown = () => {
311
+ device.stop();
312
+ timeline.stop();
313
+ process.exit(0);
314
+ };
315
+ process.on("SIGINT", shutdown);
316
+ process.on("SIGTERM", shutdown);
317
+ }
318
+
319
+ const [command, ...rest] = process.argv.slice(2);
320
+
321
+ if (command === "ui") {
322
+ await ui(rest);
323
+ } else if (command === "mcp") {
324
+ // Calls the same boot function `node dist/index.js` uses under its own
325
+ // `isMainModule()` guard, rather than `import("./index.js")`ing for the
326
+ // side effect. That side-effect import used to be how this worked, but it
327
+ // silently stopped booting anything once the boot moved behind the guard:
328
+ // `argv[1]` here is `cli.js`, so the guard (correctly) never fires for us.
329
+ // One boot path, two callers.
330
+ await bootPortholeServer();
331
+ } else if (command === "capture") {
332
+ const options = parseCapture(rest);
333
+ if (options.forward) {
334
+ const forwarded = runAdb(
335
+ ["forward", `tcp:${options.port}`, `tcp:${options.port}`],
336
+ options.serial,
337
+ );
338
+ if (!forwarded.ok) console.error(forwarded.output);
339
+ }
340
+ process.exit(await capture(options));
341
+ } else if (command === "save") {
342
+ const options = parseSave(rest);
343
+ const projectRoot = resolveProjectRoot().directory;
344
+ const saveOptions: SaveFromSessionsOptions = { root: sessionsRoot(projectRoot), projectRoot, ...options };
345
+ const result = await saveFromSessions(saveOptions);
346
+ process.stderr.write(`${result.message}\n`);
347
+ process.exit(result.code);
348
+ } else if (command === "sessions") {
349
+ const result = await listSessionsText(sessionsRoot(resolveProjectRoot().directory));
350
+ process.stdout.write(`${result.message}\n`);
351
+ process.exit(result.code);
352
+ } else if (command === "report") {
353
+ if (!rest[0]) {
354
+ process.stderr.write("porthole report <trace.json>\n");
355
+ process.exit(2);
356
+ }
357
+ process.exit(await report(rest[0]));
358
+ } else if (command === "compare") {
359
+ if (!rest[0] || !rest[1]) {
360
+ process.stderr.write("porthole compare <baseline.json> <trace.json>\n");
361
+ process.exit(2);
362
+ }
363
+ process.exit(await compare(rest[0], rest[1]));
364
+ } else {
365
+ process.stdout.write(USAGE);
366
+ process.exit(command === undefined || command === "--help" || command === "-h" ? 0 : 2);
367
+ }