rpc-agent 0.3.1 → 0.5.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/dist/options.js CHANGED
@@ -24,6 +24,20 @@ export function defaultShotOptions(url) {
24
24
  sliceWidth: DEFAULT_SLICE_WIDTH,
25
25
  };
26
26
  }
27
+ export const FAIL_ON = ['error', 'warn'];
28
+ export function defaultCheckOptions(url) {
29
+ return {
30
+ url: normalizeUrl(url),
31
+ devices: [...DEFAULT_DEVICES],
32
+ outDir: DEFAULT_OUT_DIR,
33
+ waitMs: 0,
34
+ timeoutMs: DEFAULT_TIMEOUT_MS,
35
+ emulate: true,
36
+ reducedMotion: false,
37
+ shots: false,
38
+ failOn: 'error',
39
+ };
40
+ }
27
41
  /**
28
42
  * Accept what an agent is likely to type:
29
43
  * http(s)://…, file://… -> unchanged
package/dist/png.js CHANGED
@@ -8,6 +8,53 @@ export function pngSize(buf) {
8
8
  const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
9
9
  return { width: view.getUint32(16), height: view.getUint32(20) };
10
10
  }
11
+ /** Width/height of a PNG, JPEG, GIF or WebP from its header; undefined for anything else. */
12
+ export function imageSize(buf) {
13
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
14
+ const ascii = (at, len) => String.fromCharCode(...buf.subarray(at, at + len));
15
+ try {
16
+ if (buf.length >= 24 && SIGNATURE.every((b, i) => buf[i] === b))
17
+ return pngSize(buf);
18
+ if (ascii(0, 4) === 'GIF8' && buf.length >= 10) {
19
+ return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
20
+ }
21
+ if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
22
+ const kind = ascii(12, 4);
23
+ if (kind === 'VP8 ')
24
+ return { width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff };
25
+ if (kind === 'VP8L') {
26
+ const bits = view.getUint32(21, true);
27
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
28
+ }
29
+ if (kind === 'VP8X') {
30
+ const u24 = (at) => buf[at] | (buf[at + 1] << 8) | (buf[at + 2] << 16);
31
+ return { width: u24(24) + 1, height: u24(27) + 1 };
32
+ }
33
+ return undefined;
34
+ }
35
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
36
+ // Walk the JPEG segments to the first start-of-frame marker.
37
+ for (let at = 2; at + 9 < buf.length;) {
38
+ if (buf[at] !== 0xff)
39
+ return undefined;
40
+ const marker = buf[at + 1];
41
+ if (marker === 0xff) {
42
+ at++;
43
+ continue;
44
+ }
45
+ const len = view.getUint16(at + 2);
46
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
47
+ return { width: view.getUint16(at + 7), height: view.getUint16(at + 5) };
48
+ }
49
+ at += 2 + len;
50
+ }
51
+ }
52
+ }
53
+ catch {
54
+ // Truncated header.
55
+ }
56
+ return undefined;
57
+ }
11
58
  /**
12
59
  * Decode an 8-bit RGB/RGBA non-interlaced PNG, which is what Chromium
13
60
  * screenshots are. Anything else throws: this is not a general decoder.
package/dist/term.js CHANGED
@@ -44,3 +44,23 @@ export function openPath(path) {
44
44
  return false;
45
45
  }
46
46
  }
47
+ /**
48
+ * Resolve when a line (Enter) arrives on stdin. EOF never resolves it, so a
49
+ * caller can race it against something else (the login window closing).
50
+ */
51
+ export function waitForEnter(input = process.stdin) {
52
+ let onData = () => { };
53
+ const promise = new Promise(res => {
54
+ onData = chunk => {
55
+ if (/[\r\n]/.test(String(chunk)))
56
+ res();
57
+ };
58
+ input.on('data', onData);
59
+ input.resume();
60
+ });
61
+ const stop = () => {
62
+ input.off('data', onData);
63
+ input.pause();
64
+ };
65
+ return { promise, stop };
66
+ }
package/dist/ui.js CHANGED
@@ -6,11 +6,14 @@ import { connect } from 'node:net';
6
6
  import { relative } from 'node:path';
