rpc-agent 0.0.0-stage → 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 +157 -0
- package/LICENSE +21 -0
- package/NOTICE +16 -0
- package/README.md +471 -2
- package/assets/bezels/imac.svg +63 -0
- package/assets/bezels/ipad.svg +27 -0
- package/assets/bezels/iphone-11-pro.svg +35 -0
- package/assets/bezels/iphone-12-mini.svg +26 -0
- package/assets/bezels/iphone-12-pro-max.svg +26 -0
- package/assets/bezels/iphone-17-pro.svg +172 -0
- package/assets/bezels/iphone-se.svg +37 -0
- package/assets/bezels/laptop.svg +25 -0
- package/assets/bezels/macbook-air.svg +235 -0
- package/assets/bezels/macbook-pro-13.svg +63 -0
- package/assets/bezels/macbook-pro-14.svg +115 -0
- package/assets/bezels/macbook-pro.svg +32 -0
- package/assets/bezels/macbook.svg +25 -0
- package/assets/bezels/monitor.svg +57 -0
- package/assets/bezels/phone-classic.svg +17 -0
- package/assets/bezels/phone-home.svg +12 -0
- package/assets/bezels/phone-island.svg +130 -0
- package/assets/bezels/phone-notch.svg +26 -0
- package/assets/bezels/surface-laptop.svg +84 -0
- package/assets/bezels/tablet.svg +25 -0
- package/assets/bezels/windows-laptop.svg +84 -0
- package/dist/args.js +308 -0
- package/dist/auth.js +416 -0
- package/dist/bezel.js +74 -0
- package/dist/browser.js +82 -0
- package/dist/capture.js +274 -0
- package/dist/cli.js +255 -0
- package/dist/composite.js +115 -0
- package/dist/devices.js +407 -0
- package/dist/errors.js +115 -0
- package/dist/help.js +160 -0
- package/dist/login.js +150 -0
- package/dist/mcp.js +312 -0
- package/dist/options.js +51 -0
- package/dist/png.js +183 -0
- package/dist/slices.js +59 -0
- package/dist/term.js +66 -0
- package/dist/ui.js +599 -0
- package/package.json +68 -4
package/dist/args.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { parseCookie, parseHeader, resolveAuth, toOrigin } from './auth.js';
|
|
3
|
+
import { resolveDevices } from './devices.js';
|
|
4
|
+
import { COLOR_SCHEMES, DEFAULT_TIMEOUT_MS, defaultShotOptions, normalizeUrl, } from './options.js';
|
|
5
|
+
export const HELP_TOPICS = ['shot', 'login', 'sessions', 'devices', 'doctor', 'install', 'mcp'];
|
|
6
|
+
function isTopic(s) {
|
|
7
|
+
return HELP_TOPICS.includes(s ?? '');
|
|
8
|
+
}
|
|
9
|
+
function wantsHelp(args) {
|
|
10
|
+
return args.includes('--help') || args.includes('-h');
|
|
11
|
+
}
|
|
12
|
+
export class ArgError extends Error {
|
|
13
|
+
name = 'ArgError';
|
|
14
|
+
}
|
|
15
|
+
export const USAGE = `rpc - screenshot a URL at device viewports, optionally in device bezels
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
rpc shot <url> [options] capture screenshots
|
|
19
|
+
rpc login <url> [--form] sign in once and save the session for that origin
|
|
20
|
+
rpc sessions [list|clear [origin]]
|
|
21
|
+
list or delete saved sessions
|
|
22
|
+
rpc devices [--json] list device ids and aliases
|
|
23
|
+
rpc mcp run the MCP server on stdio
|
|
24
|
+
rpc doctor [--json] check that Playwright's Chromium is installed and launches
|
|
25
|
+
rpc install [--headed] download Playwright's Chromium (same version rpc uses)
|
|
26
|
+
|
|
27
|
+
Shot options:
|
|
28
|
+
-d, --devices <ids> comma-separated device ids or aliases
|
|
29
|
+
(default: iphone-15-pro,desktop-1440)
|
|
30
|
+
-b, --bezels frame each shot in its device bezel
|
|
31
|
+
-f, --full-page capture the full scroll height (stitched, any height)
|
|
32
|
+
--slices with --full-page: also write viewport-tall JPEG
|
|
33
|
+
review slices (<stem>-slice-NN.jpg)
|
|
34
|
+
--slice-width <px> max slice width (default: 768)
|
|
35
|
+
--max-height <px> full-page cap in CSS px (default: 60000)
|
|
36
|
+
--reduced-motion emulate prefers-reduced-motion: reduce
|
|
37
|
+
-o, --out <dir> output directory (default: ./.rpc-shots)
|
|
38
|
+
--wait-for <selector> wait for a CSS selector before capturing
|
|
39
|
+
--wait <ms> extra settle delay after load (default: 0)
|
|
40
|
+
--color-scheme <s> light | dark | no-preference
|
|
41
|
+
--timeout <ms> navigation timeout (default: 30000)
|
|
42
|
+
--no-emulate viewport + DPR only (no mobile UA / touch)
|
|
43
|
+
--json print the result as JSON
|
|
44
|
+
--open open the output folder when done
|
|
45
|
+
|
|
46
|
+
Auth options (values are never printed):
|
|
47
|
+
--cookie <c> "name=value[; Domain=…; Path=…]", repeatable;
|
|
48
|
+
sent to the target origin only
|
|
49
|
+
--header <h> "Name: value", repeatable; target origin only
|
|
50
|
+
--storage-state <file> load a Playwright storageState JSON
|
|
51
|
+
--no-session don't use the session saved by \`rpc login\`
|
|
52
|
+
--allow-origin <o> also send headers/cookies to this origin, repeatable
|
|
53
|
+
|
|
54
|
+
Run \`rpc help <command>\` for one command's options and examples.
|
|
55
|
+
Run \`rpc\` with no arguments in a terminal for an interactive capture.
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
rpc shot localhost:3000 -d iphone-15,ipad,desktop-1440 --bezels
|
|
59
|
+
rpc shot https://example.com --full-page --color-scheme dark
|
|
60
|
+
rpc login localhost:3000/sign-in && rpc shot localhost:3000/dashboard
|
|
61
|
+
`;
|
|
62
|
+
export function parseCli(argv, env = process.env) {
|
|
63
|
+
const [cmd, ...rest] = argv;
|
|
64
|
+
switch (cmd) {
|
|
65
|
+
case undefined:
|
|
66
|
+
case 'help':
|
|
67
|
+
case '-h':
|
|
68
|
+
case '--help': {
|
|
69
|
+
const topic = rest[0];
|
|
70
|
+
if (topic === undefined)
|
|
71
|
+
return { command: 'help' };
|
|
72
|
+
if (!isTopic(topic))
|
|
73
|
+
throw new ArgError(`No help for "${topic}". Topics: ${HELP_TOPICS.join(', ')}`);
|
|
74
|
+
return { command: 'help', topic };
|
|
75
|
+
}
|
|
76
|
+
case '-v':
|
|
77
|
+
case '--version':
|
|
78
|
+
return { command: 'version' };
|
|
79
|
+
case 'mcp':
|
|
80
|
+
if (wantsHelp(rest))
|
|
81
|
+
return { command: 'help', topic: 'mcp' };
|
|
82
|
+
return { command: 'mcp' };
|
|
83
|
+
case 'install': {
|
|
84
|
+
if (wantsHelp(rest))
|
|
85
|
+
return { command: 'help', topic: 'install' };
|
|
86
|
+
const { values } = parse(rest, { headed: { type: 'boolean' } }, false);
|
|
87
|
+
return values.headed ? { command: 'install', headed: true } : { command: 'install' };
|
|
88
|
+
}
|
|
89
|
+
case 'login':
|
|
90
|
+
return parseLogin(rest, env);
|
|
91
|
+
case 'sessions':
|
|
92
|
+
return parseSessions(rest);
|
|
93
|
+
case 'doctor': {
|
|
94
|
+
if (wantsHelp(rest))
|
|
95
|
+
return { command: 'help', topic: 'doctor' };
|
|
96
|
+
const { values } = parse(rest, { json: { type: 'boolean' } }, false);
|
|
97
|
+
return { command: 'doctor', json: Boolean(values.json) };
|
|
98
|
+
}
|
|
99
|
+
case 'devices': {
|
|
100
|
+
if (wantsHelp(rest))
|
|
101
|
+
return { command: 'help', topic: 'devices' };
|
|
102
|
+
const { values } = parse(rest, { json: { type: 'boolean' } }, false);
|
|
103
|
+
return { command: 'devices', json: Boolean(values.json) };
|
|
104
|
+
}
|
|
105
|
+
case 'shot':
|
|
106
|
+
return parseShot(rest);
|
|
107
|
+
default:
|
|
108
|
+
throw new ArgError(`Unknown command "${cmd}". Run \`rpc --help\`.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function parseShot(args) {
|
|
112
|
+
if (wantsHelp(args))
|
|
113
|
+
return { command: 'help', topic: 'shot' };
|
|
114
|
+
const { values, positionals } = parse(args, {
|
|
115
|
+
devices: { type: 'string', short: 'd' },
|
|
116
|
+
bezels: { type: 'boolean', short: 'b' },
|
|
117
|
+
'full-page': { type: 'boolean', short: 'f' },
|
|
118
|
+
out: { type: 'string', short: 'o' },
|
|
119
|
+
'wait-for': { type: 'string' },
|
|
120
|
+
wait: { type: 'string' },
|
|
121
|
+
'color-scheme': { type: 'string' },
|
|
122
|
+
timeout: { type: 'string' },
|
|
123
|
+
'no-emulate': { type: 'boolean' },
|
|
124
|
+
slices: { type: 'boolean' },
|
|
125
|
+
'slice-width': { type: 'string' },
|
|
126
|
+
'max-height': { type: 'string' },
|
|
127
|
+
'reduced-motion': { type: 'boolean' },
|
|
128
|
+
json: { type: 'boolean' },
|
|
129
|
+
open: { type: 'boolean' },
|
|
130
|
+
cookie: { type: 'string', multiple: true },
|
|
131
|
+
header: { type: 'string', multiple: true },
|
|
132
|
+
'storage-state': { type: 'string' },
|
|
133
|
+
'no-session': { type: 'boolean' },
|
|
134
|
+
'allow-origin': { type: 'string', multiple: true },
|
|
135
|
+
}, true);
|
|
136
|
+
if (positionals.length === 0)
|
|
137
|
+
throw new ArgError('Missing <url>. Usage: rpc shot <url> [options]');
|
|
138
|
+
if (positionals.length > 1)
|
|
139
|
+
throw new ArgError(`Expected one URL, got: ${positionals.join(' ')}`);
|
|
140
|
+
let options;
|
|
141
|
+
try {
|
|
142
|
+
options = defaultShotOptions(positionals[0]);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
throw new ArgError(err.message);
|
|
146
|
+
}
|
|
147
|
+
if (typeof values.devices === 'string') {
|
|
148
|
+
const list = splitList(values.devices);
|
|
149
|
+
if (list.length === 0)
|
|
150
|
+
throw new ArgError('--devices is empty');
|
|
151
|
+
try {
|
|
152
|
+
options.devices = resolveDevices(list).map(d => d.id);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
throw new ArgError(err.message);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (values.bezels)
|
|
159
|
+
options.bezels = true;
|
|
160
|
+
if (values['full-page'])
|
|
161
|
+
options.fullPage = true;
|
|
162
|
+
if (typeof values.out === 'string')
|
|
163
|
+
options.outDir = values.out;
|
|
164
|
+
if (typeof values['wait-for'] === 'string')
|
|
165
|
+
options.waitFor = values['wait-for'];
|
|
166
|
+
if (typeof values.wait === 'string')
|
|
167
|
+
options.waitMs = parseMs('--wait', values.wait);
|
|
168
|
+
if (typeof values.timeout === 'string')
|
|
169
|
+
options.timeoutMs = parseMs('--timeout', values.timeout);
|
|
170
|
+
if (typeof values['color-scheme'] === 'string') {
|
|
171
|
+
const s = values['color-scheme'];
|
|
172
|
+
if (!COLOR_SCHEMES.includes(s)) {
|
|
173
|
+
throw new ArgError(`--color-scheme must be one of ${COLOR_SCHEMES.join(', ')}`);
|
|
174
|
+
}
|
|
175
|
+
options.colorScheme = s;
|
|
176
|
+
}
|
|
177
|
+
if (values['no-emulate'])
|
|
178
|
+
options.emulate = false;
|
|
179
|
+
if (values.slices)
|
|
180
|
+
options.saveSlices = true;
|
|
181
|
+
if (values['reduced-motion'])
|
|
182
|
+
options.reducedMotion = true;
|
|
183
|
+
if (typeof values['slice-width'] === 'string') {
|
|
184
|
+
options.sliceWidth = parseInt_('--slice-width', values['slice-width'], 256, 4096);
|
|
185
|
+
}
|
|
186
|
+
if (typeof values['max-height'] === 'string') {
|
|
187
|
+
options.maxHeight = parseInt_('--max-height', values['max-height'], 500, 200_000);
|
|
188
|
+
}
|
|
189
|
+
const auth = {};
|
|
190
|
+
try {
|
|
191
|
+
if (values.cookie?.length)
|
|
192
|
+
auth.cookies = values.cookie.map(parseCookie);
|
|
193
|
+
if (values.header?.length)
|
|
194
|
+
auth.headers = Object.fromEntries(values.header.map(parseHeader));
|
|
195
|
+
if (typeof values['storage-state'] === 'string')
|
|
196
|
+
auth.storageState = values['storage-state'];
|
|
197
|
+
if (values['no-session'])
|
|
198
|
+
auth.useSession = false;
|
|
199
|
+
if (values['allow-origin']?.length)
|
|
200
|
+
auth.allowOrigins = values['allow-origin'].flatMap(splitList).map(toOrigin);
|
|
201
|
+
if (Object.keys(auth).length) {
|
|
202
|
+
resolveAuth(options.url, auth); // validate now: bad input is a usage error
|
|
203
|
+
options.auth = auth;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
throw new ArgError(err.message);
|
|
208
|
+
}
|
|
209
|
+
return { command: 'shot', options, json: Boolean(values.json), open: Boolean(values.open) };
|
|
210
|
+
}
|
|
211
|
+
function parseLogin(args, env) {
|
|
212
|
+
if (wantsHelp(args))
|
|
213
|
+
return { command: 'help', topic: 'login' };
|
|
214
|
+
const { values, positionals } = parse(args, {
|
|
215
|
+
save: { type: 'string' },
|
|
216
|
+
form: { type: 'boolean' },
|
|
217
|
+
email: { type: 'string' },
|
|
218
|
+
password: { type: 'string' },
|
|
219
|
+
'email-selector': { type: 'string' },
|
|
220
|
+
'password-selector': { type: 'string' },
|
|
221
|
+
'submit-selector': { type: 'string' },
|
|
222
|
+
timeout: { type: 'string' },
|
|
223
|
+
json: { type: 'boolean' },
|
|
224
|
+
}, true);
|
|
225
|
+
if (positionals.length !== 1)
|
|
226
|
+
throw new ArgError('Usage: rpc login <url> [--form] [--save <file>]');
|
|
227
|
+
let url;
|
|
228
|
+
try {
|
|
229
|
+
url = normalizeUrl(positionals[0]);
|
|
230
|
+
toOrigin(url);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
throw new ArgError(err.message);
|
|
234
|
+
}
|
|
235
|
+
const cmd = {
|
|
236
|
+
command: 'login',
|
|
237
|
+
url,
|
|
238
|
+
timeoutMs: typeof values.timeout === 'string' ? parseMs('--timeout', values.timeout) : DEFAULT_TIMEOUT_MS,
|
|
239
|
+
json: Boolean(values.json),
|
|
240
|
+
};
|
|
241
|
+
if (typeof values.save === 'string')
|
|
242
|
+
cmd.save = values.save;
|
|
243
|
+
const formOnly = ['email', 'password', 'email-selector', 'password-selector', 'submit-selector'];
|
|
244
|
+
if (!values.form) {
|
|
245
|
+
const used = formOnly.filter(k => values[k] !== undefined);
|
|
246
|
+
if (used.length)
|
|
247
|
+
throw new ArgError(`--${used[0]} needs --form`);
|
|
248
|
+
return cmd;
|
|
249
|
+
}
|
|
250
|
+
const email = values.email ?? env.RPC_LOGIN_EMAIL;
|
|
251
|
+
const password = values.password ?? env.RPC_LOGIN_PASSWORD;
|
|
252
|
+
if (!email)
|
|
253
|
+
throw new ArgError('--form needs an email: set RPC_LOGIN_EMAIL (or pass --email)');
|
|
254
|
+
if (!password)
|
|
255
|
+
throw new ArgError('--form needs a password: set RPC_LOGIN_PASSWORD');
|
|
256
|
+
cmd.form = {
|
|
257
|
+
email,
|
|
258
|
+
password,
|
|
259
|
+
...(values['email-selector'] ? { emailSelector: values['email-selector'] } : {}),
|
|
260
|
+
...(values['password-selector'] ? { passwordSelector: values['password-selector'] } : {}),
|
|
261
|
+
...(values['submit-selector'] ? { submitSelector: values['submit-selector'] } : {}),
|
|
262
|
+
};
|
|
263
|
+
if (values.password !== undefined)
|
|
264
|
+
cmd.passwordFromFlag = true;
|
|
265
|
+
return cmd;
|
|
266
|
+
}
|
|
267
|
+
function parseSessions(args) {
|
|
268
|
+
if (wantsHelp(args))
|
|
269
|
+
return { command: 'help', topic: 'sessions' };
|
|
270
|
+
const { values, positionals } = parse(args, { json: { type: 'boolean' } }, true);
|
|
271
|
+
const [action = 'list', origin, ...extra] = positionals;
|
|
272
|
+
if (action !== 'list' && action !== 'clear')
|
|
273
|
+
throw new ArgError(`Unknown sessions action "${action}". Use list or clear.`);
|
|
274
|
+
if (extra.length || (action === 'list' && origin))
|
|
275
|
+
throw new ArgError('Usage: rpc sessions list | rpc sessions clear [origin]');
|
|
276
|
+
if (origin) {
|
|
277
|
+
try {
|
|
278
|
+
toOrigin(origin);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
throw new ArgError(err.message);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { command: 'sessions', action, ...(origin ? { origin } : {}), json: Boolean(values.json) };
|
|
285
|
+
}
|
|
286
|
+
function parse(args, options, allowPositionals) {
|
|
287
|
+
try {
|
|
288
|
+
return parseArgs({ args, options, allowPositionals, strict: true });
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
throw new ArgError(err.message);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function splitList(s) {
|
|
295
|
+
return s.split(',').map(x => x.trim()).filter(Boolean);
|
|
296
|
+
}
|
|
297
|
+
function parseInt_(flag, raw, min, max) {
|
|
298
|
+
const n = Number(raw);
|
|
299
|
+
if (!Number.isInteger(n) || n < min || n > max)
|
|
300
|
+
throw new ArgError(`${flag} must be an integer from ${min} to ${max}`);
|
|
301
|
+
return n;
|
|
302
|
+
}
|
|
303
|
+
function parseMs(flag, raw) {
|
|
304
|
+
const n = Number(raw);
|
|
305
|
+
if (!Number.isFinite(n) || n < 0)
|
|
306
|
+
throw new ArgError(`${flag} must be a non-negative number of ms`);
|
|
307
|
+
return Math.round(n);
|
|
308
|
+
}
|