rpc-agent 0.3.1 → 0.4.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/CHANGELOG.md +54 -0
- package/README.md +174 -5
- package/dist/args.js +126 -7
- package/dist/auth.js +416 -0
- package/dist/browser.js +6 -4
- package/dist/capture.js +42 -9
- package/dist/cli.js +83 -3
- package/dist/help.js +54 -4
- package/dist/login.js +150 -0
- package/dist/mcp.js +71 -4
- package/dist/term.js +20 -0
- package/dist/ui.js +114 -3
- package/package.json +3 -2
package/dist/help.js
CHANGED
|
@@ -15,16 +15,57 @@ const SHOT_OPTIONS = [
|
|
|
15
15
|
[' --open', 'open the output folder when done'],
|
|
16
16
|
[' --json', 'print the result as JSON (no colour, no progress)'],
|
|
17
17
|
];
|
|
18
|
+
const AUTH_OPTIONS = [
|
|
19
|
+
[' --cookie <c>', '"name=value[; Domain=…; Path=…]", repeatable; target origin only'],
|
|
20
|
+
[' --header <h>', '"Name: value", repeatable; sent to the target origin only'],
|
|
21
|
+
[' --storage-state <file>', 'load a Playwright storageState JSON (cookies + localStorage)'],
|
|
22
|
+
[' --no-session', "don't use the session saved by `rpc login` for this origin"],
|
|
23
|
+
[' --allow-origin <o>', 'also send headers and loaded cookies to this origin, repeatable'],
|
|
24
|
+
];
|
|
25
|
+
const LOGIN_OPTIONS = [
|
|
26
|
+
[' --save <file>', 'write the storage state here instead of the per-origin session'],
|
|
27
|
+
[' --form', 'headless: fill an email + password form (CI, agents)'],
|
|
28
|
+
[' --email <email>', 'with --form; default $RPC_LOGIN_EMAIL'],
|
|
29
|
+
[' --password <pw>', 'with --form; prefer $RPC_LOGIN_PASSWORD (keeps it out of shell history)'],
|
|
30
|
+
[' --email-selector <css>', `default: input[type=email], input[name=email], …`],
|
|
31
|
+
[' --password-selector <css>', 'default: input[type=password]'],
|
|
32
|
+
[' --submit-selector <css>', 'default: button[type=submit], input[type=submit] (else Enter)'],
|
|
33
|
+
[' --timeout <ms>', 'navigation and form timeout (default: 30000)'],
|
|
34
|
+
[' --json', 'print the result as JSON'],
|
|
35
|
+
];
|
|
18
36
|
export const COMMANDS = {
|
|
19
37
|
shot: {
|
|
20
38
|
usage: 'rpc shot <url> [options]',
|
|
21
39
|
summary: 'Capture a URL at device viewports, optionally framed in device bezels.',
|
|
22
|
-
options: SHOT_OPTIONS,
|
|
40
|
+
options: [...SHOT_OPTIONS, ...AUTH_OPTIONS],
|
|
23
41
|
examples: [
|
|
24
42
|
['rpc shot localhost:3000 -d iphone-15,ipad,desktop-1440 --bezels', ''],
|
|
25
43
|
['rpc shot example.com/pricing --full-page --slices', 'whole page + review slices'],
|
|
26
44
|
['rpc shot localhost:5173 --color-scheme dark --open', 'dark mode, open when done'],
|
|
27
45
|
['rpc shot localhost:3000 -d pixel --json', 'machine-readable result'],
|
|
46
|
+
['rpc shot localhost:3000/billing', 'uses the session saved by rpc login'],
|
|
47
|
+
["rpc shot api.example.com/admin --header 'Authorization: Bearer $TOKEN'", ''],
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
login: {
|
|
51
|
+
usage: 'rpc login <url> [--form] [--save <file>]',
|
|
52
|
+
summary: 'Open a browser window at the URL, sign in, then press Enter (or close the window). The session is saved for that ' +
|
|
53
|
+
'origin (~/.config/rpc-agent/sessions, mode 0600) and `rpc shot` uses it automatically.',
|
|
54
|
+
options: LOGIN_OPTIONS,
|
|
55
|
+
examples: [
|
|
56
|
+
['rpc login localhost:3000/sign-in', 'sign in by hand in a window'],
|
|
57
|
+
['RPC_LOGIN_EMAIL=me@x.test RPC_LOGIN_PASSWORD=… rpc login localhost:3000/sign-in --form', 'CI / agents'],
|
|
58
|
+
['rpc login example.com --save auth.json', 'write a storageState file instead'],
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
sessions: {
|
|
62
|
+
usage: 'rpc sessions [list] | rpc sessions clear [origin]',
|
|
63
|
+
summary: 'List the sessions saved by `rpc login` (origins, cookie counts, expiry; never values), or delete them.',
|
|
64
|
+
options: [[' --json', 'print as JSON']],
|
|
65
|
+
examples: [
|
|
66
|
+
['rpc sessions', ''],
|
|
67
|
+
['rpc sessions clear localhost:3000', 'forget one origin'],
|
|
68
|
+
['rpc sessions clear', 'forget all'],
|
|
28
69
|
],
|
|
29
70
|
},
|
|
30
71
|
devices: {
|
|
@@ -43,18 +84,24 @@ export const COMMANDS = {
|
|
|
43
84
|
examples: [['rpc doctor', 'exits 1 when Chromium is missing']],
|
|
44
85
|
},
|
|
45
86
|
install: {
|
|
46
|
-
usage: 'rpc install',
|
|
87
|
+
usage: 'rpc install [--headed]',
|
|
47
88
|
summary: "Download Playwright's Chromium with the Playwright version rpc uses, then run doctor.",
|
|
48
|
-
|
|
89
|
+
options: [[' --headed', 'also download full Chromium, for the window `rpc login` opens']],
|
|
90
|
+
examples: [
|
|
91
|
+
['rpc install', 'headless shell only (captures)'],
|
|
92
|
+
['rpc install --headed', 'for rpc login'],
|
|
93
|
+
],
|
|
49
94
|
},
|
|
50
95
|
mcp: {
|
|
51
96
|
usage: 'rpc mcp',
|
|
52
|
-
summary: 'Run the MCP server on stdio (tools: screenshot, list_devices).',
|
|
97
|
+
summary: 'Run the MCP server on stdio (tools: screenshot, list_devices, list_sessions).',
|
|
53
98
|
examples: [['claude mcp add --scope user rpc -- node /abs/path/dist/cli.js mcp', 'add to Claude Code']],
|
|
54
99
|
},
|
|
55
100
|
};
|
|
56
101
|
const TOP = [
|
|
57
102
|
['shot <url>', 'capture screenshots'],
|
|
103
|
+
['login <url>', 'sign in once; later shots of that origin use the session'],
|
|
104
|
+
['sessions', 'list or clear saved sessions'],
|
|
58
105
|
['devices', 'list device ids and aliases'],
|
|
59
106
|
['doctor', "check that Playwright's Chromium is installed and launches"],
|
|
60
107
|
['install', "download Playwright's Chromium (same version rpc uses)"],
|
|
@@ -91,6 +138,9 @@ export function richHelp(c) {
|
|
|
91
138
|
c.bold('Shot options:'),
|
|
92
139
|
...table(SHOT_OPTIONS, c),
|
|
93
140
|
'',
|
|
141
|
+
c.bold('Auth options:'),
|
|
142
|
+
...table(AUTH_OPTIONS, c),
|
|
143
|
+
'',
|
|
94
144
|
c.bold('Examples:'),
|
|
95
145
|
...examples(COMMANDS.shot.examples, c),
|
|
96
146
|
'',
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// `rpc login`: sign in once, save the browser's storage state for the origin.
|
|
2
|
+
import { chromium } from 'playwright';
|
|
3
|
+
import { isLoginUrl, redact, scopeState, toOrigin, writeSession } from './auth.js';
|
|
4
|
+
import { launchBrowser } from './browser.js';
|
|
5
|
+
export const DEFAULT_SELECTORS = {
|
|
6
|
+
email: 'input[type=email], input[autocomplete=username], input[name=email], input[name=username], input#email',
|
|
7
|
+
password: 'input[type=password]',
|
|
8
|
+
submit: 'button[type=submit], input[type=submit]',
|
|
9
|
+
};
|
|
10
|
+
export class HeadedBrowserMissingError extends Error {
|
|
11
|
+
name = 'HeadedBrowserMissingError';
|
|
12
|
+
constructor() {
|
|
13
|
+
super('rpc login opens a browser window, which needs full Chromium (`rpc install` only downloads the headless shell). ' +
|
|
14
|
+
'Run `rpc install --headed`, or install Google Chrome, or sign in headlessly with `rpc login <url> --form`.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Headed Chromium; falls back to an installed Google Chrome. */
|
|
18
|
+
export async function launchHeaded() {
|
|
19
|
+
try {
|
|
20
|
+
return await chromium.launch({ headless: false });
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (!/Executable doesn't exist|playwright install/i.test(err.message))
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return await chromium.launch({ headless: false, channel: 'chrome' });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new HeadedBrowserMissingError();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function save(state, opts, finalUrl) {
|
|
34
|
+
const origin = toOrigin(opts.url);
|
|
35
|
+
const path = await writeSession(state, { origin, savedAt: new Date().toISOString(), url: opts.url }, opts.save);
|
|
36
|
+
const own = scopeState(state, [origin]);
|
|
37
|
+
return {
|
|
38
|
+
origin,
|
|
39
|
+
path,
|
|
40
|
+
finalUrl,
|
|
41
|
+
cookies: own.cookies.length,
|
|
42
|
+
localStorage: own.origins.length,
|
|
43
|
+
stillOnLogin: isLoginUrl(finalUrl) && new URL(finalUrl).origin === origin,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Open a visible browser at the URL and save the storage state when `done`
|
|
48
|
+
* resolves (the user pressed Enter) or the window is closed.
|
|
49
|
+
*/
|
|
50
|
+
export async function interactiveLogin(opts, done, onOpen) {
|
|
51
|
+
const browser = await launchHeaded();
|
|
52
|
+
try {
|
|
53
|
+
const context = await browser.newContext({ viewport: null, ignoreHTTPSErrors: true });
|
|
54
|
+
const page = await context.newPage();
|
|
55
|
+
let lastUrl = opts.url;
|
|
56
|
+
page.on('framenavigated', f => {
|
|
57
|
+
if (f === page.mainFrame())
|
|
58
|
+
lastUrl = f.url();
|
|
59
|
+
});
|
|
60
|
+
await page.goto(opts.url, { timeout: opts.timeoutMs }).catch(() => { });
|
|
61
|
+
onOpen?.();
|
|
62
|
+
// Snapshot regularly: once the window is closed the context can't be read.
|
|
63
|
+
let snapshot = await context.storageState();
|
|
64
|
+
const timer = setInterval(() => {
|
|
65
|
+
context.storageState().then(s => (snapshot = s), () => { });
|
|
66
|
+
}, 1000);
|
|
67
|
+
const closed = new Promise(res => {
|
|
68
|
+
page.on('close', res);
|
|
69
|
+
browser.on('disconnected', res);
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
await Promise.race([done, closed]);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearInterval(timer);
|
|
76
|
+
}
|
|
77
|
+
const state = await context.storageState().catch(() => snapshot);
|
|
78
|
+
if (!page.isClosed())
|
|
79
|
+
lastUrl = page.url();
|
|
80
|
+
return await save(state, opts, lastUrl);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await browser.close().catch(() => { });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Headless: fill an email + password form, submit, wait to leave the login
|
|
88
|
+
* URL, save. For CI and agents.
|
|
89
|
+
*/
|
|
90
|
+
export async function formLogin(opts, form) {
|
|
91
|
+
const browser = await launchBrowser();
|
|
92
|
+
try {
|
|
93
|
+
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
94
|
+
const page = await context.newPage();
|
|
95
|
+
await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
|
|
96
|
+
// Let client-side forms hydrate, so submit runs the app's handler rather
|
|
97
|
+
// than a native GET that would put the password in the URL.
|
|
98
|
+
await page.waitForLoadState('networkidle', { timeout: Math.min(opts.timeoutMs, 10_000) }).catch(() => { });
|
|
99
|
+
const start = new URL(page.url());
|
|
100
|
+
const email = page.locator(form.emailSelector ?? DEFAULT_SELECTORS.email).first();
|
|
101
|
+
const password = page.locator(form.passwordSelector ?? DEFAULT_SELECTORS.password).first();
|
|
102
|
+
await waitVisible(email, 'email field', form.emailSelector ? '--email-selector' : undefined, opts.timeoutMs);
|
|
103
|
+
await email.fill(form.email);
|
|
104
|
+
await waitVisible(password, 'password field', form.passwordSelector ? '--password-selector' : undefined, opts.timeoutMs);
|
|
105
|
+
await password.fill(form.password);
|
|
106
|
+
const submit = page.locator(form.submitSelector ?? DEFAULT_SELECTORS.submit).first();
|
|
107
|
+
if (form.submitSelector || (await submit.count()) > 0) {
|
|
108
|
+
await waitVisible(submit, 'submit button', form.submitSelector ? '--submit-selector' : undefined, opts.timeoutMs);
|
|
109
|
+
await submit.click();
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
await password.press('Enter');
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await page.waitForURL(u => u.origin + u.pathname !== start.origin + start.pathname, { timeout: opts.timeoutMs });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
const shown = await pageMessage(page);
|
|
119
|
+
const message = shown && redact(shown, [form.password]);
|
|
120
|
+
throw new Error(`Still on ${start.origin}${start.pathname} ${Math.round(opts.timeoutMs / 1000)} s after submitting the form` +
|
|
121
|
+
`${message ? ` (the page says: "${message}")` : ''}. Check the credentials, or the selectors (--email-selector, --password-selector, --submit-selector).`);
|
|
122
|
+
}
|
|
123
|
+
await page.waitForLoadState('networkidle', { timeout: Math.min(opts.timeoutMs, 10_000) }).catch(() => { });
|
|
124
|
+
return await save(await context.storageState(), opts, page.url());
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await browser.close().catch(() => { });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function waitVisible(loc, what, flag, timeoutMs) {
|
|
131
|
+
try {
|
|
132
|
+
await loc.waitFor({ state: 'visible', timeout: Math.min(timeoutMs, 15_000) });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new Error(`No ${what} found on the page${flag ? ` for ${flag}` : ''}. Pass ${flag ?? `--${what.split(' ')[0]}-selector`} with a CSS selector that matches it.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** A visible alert or error message, if the login page shows one. */
|
|
139
|
+
async function pageMessage(page) {
|
|
140
|
+
const loc = page.locator('[role=alert], [aria-live=assertive], .error, [data-error]').filter({ hasText: /\S/ }).first();
|
|
141
|
+
try {
|
|
142
|
+
if ((await loc.count()) === 0)
|
|
143
|
+
return undefined;
|
|
144
|
+
const t = (await loc.innerText({ timeout: 1000 })).trim().replace(/\s+/g, ' ');
|
|
145
|
+
return t ? t.slice(0, 160) : undefined;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { describeAuth, listSessions, resolveAuth, sessionsDir, toOrigin } from './auth.js';
|
|
5
6
|
import { capture, launchBrowser } from './capture.js';
|
|
6
7
|
import { renderPreview } from './composite.js';
|
|
7
8
|
import { CATEGORY_LABELS, CATEGORY_ORDER, DEFAULT_DEVICES, DEVICE_ALIASES, DEVICES, resolveDevices, } from './devices.js';
|
|
@@ -64,6 +65,33 @@ const screenshotInput = {
|
|
|
64
65
|
.boolean()
|
|
65
66
|
.optional()
|
|
66
67
|
.describe('Return JPEG previews inline: one per device, or the full-page slices when fullPage. Default true.'),
|
|
68
|
+
cookies: z
|
|
69
|
+
.array(z.object({
|
|
70
|
+
name: z.string().min(1),
|
|
71
|
+
value: z.string(),
|
|
72
|
+
domain: z.string().optional().describe('Default: the target host only. Must match the target host (or an allowOrigins host).'),
|
|
73
|
+
path: z.string().optional().describe('Default /.'),
|
|
74
|
+
}))
|
|
75
|
+
.max(50)
|
|
76
|
+
.optional()
|
|
77
|
+
.describe('Cookies set on the target origin before loading. Values are never echoed back.'),
|
|
78
|
+
headers: z
|
|
79
|
+
.record(z.string(), z.string())
|
|
80
|
+
.optional()
|
|
81
|
+
.describe('Request headers, e.g. {"Authorization": "Bearer …"}. Sent only to the target origin (and allowOrigins), never to third-party requests or redirects. Values are never echoed back.'),
|
|
82
|
+
storageState: z
|
|
83
|
+
.string()
|
|
84
|
+
.optional()
|
|
85
|
+
.describe("Path to a Playwright storageState JSON (cookies + localStorage), relative to the server's cwd. Only the target origin's entries are loaded."),
|
|
86
|
+
useSession: z
|
|
87
|
+
.boolean()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe('Use the session saved by `rpc login <url>` for the target origin. Default true when one exists (see list_sessions). Ignored when storageState is set.'),
|
|
90
|
+
allowOrigins: z
|
|
91
|
+
.array(z.string())
|
|
92
|
+
.max(10)
|
|
93
|
+
.optional()
|
|
94
|
+
.describe('Extra origins (e.g. an API host) that also get the headers and loaded cookies.'),
|
|
67
95
|
};
|
|
68
96
|
export function createServer() {
|
|
69
97
|
const server = new McpServer({ name: 'rpc', version: pkg.version });
|
|
@@ -77,6 +105,17 @@ export function createServer() {
|
|
|
77
105
|
}, async (args) => {
|
|
78
106
|
let options;
|
|
79
107
|
try {
|
|
108
|
+
const auth = {};
|
|
109
|
+
if (args.cookies?.length)
|
|
110
|
+
auth.cookies = args.cookies;
|
|
111
|
+
if (args.headers && Object.keys(args.headers).length)
|
|
112
|
+
auth.headers = args.headers;
|
|
113
|
+
if (args.storageState)
|
|
114
|
+
auth.storageState = args.storageState;
|
|
115
|
+
if (args.useSession === false)
|
|
116
|
+
auth.useSession = false;
|
|
117
|
+
if (args.allowOrigins?.length)
|
|
118
|
+
auth.allowOrigins = args.allowOrigins.map(toOrigin);
|
|
80
119
|
options = {
|
|
81
120
|
url: normalizeUrl(args.url),
|
|
82
121
|
devices: resolveDevices(args.devices ?? DEFAULT_DEVICES).map(d => d.id),
|
|
@@ -93,7 +132,9 @@ export function createServer() {
|
|
|
93
132
|
saveSlices: args.saveSlices,
|
|
94
133
|
maxSlices: args.maxSlices ?? DEFAULT_MAX_SLICES,
|
|
95
134
|
sliceWidth: args.sliceWidth ?? DEFAULT_SLICE_WIDTH,
|
|
135
|
+
...(Object.keys(auth).length ? { auth } : {}),
|
|
96
136
|
};
|
|
137
|
+
resolveAuth(options.url, auth); // bad cookies/headers/storage state: a tool error, before launching
|
|
97
138
|
}
|
|
98
139
|
catch (err) {
|
|
99
140
|
return { isError: true, content: [{ type: 'text', text: err.message }] };
|
|
@@ -111,9 +152,9 @@ export function createServer() {
|
|
|
111
152
|
const content = [
|
|
112
153
|
{
|
|
113
154
|
type: 'text',
|
|
114
|
-
text: headline(result.shots, options) +
|
|
155
|
+
text: headline(result.shots, options, result.auth) +
|
|
115
156
|
'\n\n' +
|
|
116
|
-
JSON.stringify(summarise(result.url, result.outDir, result.shots), null, 2),
|
|
157
|
+
JSON.stringify(summarise(result.url, result.outDir, result.shots, result.auth), null, 2),
|
|
117
158
|
},
|
|
118
159
|
];
|
|
119
160
|
if (inline) {
|
|
@@ -168,6 +209,29 @@ export function createServer() {
|
|
|
168
209
|
lines.push(`${a} -> ${id}`);
|
|
169
210
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
170
211
|
});
|
|
212
|
+
server.registerTool('list_sessions', {
|
|
213
|
+
title: 'List saved login sessions',
|
|
214
|
+
description: 'List the origins that have a session saved by `rpc login` (used automatically by screenshot for that origin): ' +
|
|
215
|
+
'cookie count, when it was saved, and whether it has expired. Never returns cookie values.',
|
|
216
|
+
inputSchema: {},
|
|
217
|
+
annotations: { readOnlyHint: true },
|
|
218
|
+
}, async () => {
|
|
219
|
+
const list = await listSessions();
|
|
220
|
+
if (list.length === 0) {
|
|
221
|
+
return {
|
|
222
|
+
content: [
|
|
223
|
+
{
|
|
224
|
+
type: 'text',
|
|
225
|
+
text: `No saved sessions in ${sessionsDir()}. The user can create one with \`rpc login <url>\` ` +
|
|
226
|
+
'(a browser window) or `rpc login <url> --form` with RPC_LOGIN_EMAIL / RPC_LOGIN_PASSWORD set.',
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const lines = list.map(s => `${s.origin}: ${s.cookies} cookie(s)${s.savedAt ? `, saved ${s.savedAt}` : ''}` +
|
|
232
|
+
`${s.expired ? `, EXPIRED ${s.expires} (run \`rpc login ${s.origin}\`)` : s.expires ? `, expires ${s.expires}` : ''}`);
|
|
233
|
+
return { content: [{ type: 'text', text: lines.join('\n') + '\n\n' + JSON.stringify({ dir: sessionsDir(), sessions: list }, null, 2) }] };
|
|
234
|
+
});
|
|
171
235
|
return server;
|
|
172
236
|
}
|
|
173
237
|
/** Framed first screen (the hero), then the first maxSlices slices, each with a label. */
|
|
@@ -187,8 +251,10 @@ async function fullPageImages(browser, s, maxSlices) {
|
|
|
187
251
|
return out;
|
|
188
252
|
}
|
|
189
253
|
/** Plain-language first lines: failures with hints, and what full-page output covers. */
|
|
190
|
-
export function headline(shots, options) {
|
|
254
|
+
export function headline(shots, options, auth) {
|
|
191
255
|
const lines = [];
|
|
256
|
+
if (auth)
|
|
257
|
+
lines.push(`auth: ${describeAuth(auth)}`);
|
|
192
258
|
const failed = shots.filter(s => s.error);
|
|
193
259
|
if (failed.length === shots.length)
|
|
194
260
|
lines.push(`All ${shots.length} device(s) failed.`);
|
|
@@ -232,11 +298,12 @@ export function headline(shots, options) {
|
|
|
232
298
|
}
|
|
233
299
|
return lines.join('\n');
|
|
234
300
|
}
|
|
235
|
-
function summarise(url, outDir, shots) {
|
|
301
|
+
function summarise(url, outDir, shots, auth) {
|
|
236
302
|
return {
|
|
237
303
|
url,
|
|
238
304
|
outDir,
|
|
239
305
|
shots: shots.map(({ buffers: _b, ...s }) => s),
|
|
306
|
+
...(auth ? { auth } : {}),
|
|
240
307
|
};
|
|
241
308
|
}
|
|
242
309
|
export async function runMcpServer() {
|
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,12 @@ 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';
|
|
11
12
|
import { CATEGORY_ORDER, DEVICE_ALIASES, DEVICES, } from './devices.js';
|
|
12
13
|
import { defaultShotOptions, normalizeUrl } from './options.js';
|
|
13
|
-
import { openCommand, openPath } from './term.js';
|
|
14
|
+
import { openCommand, openPath, waitForEnter } from './term.js';
|
|
14
15
|
export function colors(enabled) {
|
|
15
16
|
return pc.createColors(enabled);
|
|
16
17
|
}
|
|
@@ -174,7 +175,7 @@ export function printDevices(c) {
|
|
|
174
175
|
}
|
|
175
176
|
// ------------------------------------------------------------------ shot
|
|
176
177
|
/** Lines for the "Capture Summary" box. */
|
|
177
|
-
export function summaryLines(o, c) {
|
|
178
|
+
export function summaryLines(o, c, auth) {
|
|
178
179
|
const yes = (b) => (b ? c.green('yes') : c.dim('no'));
|
|
179
180
|
const rows = [
|
|
180
181
|
['devices', formatList(o.devices.map(d => c.cyan(d)))],
|
|
@@ -188,10 +189,34 @@ export function summaryLines(o, c) {
|
|
|
188
189
|
rows.push(['wait for', o.waitFor]);
|
|
189
190
|
if (!o.emulate)
|
|
190
191
|
rows.push(['emulation', c.yellow('viewport + DPR only')]);
|
|
192
|
+
if (auth)
|
|
193
|
+
rows.push(...authRows(auth, c));
|
|
191
194
|
rows.push(['output', `${rel(o.outDir)}/`]);
|
|
192
195
|
const w = Math.max(...rows.map(([k]) => k.length)) + 1;
|
|
193
196
|
return [c.cyan(o.url), ...rows.map(([k, v]) => ` ${c.dim(`${k}:`.padEnd(w))} ${v}`)];
|
|
194
197
|
}
|
|
198
|
+
/** Auth rows for the summary box: names only, values masked. */
|
|
199
|
+
export function authRows(a, c) {
|
|
200
|
+
const rows = [];
|
|
201
|
+
if (a.session) {
|
|
202
|
+
const saved = a.session.savedAt ? c.dim(` saved ${localTime(a.session.savedAt)}`) : '';
|
|
203
|
+
if (a.session.loaded)
|
|
204
|
+
rows.push(['session', `${c.green(a.session.origin)}${saved}`]);
|
|
205
|
+
else if (a.session.expired)
|
|
206
|
+
rows.push(['session', c.yellow(`${a.session.origin} expired, not loaded`)]);
|
|
207
|
+
else
|
|
208
|
+
rows.push(['session', c.dim(`${a.session.origin} skipped (--no-session)`)]);
|
|
209
|
+
}
|
|
210
|
+
if (a.storageState)
|
|
211
|
+
rows.push(['storage state', rel(a.storageState)]);
|
|
212
|
+
if (a.cookies?.length)
|
|
213
|
+
rows.push(['cookies', formatList(a.cookies.map(x => `${x.name}=${c.dim(MASK)}`))]);
|
|
214
|
+
if (a.headers)
|
|
215
|
+
rows.push(['headers', formatList(Object.keys(a.headers).map(h => `${h}: ${c.dim(MASK)}`))]);
|
|
216
|
+
if (a.allowOrigins?.length)
|
|
217
|
+
rows.push(['also allowed', formatList(a.allowOrigins)]);
|
|
218
|
+
return rows;
|
|
219
|
+
}
|
|
195
220
|
function statusCell(s, c) {
|
|
196
221
|
if (s.error)
|
|
197
222
|
return c.red(s.code ?? 'error');
|
|
@@ -279,10 +304,11 @@ export function capturedLines(result, c) {
|
|
|
279
304
|
return out;
|
|
280
305
|
}
|
|
281
306
|
export async function runShot(options, run, c) {
|
|
307
|
+
const auth = resolveAuth(options.url, options.auth ?? {});
|
|
282
308
|
if (!run.continued) {
|
|
283
309
|
printBanner(c);
|
|
284
310
|
intro(c);
|
|
285
|
-
p.note(summaryLines(options, c).join('\n'), 'Capture Summary');
|
|
311
|
+
p.note(summaryLines(options, c, auth.summary).join('\n'), 'Capture Summary');
|
|
286
312
|
}
|
|
287
313
|
const s = p.spinner();
|
|
288
314
|
s.start('Starting Chromium');
|
|
@@ -313,6 +339,7 @@ export async function runShot(options, run, c) {
|
|
|
313
339
|
try {
|
|
314
340
|
result = await capture(options, {
|
|
315
341
|
browser,
|
|
342
|
+
auth,
|
|
316
343
|
onDeviceStart: (d) => {
|
|
317
344
|
started = performance.now();
|
|
318
345
|
current = p.spinner();
|
|
@@ -466,6 +493,90 @@ export async function runInteractive(c) {
|
|
|
466
493
|
}
|
|
467
494
|
return runShot(options, { open: false, continued: true, showCommand: true }, c);
|
|
468
495
|
}
|
|
496
|
+
// ------------------------------------------------------------------ login
|
|
497
|
+
export async function runLogin(cmd, c) {
|
|
498
|
+
intro(c);
|
|
499
|
+
const { formLogin, interactiveLogin, HeadedBrowserMissingError } = await import('./login.js');
|
|
500
|
+
const s = p.spinner();
|
|
501
|
+
let spinning = false;
|
|
502
|
+
try {
|
|
503
|
+
let result;
|
|
504
|
+
if (cmd.form) {
|
|
505
|
+
s.start(`Signing in at ${c.cyan(cmd.url)} ${c.dim('(headless, form)')}`);
|
|
506
|
+
spinning = true;
|
|
507
|
+
result = await formLogin(cmd, cmd.form);
|
|
508
|
+
spinning = false;
|
|
509
|
+
s.stop(`Signed in ${c.dim(`now at ${result.finalUrl}`)}`);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
s.start('Opening Chromium');
|
|
513
|
+
spinning = true;
|
|
514
|
+
const enter = waitForEnter();
|
|
515
|
+
try {
|
|
516
|
+
result = await interactiveLogin(cmd, enter.promise, () => {
|
|
517
|
+
spinning = false;
|
|
518
|
+
s.stop(`Opened ${c.cyan(cmd.url)}`);
|
|
519
|
+
p.log.info(`Sign in in the browser window, then press ${c.bold('Enter')} here ${c.dim('(or close the window)')}`);
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
enter.stop();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (result.stillOnLogin) {
|
|
527
|
+
p.log.warn(c.yellow(`The page was still on a login URL (${result.finalUrl}). The sign-in may not have finished.`));
|
|
528
|
+
}
|
|
529
|
+
p.note([
|
|
530
|
+
`${c.dim('origin:')} ${c.cyan(result.origin)}`,
|
|
531
|
+
`${c.dim('file:')} ${rel(result.path)} ${c.dim('(mode 0600)')}`,
|
|
532
|
+
`${c.dim('cookies:')} ${result.cookies}`,
|
|
533
|
+
`${c.dim('localStorage:')} ${result.localStorage} origin(s)`,
|
|
534
|
+
].join('\n'), 'Saved session');
|
|
535
|
+
const next = cmd.save ? `rpc shot <url> --storage-state ${quote(rel(result.path))}` : `rpc shot ${result.origin}/…`;
|
|
536
|
+
p.outro(`${c.green('Done!')} ${c.dim(`Next: ${next}`)}`);
|
|
537
|
+
return 0;
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
const msg = err.message;
|
|
541
|
+
if (spinning)
|
|
542
|
+
s.error(c.red(err instanceof HeadedBrowserMissingError || err instanceof BrowserMissingError ? 'No browser to open' : 'Sign-in failed'));
|
|
543
|
+
if (err instanceof HeadedBrowserMissingError || err instanceof BrowserMissingError) {
|
|
544
|
+
p.note(wrap(msg.replace(/^Fix: /, ''), textWidth()).join('\n'), c.yellow('Fix'));
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
p.log.error(wrap(msg, textWidth()).join('\n'));
|
|
548
|
+
}
|
|
549
|
+
p.outro(c.red('No session saved.'));
|
|
550
|
+
return 1;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
export async function runSessions(action, origin, c) {
|
|
554
|
+
intro(c);
|
|
555
|
+
if (action === 'clear') {
|
|
556
|
+
const gone = await clearSessions(origin);
|
|
557
|
+
if (gone.length === 0)
|
|
558
|
+
p.log.info(origin ? `No saved session for ${c.cyan(origin)}` : 'No saved sessions');
|
|
559
|
+
for (const o of gone)
|
|
560
|
+
p.log.success(`Removed ${c.cyan(o)}`);
|
|
561
|
+
p.outro(c.dim(`${gone.length} removed`));
|
|
562
|
+
return 0;
|
|
563
|
+
}
|
|
564
|
+
const list = await listSessions();
|
|
565
|
+
if (list.length === 0) {
|
|
566
|
+
p.log.info(`No saved sessions in ${c.dim(sessionsDir())}`);
|
|
567
|
+
p.outro(c.dim('Create one with: rpc login <url>'));
|
|
568
|
+
return 0;
|
|
569
|
+
}
|
|
570
|
+
const when = localTime;
|
|
571
|
+
const originW = Math.max(...list.map(s => s.origin.length));
|
|
572
|
+
const lines = list.map(s => {
|
|
573
|
+
const exp = s.expired ? c.red(`expired ${when(s.expires)}`) : s.expires ? c.dim(`expires ${when(s.expires)}`) : c.dim('no expiry');
|
|
574
|
+
return `${c.cyan(s.origin.padEnd(originW))} ${String(s.cookies).padStart(2)} cookie${s.cookies === 1 ? ' ' : 's'} ${c.dim(`saved ${when(s.savedAt)}`)} ${exp}`;
|
|
575
|
+
});
|
|
576
|
+
p.note(lines.join('\n'), `${list.length} saved session${list.length === 1 ? '' : 's'}`);
|
|
577
|
+
p.outro(c.dim(sessionsDir()));
|
|
578
|
+
return 0;
|
|
579
|
+
}
|
|
469
580
|
// ------------------------------------------------------------------ doctor
|
|
470
581
|
export async function runDoctor(check, c) {
|
|
471
582
|
intro(c);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rpc-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Screenshot a URL at device viewports, optionally framed in device bezels. CLI and MCP server for AI coding agents.",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Screenshot a URL at device viewports, optionally framed in device bezels, signed in or not. 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",
|