7
7
  import * as p from '@clack/prompts';
8
8
  import pc from 'picocolors';
9
+ import { clearSessions, listSessions, localTime, MASK, resolveAuth, sessionsDir, } from './auth.js';
9
10
  import { BrowserMissingError, installHint, launchBrowser } from './browser.js';
10
11
  import { capture } from './capture.js';
12
+ import { checkExitCode, runCheck as audit, summaryLine } from './check.js';
13
+ import { countsPhrase, groupFindings, groupLines } from './check-format.js';
11
14
  import { CATEGORY_ORDER, DEVICE_ALIASES, DEVICES, } from './devices.js';
12
15
  import { defaultShotOptions, normalizeUrl } from './options.js';
13
- import { openCommand, openPath } from './term.js';
16
+ import { openCommand, openPath, waitForEnter } from './term.js';
14
17
  export function colors(enabled) {
15
18
  return pc.createColors(enabled);
16
19
  }
@@ -174,7 +177,7 @@ export function printDevices(c) {
174
177
  }
175
178
  // ------------------------------------------------------------------ shot
176
179
  /** Lines for the "Capture Summary" box. */
177
- export function summaryLines(o, c) {
180
+ export function summaryLines(o, c, auth) {
178
181
  const yes = (b) => (b ? c.green('yes') : c.dim('no'));
179
182
  const rows = [
180
183
  ['devices', formatList(o.devices.map(d => c.cyan(d)))],
@@ -188,10 +191,34 @@ export function summaryLines(o, c) {
188
191
  rows.push(['wait for', o.waitFor]);
189
192
  if (!o.emulate)
190
193
  rows.push(['emulation', c.yellow('viewport + DPR only')]);
194
+ if (auth)
195
+ rows.push(...authRows(auth, c));
191
196
  rows.push(['output', `${rel(o.outDir)}/`]);
192
197
  const w = Math.max(...rows.map(([k]) => k.length)) + 1;
193
198
  return [c.cyan(o.url), ...rows.map(([k, v]) => ` ${c.dim(`${k}:`.padEnd(w))} ${v}`)];
194
199
  }
200
+ /** Auth rows for the summary box: names only, values masked. */
201
+ export function authRows(a, c) {
202
+ const rows = [];
203
+ if (a.session) {
204
+ const saved = a.session.savedAt ? c.dim(` saved ${localTime(a.session.savedAt)}`) : '';
205
+ if (a.session.loaded)
206
+ rows.push(['session', `${c.green(a.session.origin)}${saved}`]);
207
+ else if (a.session.expired)
208
+ rows.push(['session', c.yellow(`${a.session.origin} expired, not loaded`)]);
209
+ else
210
+ rows.push(['session', c.dim(`${a.session.origin} skipped (--no-session)`)]);
211
+ }
212
+ if (a.storageState)
213
+ rows.push(['storage state', rel(a.storageState)]);
214
+ if (a.cookies?.length)
215
+ rows.push(['cookies', formatList(a.cookies.map(x => `${x.name}=${c.dim(MASK)}`))]);
216
+ if (a.headers)
217
+ rows.push(['headers', formatList(Object.keys(a.headers).map(h => `${h}: ${c.dim(MASK)}`))]);
218
+ if (a.allowOrigins?.length)
219
+ rows.push(['also allowed', formatList(a.allowOrigins)]);
220
+ return rows;
221
+ }
195
222
  function statusCell(s, c) {
196
223
  if (s.error)
197
224
  return c.red(s.code ?? 'error');
@@ -279,10 +306,11 @@ export function capturedLines(result, c) {
279
306
  return out;
280
307
  }
281
308
  export async function runShot(options, run, c) {
309
+ const auth = resolveAuth(options.url, options.auth ?? {});
282
310
  if (!run.continued) {
283
311
  printBanner(c);
284
312
  intro(c);
285
- p.note(summaryLines(options, c).join('\n'), 'Capture Summary');
313
+ p.note(summaryLines(options, c, auth.summary).join('\n'), 'Capture Summary');
286
314
  }
287
315
  const s = p.spinner();
288
316
  s.start('Starting Chromium');
@@ -313,6 +341,7 @@ export async function runShot(options, run, c) {
313
341
  try {
314
342
  result = await capture(options, {
315
343
  browser,
344
+ auth,
316
345
  onDeviceStart: (d) => {
317
346
  started = performance.now();
318
347
  current = p.spinner();
@@ -362,6 +391,110 @@ export async function runShot(options, run, c) {
362
391
  p.outro(`${c.green('Done!')} ${c.dim(advice)}`);
363
392
  return failed > 0 ? 1 : 0;
364
393
  }
394
+ // ------------------------------------------------------------------ check
395
+ /** Lines for the "Check Summary" box. */
396
+ export function checkSummaryLines(o, c, auth) {
397
+ const rows = [
398
+ ['devices', formatList(o.devices.map(d => c.cyan(d)))],
399
+ ['fail on', o.failOn === 'warn' ? c.yellow('errors and warnings') : 'errors'],
400
+ ];
401
+ if (o.colorScheme)
402
+ rows.push(['colour scheme', o.colorScheme]);
403
+ if (o.waitFor)
404
+ rows.push(['wait for', o.waitFor]);
405
+ if (auth)
406
+ rows.push(...authRows(auth, c));
407
+ if (o.shots)
408
+ rows.push(['shots', `${rel(o.outDir)}/`]);
409
+ const w = Math.max(...rows.map(([k]) => k.length)) + 1;
410
+ return [c.cyan(o.url), ...rows.map(([k, v]) => ` ${c.dim(`${k}:`.padEnd(w))} ${v}`)];
411
+ }
412
+ /** The spinner line once a device is done: status, CLS, time. */
413
+ function checkDeviceLine(d, ms, label, c) {
414
+ if (d.error)
415
+ return `${label} ${c.red(d.code ?? 'error')} ${c.dim(formatMs(ms))}`;
416
+ const status = d.status == null ? c.dim('--') : d.status >= 400 ? c.red(String(d.status)) : c.green(String(d.status));
417
+ const cls = d.cls === undefined ? '' : d.cls > 0.25 ? c.red(d.cls.toFixed(3)) : d.cls > 0.1 ? c.yellow(d.cls.toFixed(3)) : c.green(d.cls.toFixed(3));
418
+ return `${label} ${status} ${c.dim('CLS')} ${cls} ${c.dim(formatMs(ms))}`;
419
+ }
420
+ /** One box per check that found something, titled with its counts. */
421
+ export function checkBoxes(report, c) {
422
+ const all = report.devices.map(d => d.device);
423
+ return groupFindings(report).map(g => {
424
+ const counts = countsPhrase(g.findings);
425
+ const tint = g.findings.some(f => f.severity === 'error') ? c.red : g.findings.some(f => f.severity === 'warn') ? c.yellow : c.cyan;
426
+ return { title: `${g.title} ${c.dim('·')} ${tint(counts)}`, lines: groupLines(g, all, c, { icons: true, width: textWidth() }) };
427
+ });
428
+ }
429
+ export async function runCheck(options, c) {
430
+ const auth = resolveAuth(options.url, options.auth ?? {});
431
+ intro(c);
432
+ p.note(checkSummaryLines(options, c, auth.summary).join('\n'), 'Check Summary');
433
+ const s = p.spinner();
434
+ s.start('Starting Chromium');
435
+ let browser;
436
+ try {
437
+ browser = await launchBrowser();
438
+ }
439
+ catch (err) {
440
+ if (err instanceof BrowserMissingError) {
441
+ s.error(c.red("Playwright's Chromium is not installed"));
442
+ p.note(wrap(installHint().replace(/^Fix: /, ''), textWidth()).join('\n'), c.yellow('Fix'));
443
+ p.outro(`${c.red('Nothing checked.')} ${c.dim('Run: rpc install')}`);
444
+ return 1;
445
+ }
446
+ s.error(c.red(`Chromium did not start: ${err.message.split('\n')[0]}`));
447
+ p.outro(c.red('Nothing checked.'));
448
+ return 1;
449
+ }
450
+ s.stop(`Chromium ${c.dim(browser.version())}`);
451
+ let current;
452
+ let started = 0;
453
+ let report;
454
+ const picked = options.devices.map(id => DEVICES.find(d => d.id === id)).filter((d) => Boolean(d));
455
+ const idW = Math.max(0, ...picked.map(d => d.id.length));
456
+ const vpW = Math.max(0, ...picked.map(d => viewport(d).length));
457
+ const label = (id, vp) => `${id.padEnd(idW)} ${c.dim(vp.padEnd(vpW))}`;
458
+ try {
459
+ report = await audit(options, {
460
+ browser,
461
+ auth,
462
+ onDeviceStart: (d) => {
463
+ started = performance.now();
464
+ current = p.spinner();
465
+ current.start(label(d.id, viewport(d)));
466
+ },
467
+ onDevice: d => {
468
+ const line = checkDeviceLine(d, performance.now() - started, label(d.device, viewport({ ...d.viewport, dpr: d.dpr })), c);
469
+ if (d.error)
470
+ current?.error(line);
471
+ else
472
+ current?.stop(line);
473
+ current = undefined;
474
+ },
475
+ });
476
+ }
477
+ catch (err) {
478
+ current?.error(c.red(err.message));
479
+ p.outro(c.red('Check failed.'));
480
+ return 1;
481
+ }
482
+ finally {
483
+ await browser.close().catch(() => { });
484
+ }
485
+ for (const box of checkBoxes(report, c))
486
+ p.note(box.lines.join('\n'), box.title);
487
+ const code = checkExitCode(report, options.failOn);
488
+ const line = summaryLine(report.summary);
489
+ const sum = report.summary;
490
+ if (sum.errors)
491
+ p.outro(`${c.red(line)} ${c.dim('Exit code 1.')}`);
492
+ else if (sum.warnings)
493
+ p.outro(`${c.yellow(line)} ${c.dim(code ? 'Exit code 1 (--fail-on warn).' : 'No errors.')}`);
494
+ else
495
+ p.outro(`${c.green(line)} ${c.dim('Ready to ship.')}`);
496
+ return code;
497
+ }
365
498
  // ------------------------------------------------------------------ interactive
366
499
  const PRESELECT = ['iphone-15-pro', 'ipad-air-11', 'desktop-1440'];
367
500
  const DEV_PORTS = [3000, 5173, 4321, 8080];
@@ -466,6 +599,90 @@ export async function runInteractive(c) {
466
599
  }
467
600
  return runShot(options, { open: false, continued: true, showCommand: true }, c);
468
601
  }
602
+ // ------------------------------------------------------------------ login
603
+ export async function runLogin(cmd, c) {
604
+ intro(c);
605
+ const { formLogin, interactiveLogin, HeadedBrowserMissingError } = await import('./login.js');
606
+ const s = p.spinner();
607
+ let spinning = false;
608
+ try {
609
+ let result;
610
+ if (cmd.form) {
611
+ s.start(`Signing in at ${c.cyan(cmd.url)} ${c.dim('(headless, form)')}`);
612
+ spinning = true;
613
+ result = await formLogin(cmd, cmd.form);
614
+ spinning = false;
615
+ s.stop(`Signed in ${c.dim(`now at ${result.finalUrl}`)}`);
616
+ }
617
+ else {
618
+ s.start('Opening Chromium');
619
+ spinning = true;
620
+ const enter = waitForEnter();
621
+ try {
622
+ result = await interactiveLogin(cmd, enter.promise, () => {
623
+ spinning = false;
624
+ s.stop(`Opened ${c.cyan(cmd.url)}`);
625
+ p.log.info(`Sign in in the browser window, then press ${c.bold('Enter')} here ${c.dim('(or close the window)')}`);
626
+ });
627
+ }
628
+ finally {
629
+ enter.stop();
630
+ }
631
+ }
632
+ if (result.stillOnLogin) {
633
+ p.log.warn(c.yellow(`The page was still on a login URL (${result.finalUrl}). The sign-in may not have finished.`));
634
+ }
635
+ p.note([
636
+ `${c.dim('origin:')} ${c.cyan(result.origin)}`,
637
+ `${c.dim('file:')} ${rel(result.path)} ${c.dim('(mode 0600)')}`,
638
+ `${c.dim('cookies:')} ${result.cookies}`,
639
+ `${c.dim('localStorage:')} ${result.localStorage} origin(s)`,
640
+ ].join('\n'), 'Saved session');
641
+ const next = cmd.save ? `rpc shot <url> --storage-state ${quote(rel(result.path))}` : `rpc shot ${result.origin}/…`;
642
+ p.outro(`${c.green('Done!')} ${c.dim(`Next: ${next}`)}`);
643
+ return 0;
644
+ }
645
+ catch (err) {
646
+ const msg = err.message;
647
+ if (spinning)
648
+ s.error(c.red(err instanceof HeadedBrowserMissingError || err instanceof BrowserMissingError ? 'No browser to open' : 'Sign-in failed'));
649
+ if (err instanceof HeadedBrowserMissingError || err instanceof BrowserMissingError) {
650
+ p.note(wrap(msg.replace(/^Fix: /, ''), textWidth()).join('\n'), c.yellow('Fix'));
651
+ }
652
+ else {
653
+ p.log.error(wrap(msg, textWidth()).join('\n'));
654
+ }
655
+ p.outro(c.red('No session saved.'));
656
+ return 1;
657
+ }
658
+ }
659
+ export async function runSessions(action, origin, c) {
660
+ intro(c);
661
+ if (action === 'clear') {
662
+ const gone = await clearSessions(origin);
663
+ if (gone.length === 0)
664
+ p.log.info(origin ? `No saved session for ${c.cyan(origin)}` : 'No saved sessions');
665
+ for (const o of gone)
666
+ p.log.success(`Removed ${c.cyan(o)}`);
667
+ p.outro(c.dim(`${gone.length} removed`));
668
+ return 0;
669
+ }
670
+ const list = await listSessions();
671
+ if (list.length === 0) {
672
+ p.log.info(`No saved sessions in ${c.dim(sessionsDir())}`);
673
+ p.outro(c.dim('Create one with: rpc login <url>'));
674
+ return 0;
675
+ }
676
+ const when = localTime;
677
+ const originW = Math.max(...list.map(s => s.origin.length));
678
+ const lines = list.map(s => {
679
+ const exp = s.expired ? c.red(`expired ${when(s.expires)}`) : s.expires ? c.dim(`expires ${when(s.expires)}`) : c.dim('no expiry');
680
+ return `${c.cyan(s.origin.padEnd(originW))} ${String(s.cookies).padStart(2)} cookie${s.cookies === 1 ? ' ' : 's'} ${c.dim(`saved ${when(s.savedAt)}`)} ${exp}`;
681
+ });
682
+ p.note(lines.join('\n'), `${list.length} saved session${list.length === 1 ? '' : 's'}`);
683
+ p.outro(c.dim(sessionsDir()));
684
+ return 0;
685
+ }
469
686
  // ------------------------------------------------------------------ doctor
470
687
  export async function runDoctor(check, c) {
471
688
  intro(c);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rpc-agent",
3
- "version": "0.3.1",
4
- "description": "Screenshot a URL at device viewports, optionally framed in device bezels. CLI and MCP server for AI coding agents.",
3
+ "version": "0.5.0",
4
+ "description": "Screenshot a URL at device viewports, optionally framed in device bezels, signed in or not, and audit it before launch. CLI and MCP server for AI coding agents.",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "mcp-server",
@@ -55,6 +55,7 @@
55
55
  "zod": "^4.6.7"
56
56
  },
57
57
  "devDependencies": {
58
+ "@fontsource/ibm-plex-mono": "^5.3.0",
58
59
  "@types/node": "^22.20.5",
59
60
  "@xterm/headless": "^5.5.0",
60
61
  "tsx": "^4.23.15",