patchrome 0.1.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/LICENSE +21 -0
- package/README.md +514 -0
- package/bin/patchrome.js +10 -0
- package/dist/build-id.d.ts +2 -0
- package/dist/build-id.js +21 -0
- package/dist/challenges.d.ts +22 -0
- package/dist/challenges.js +97 -0
- package/dist/chrome-profiles.d.ts +17 -0
- package/dist/chrome-profiles.js +141 -0
- package/dist/cli-options.d.ts +131 -0
- package/dist/cli-options.js +43 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.js +572 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +210 -0
- package/dist/commands.d.ts +58 -0
- package/dist/commands.js +1076 -0
- package/dist/completions.d.ts +1 -0
- package/dist/completions.js +114 -0
- package/dist/copy-guard.d.ts +75 -0
- package/dist/copy-guard.js +167 -0
- package/dist/daemon.d.ts +7 -0
- package/dist/daemon.js +313 -0
- package/dist/diagnostics.d.ts +44 -0
- package/dist/diagnostics.js +117 -0
- package/dist/engine.d.ts +51 -0
- package/dist/engine.js +257 -0
- package/dist/events.d.ts +41 -0
- package/dist/events.js +106 -0
- package/dist/extract.d.ts +27 -0
- package/dist/extract.js +62 -0
- package/dist/focus.d.ts +1 -0
- package/dist/focus.js +44 -0
- package/dist/glob.d.ts +4 -0
- package/dist/glob.js +63 -0
- package/dist/har.d.ts +105 -0
- package/dist/har.js +88 -0
- package/dist/history.d.ts +35 -0
- package/dist/history.js +277 -0
- package/dist/host-platform.d.ts +5 -0
- package/dist/host-platform.js +19 -0
- package/dist/host-prompts-macos.d.ts +2 -0
- package/dist/host-prompts-macos.js +102 -0
- package/dist/host-prompts-wsl.d.ts +6 -0
- package/dist/host-prompts-wsl.js +64 -0
- package/dist/host-prompts.d.ts +3 -0
- package/dist/host-prompts.js +25 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +47 -0
- package/dist/network.d.ts +54 -0
- package/dist/network.js +204 -0
- package/dist/origin-storage.d.ts +31 -0
- package/dist/origin-storage.js +82 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.js +52 -0
- package/dist/pipe.d.ts +9 -0
- package/dist/pipe.js +73 -0
- package/dist/profile-mode.d.ts +10 -0
- package/dist/profile-mode.js +42 -0
- package/dist/protocol-help.d.ts +34 -0
- package/dist/protocol-help.js +66 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +89 -0
- package/dist/refs.d.ts +9 -0
- package/dist/refs.js +46 -0
- package/dist/routes.d.ts +20 -0
- package/dist/routes.js +106 -0
- package/dist/runner.d.ts +20 -0
- package/dist/runner.js +81 -0
- package/dist/session-name.d.ts +9 -0
- package/dist/session-name.js +50 -0
- package/dist/session-store.d.ts +5 -0
- package/dist/session-store.js +58 -0
- package/dist/sessions.d.ts +47 -0
- package/dist/sessions.js +171 -0
- package/dist/tab-groups.d.ts +9 -0
- package/dist/tab-groups.js +13 -0
- package/dist/targets.d.ts +43 -0
- package/dist/targets.js +229 -0
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +31 -0
- package/dist/wait.d.ts +24 -0
- package/dist/wait.js +88 -0
- package/examples/go/go.mod +3 -0
- package/examples/go/main.go +104 -0
- package/examples/hn-front-page.sh +18 -0
- package/examples/hn-front-page.ts +24 -0
- package/examples/hn_front_page.py +56 -0
- package/extension/tab-groups/manifest.json +8 -0
- package/extension/tab-groups/service-worker.js +41 -0
- package/package.json +60 -0
- package/skills/patchrome/SKILL.md +74 -0
- package/skills/patchrome/references/commands.md +130 -0
- package/skills/patchrome/references/debugging.md +20 -0
- package/skills/patchrome/references/hard-pages.md +49 -0
- package/skills/patchrome/references/logins.md +46 -0
- package/skills/patchrome/references/scraping.md +51 -0
- package/skills/patchrome/references/scripting.md +79 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { chromeUserDataDirFrom } from "./chrome-profiles.js";
|
|
5
|
+
import { cliOptions } from "./cli-options.js";
|
|
6
|
+
import { DaemonConnection } from "./client.js";
|
|
7
|
+
import { zshCompletionScript } from "./completions.js";
|
|
8
|
+
import { auditLine, readAuditLog } from "./copy-guard.js";
|
|
9
|
+
import { auditLogPathFrom, isValidName, profilePaths, sessionFolderName } from "./paths.js";
|
|
10
|
+
import { fixProfileMode, isProfileMode, profileModes } from "./profile-mode.js";
|
|
11
|
+
import { CommandError, exitCodeFor, } from "./protocol.js";
|
|
12
|
+
import { lookupProcess, resolveSessionName } from "./session-name.js";
|
|
13
|
+
import { clearHistory, formatHistory, historyFileName, historyFormats, isHistoryFormat, readHistory, } from "./history.js";
|
|
14
|
+
import { runPipe } from "./pipe.js";
|
|
15
|
+
import { CommandRunner, toCommandError } from "./runner.js";
|
|
16
|
+
const usage = `usage: patchrome [--profile <name>] [--session <name>] [--json] [--timeout-ms <n>] <command>
|
|
17
|
+
|
|
18
|
+
an element is a ref from the latest snapshot (@e12), or a locator that works on every run:
|
|
19
|
+
--role <role> [--name <name>] [--exact] | --text <text> [--exact] | --label <text> [--exact] | --selector <css>
|
|
20
|
+
with [--nth <n>] to pick a match other than the first, and [--frame <iframe-css>]
|
|
21
|
+
values print inline up to 2 KB and go to a file past that; --inline or --out <file> fixes the shape
|
|
22
|
+
|
|
23
|
+
tabs and navigation
|
|
24
|
+
open [url] [--wait load|domcontentloaded|networkidle] [--isolated]
|
|
25
|
+
tabs [--all]
|
|
26
|
+
switch <tab>
|
|
27
|
+
close [tab]
|
|
28
|
+
goto <url> [--wait ...]
|
|
29
|
+
|
|
30
|
+
reading
|
|
31
|
+
snapshot [--inline | --out <file>]
|
|
32
|
+
screenshot [--full] [<element>] [--out <file>]
|
|
33
|
+
text [<element>] [--inline | --out <file>]
|
|
34
|
+
eval <js> [--main-world] [--inline | --out <file>]
|
|
35
|
+
extract <schema.json|inline-json> [<element>] [--inline | --out <file>]
|
|
36
|
+
|
|
37
|
+
waiting and watching
|
|
38
|
+
wait <element> | --url <glob> | --title <text> [--gone]
|
|
39
|
+
wait --load load|domcontentloaded|networkidle
|
|
40
|
+
watch [--events navigation,load,response,console,error] [--url <glob>] [--count <n>]
|
|
41
|
+
|
|
42
|
+
acting
|
|
43
|
+
click <ref> | <element> | --at <x>,<y>
|
|
44
|
+
fill <ref> <text> | fill <element> <text>
|
|
45
|
+
type <text>
|
|
46
|
+
press <key>
|
|
47
|
+
challenge [--handoff]
|
|
48
|
+
|
|
49
|
+
network
|
|
50
|
+
network list [--url <glob>] [--type xhr,fetch] [--status 4xx] [--inline | --out <file>]
|
|
51
|
+
network get <id> | --url <glob> [--body] [--inline | --out <file>]
|
|
52
|
+
network har start|stop [--out <file>]
|
|
53
|
+
route block <glob>
|
|
54
|
+
route mock <glob> <file>
|
|
55
|
+
route list|clear
|
|
56
|
+
|
|
57
|
+
state
|
|
58
|
+
login <url> [--until <url-glob>]
|
|
59
|
+
cookies [--domain <domain>] [--inline | --out <file>]
|
|
60
|
+
state save <file>
|
|
61
|
+
state load <file>
|
|
62
|
+
state import <site> [--from <chrome-profile>]
|
|
63
|
+
|
|
64
|
+
scripting
|
|
65
|
+
pipe [--bail] JSON requests on stdin, one JSON response per line on stdout
|
|
66
|
+
session history [--format sh|jsonl] [--out <file>]
|
|
67
|
+
session history clear
|
|
68
|
+
|
|
69
|
+
debug profile only
|
|
70
|
+
console [--level debug|info|warning|error] [--follow]
|
|
71
|
+
errors
|
|
72
|
+
trace start|stop [--out <file>]
|
|
73
|
+
cdp <Domain.method> [params-json]
|
|
74
|
+
cdp help [Domain|Domain.method]
|
|
75
|
+
devtools-url
|
|
76
|
+
|
|
77
|
+
lifecycle
|
|
78
|
+
profile create <name> --mode stealth|debug
|
|
79
|
+
session
|
|
80
|
+
session label <text>
|
|
81
|
+
session close [session|pattern]
|
|
82
|
+
sessions [pattern]
|
|
83
|
+
daemon status|stop|logs
|
|
84
|
+
audit [--count <n>]
|
|
85
|
+
completions zsh`;
|
|
86
|
+
export function parseCli(argv, env, sessionFallback) {
|
|
87
|
+
const { values, positionals } = parseArgs({
|
|
88
|
+
args: argv,
|
|
89
|
+
allowPositionals: true,
|
|
90
|
+
strict: true,
|
|
91
|
+
options: cliOptions,
|
|
92
|
+
});
|
|
93
|
+
const profile = values.profile ?? env.PATCHROME_PROFILE ?? "stealth";
|
|
94
|
+
if (!isValidName(profile))
|
|
95
|
+
throw new CommandError("bad_args", `invalid profile name ${profile}`, "use letters, digits, dot, dash, underscore");
|
|
96
|
+
const [verb, ...rest] = positionals;
|
|
97
|
+
// A person signs in by hand, which takes minutes, not the 30 s an agent command gets.
|
|
98
|
+
// An import starts a second Chrome and copies every IndexedDB record of the site. Imports and loads also
|
|
99
|
+
// wait up to a minute for the person to approve the copy.
|
|
100
|
+
const defaultTimeoutMs = verb === "login" ||
|
|
101
|
+
verb === "watch" ||
|
|
102
|
+
(verb === "challenge" && values.handoff) ||
|
|
103
|
+
(verb === "console" && values.follow)
|
|
104
|
+
? 600_000
|
|
105
|
+
: verb === "state" && (positionals[1] === "import" || positionals[1] === "load")
|
|
106
|
+
? 120_000
|
|
107
|
+
: 30_000;
|
|
108
|
+
const timeoutMs = values["timeout-ms"] === undefined ? defaultTimeoutMs : Number(values["timeout-ms"]);
|
|
109
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0)
|
|
110
|
+
throw new CommandError("bad_args", `--timeout-ms must be a positive integer, got ${values["timeout-ms"]}`);
|
|
111
|
+
const need = (index, name) => {
|
|
112
|
+
const value = rest[index];
|
|
113
|
+
if (value === undefined)
|
|
114
|
+
throw new CommandError("bad_args", `${verb} needs <${name}>`, usage.split("\n").find((line) => line.trim().startsWith(`${verb} `)));
|
|
115
|
+
return value;
|
|
116
|
+
};
|
|
117
|
+
const out = values.out === undefined ? undefined : resolve(values.out);
|
|
118
|
+
if (out !== undefined && values.inline)
|
|
119
|
+
throw new CommandError("bad_args", "--out and --inline pick opposite outputs; give one");
|
|
120
|
+
// One element on the page, for commands that act on or read one.
|
|
121
|
+
const element = {
|
|
122
|
+
ref: values.ref,
|
|
123
|
+
selector: values.selector,
|
|
124
|
+
role: values.role,
|
|
125
|
+
name: values.name,
|
|
126
|
+
exact: values.exact,
|
|
127
|
+
nth: values.nth,
|
|
128
|
+
label: values.label,
|
|
129
|
+
text: values.text,
|
|
130
|
+
frame: values.frame,
|
|
131
|
+
};
|
|
132
|
+
const hasLocator = [values.selector, values.role, values.label, values.text].some((value) => value !== undefined);
|
|
133
|
+
const refuseExtra = (allowed) => {
|
|
134
|
+
if (rest.length > allowed)
|
|
135
|
+
throw new CommandError("bad_args", `${verb} takes at most ${allowed} arguments, got ${rest.length}`);
|
|
136
|
+
};
|
|
137
|
+
let command;
|
|
138
|
+
let args = {};
|
|
139
|
+
let shouldStartDaemon = true;
|
|
140
|
+
switch (verb) {
|
|
141
|
+
case "open":
|
|
142
|
+
refuseExtra(1);
|
|
143
|
+
command = "open";
|
|
144
|
+
args = { url: rest[0], wait: values.wait, isolated: values.isolated };
|
|
145
|
+
break;
|
|
146
|
+
case "tabs":
|
|
147
|
+
refuseExtra(0);
|
|
148
|
+
command = "tabs";
|
|
149
|
+
args = { all: values.all };
|
|
150
|
+
break;
|
|
151
|
+
case "switch":
|
|
152
|
+
refuseExtra(1);
|
|
153
|
+
command = "switch";
|
|
154
|
+
args = { tab: need(0, "tab") };
|
|
155
|
+
break;
|
|
156
|
+
case "close":
|
|
157
|
+
refuseExtra(1);
|
|
158
|
+
command = "close";
|
|
159
|
+
args = { tab: rest[0] };
|
|
160
|
+
break;
|
|
161
|
+
case "goto":
|
|
162
|
+
refuseExtra(1);
|
|
163
|
+
command = "goto";
|
|
164
|
+
args = { url: need(0, "url"), wait: values.wait };
|
|
165
|
+
break;
|
|
166
|
+
case "snapshot":
|
|
167
|
+
refuseExtra(0);
|
|
168
|
+
command = "snapshot";
|
|
169
|
+
args = { inline: values.inline, out };
|
|
170
|
+
break;
|
|
171
|
+
case "screenshot":
|
|
172
|
+
refuseExtra(0);
|
|
173
|
+
command = "screenshot";
|
|
174
|
+
args = { full: values.full, ...element, out };
|
|
175
|
+
break;
|
|
176
|
+
case "text":
|
|
177
|
+
refuseExtra(0);
|
|
178
|
+
command = "text";
|
|
179
|
+
args = { ...element, inline: values.inline, out };
|
|
180
|
+
break;
|
|
181
|
+
case "eval":
|
|
182
|
+
refuseExtra(1);
|
|
183
|
+
command = "eval";
|
|
184
|
+
args = { js: need(0, "js"), mainWorld: values["main-world"], inline: values.inline, out };
|
|
185
|
+
break;
|
|
186
|
+
case "click":
|
|
187
|
+
refuseExtra(1);
|
|
188
|
+
command = "click";
|
|
189
|
+
if (rest[0] === undefined && values.ref === undefined && !hasLocator && values.at === undefined)
|
|
190
|
+
need(0, "ref");
|
|
191
|
+
args = { ...element, ref: rest[0] ?? values.ref, at: values.at };
|
|
192
|
+
break;
|
|
193
|
+
case "fill":
|
|
194
|
+
// With a locator flag the only positional is the text.
|
|
195
|
+
command = "fill";
|
|
196
|
+
if (hasLocator) {
|
|
197
|
+
refuseExtra(1);
|
|
198
|
+
args = { ...element, fillText: need(0, "text") };
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
refuseExtra(2);
|
|
202
|
+
args = { ...element, ref: need(0, "ref"), fillText: need(1, "text") };
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
case "type":
|
|
206
|
+
refuseExtra(1);
|
|
207
|
+
command = "type";
|
|
208
|
+
args = { text: need(0, "text") };
|
|
209
|
+
break;
|
|
210
|
+
case "challenge":
|
|
211
|
+
refuseExtra(0);
|
|
212
|
+
command = "challenge";
|
|
213
|
+
args = { handoff: values.handoff };
|
|
214
|
+
break;
|
|
215
|
+
case "press":
|
|
216
|
+
refuseExtra(1);
|
|
217
|
+
command = "press";
|
|
218
|
+
args = { key: need(0, "key") };
|
|
219
|
+
break;
|
|
220
|
+
case "extract":
|
|
221
|
+
refuseExtra(1);
|
|
222
|
+
command = "extract";
|
|
223
|
+
args = { schema: schemaArg(need(0, "schema")), ...element, inline: values.inline, out };
|
|
224
|
+
break;
|
|
225
|
+
case "wait":
|
|
226
|
+
refuseExtra(0);
|
|
227
|
+
command = "wait";
|
|
228
|
+
if (values.ref !== undefined)
|
|
229
|
+
throw new CommandError("bad_args", "wait does not take a ref", "wait for a locator, a URL or a title; refs go stale when the page changes");
|
|
230
|
+
args = { ...element, url: values.url, title: values.title, load: values.load, gone: values.gone };
|
|
231
|
+
break;
|
|
232
|
+
case "watch":
|
|
233
|
+
refuseExtra(0);
|
|
234
|
+
command = "watch";
|
|
235
|
+
args = {
|
|
236
|
+
events: values.events,
|
|
237
|
+
url: values.url,
|
|
238
|
+
count: values.count === undefined ? undefined : Number(values.count),
|
|
239
|
+
};
|
|
240
|
+
if (args.count !== undefined && (!Number.isInteger(args.count) || Number(args.count) <= 0))
|
|
241
|
+
throw new CommandError("bad_args", `--count must be a positive integer, got ${values.count}`);
|
|
242
|
+
break;
|
|
243
|
+
case "completions": {
|
|
244
|
+
refuseExtra(1);
|
|
245
|
+
const shell = need(0, "shell");
|
|
246
|
+
if (shell !== "zsh")
|
|
247
|
+
throw new CommandError("bad_args", `completions for ${shell} are not available`, "completions zsh");
|
|
248
|
+
return { kind: "completions", shell };
|
|
249
|
+
}
|
|
250
|
+
case "network": {
|
|
251
|
+
const action = need(0, "list|get|har");
|
|
252
|
+
if (action === "list") {
|
|
253
|
+
refuseExtra(1);
|
|
254
|
+
command = "network-list";
|
|
255
|
+
args = { url: values.url, type: values.type, status: values.status, inline: values.inline, out };
|
|
256
|
+
}
|
|
257
|
+
else if (action === "get") {
|
|
258
|
+
refuseExtra(2);
|
|
259
|
+
command = "network-get";
|
|
260
|
+
if (rest[1] === undefined && values.url === undefined)
|
|
261
|
+
need(1, "id");
|
|
262
|
+
args = { id: rest[1], url: values.url, body: values.body, inline: values.inline, out };
|
|
263
|
+
}
|
|
264
|
+
else if (action === "har") {
|
|
265
|
+
refuseExtra(2);
|
|
266
|
+
const harAction = need(1, "start|stop");
|
|
267
|
+
if (harAction !== "start" && harAction !== "stop")
|
|
268
|
+
throw new CommandError("bad_args", `network har ${harAction} is not a command`, "network har start|stop");
|
|
269
|
+
command = harAction === "start" ? "network-har-start" : "network-har-stop";
|
|
270
|
+
args = { out };
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
throw new CommandError("bad_args", `network ${action} is not a command`, "network list|get|har");
|
|
274
|
+
}
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "route": {
|
|
278
|
+
const action = need(0, "block|mock|list|clear");
|
|
279
|
+
if (action === "block") {
|
|
280
|
+
refuseExtra(2);
|
|
281
|
+
command = "route-block";
|
|
282
|
+
args = { glob: need(1, "glob") };
|
|
283
|
+
}
|
|
284
|
+
else if (action === "mock") {
|
|
285
|
+
refuseExtra(3);
|
|
286
|
+
command = "route-mock";
|
|
287
|
+
args = { glob: need(1, "glob"), file: resolve(need(2, "file")) };
|
|
288
|
+
}
|
|
289
|
+
else if (action === "list" || action === "clear") {
|
|
290
|
+
refuseExtra(1);
|
|
291
|
+
command = action === "list" ? "route-list" : "route-clear";
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
throw new CommandError("bad_args", `route ${action} is not a command`, "route block|mock|list|clear");
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case "login":
|
|
299
|
+
refuseExtra(1);
|
|
300
|
+
command = "login";
|
|
301
|
+
args = { url: need(0, "url"), until: values.until };
|
|
302
|
+
break;
|
|
303
|
+
case "cookies":
|
|
304
|
+
refuseExtra(0);
|
|
305
|
+
command = "cookies";
|
|
306
|
+
args = { domain: values.domain, inline: values.inline, out };
|
|
307
|
+
break;
|
|
308
|
+
case "state": {
|
|
309
|
+
refuseExtra(2);
|
|
310
|
+
const action = need(0, "save|load|import");
|
|
311
|
+
if (action === "import") {
|
|
312
|
+
command = "state-import";
|
|
313
|
+
// The CLI's environment names the Chrome to read, not the long-running daemon's.
|
|
314
|
+
args = { site: need(1, "site"), from: values.from, chromeUserDataDir: chromeUserDataDirFrom(env) };
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
if (action !== "save" && action !== "load")
|
|
318
|
+
throw new CommandError("bad_args", `state ${action} is not a command`, "state save|load <file>, state import <site> [--from <chrome-profile>]");
|
|
319
|
+
command = action === "save" ? "state-save" : "state-load";
|
|
320
|
+
args = { file: resolve(need(1, "file")) };
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case "console":
|
|
324
|
+
refuseExtra(0);
|
|
325
|
+
command = "console";
|
|
326
|
+
args = { level: values.level, follow: values.follow, inline: values.inline, out };
|
|
327
|
+
break;
|
|
328
|
+
case "errors":
|
|
329
|
+
refuseExtra(0);
|
|
330
|
+
command = "errors";
|
|
331
|
+
args = { inline: values.inline, out };
|
|
332
|
+
break;
|
|
333
|
+
case "trace": {
|
|
334
|
+
refuseExtra(1);
|
|
335
|
+
const action = need(0, "start|stop");
|
|
336
|
+
if (action !== "start" && action !== "stop")
|
|
337
|
+
throw new CommandError("bad_args", `trace ${action} is not a command`, "trace start|stop");
|
|
338
|
+
command = action === "start" ? "trace-start" : "trace-stop";
|
|
339
|
+
args = { out };
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
case "cdp":
|
|
343
|
+
refuseExtra(2);
|
|
344
|
+
if (rest[0] === "help") {
|
|
345
|
+
command = "cdp-help";
|
|
346
|
+
args = { topic: rest[1], inline: values.inline, out };
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
command = "cdp";
|
|
350
|
+
args = { method: need(0, "Domain.method"), params: rest[1], inline: values.inline, out };
|
|
351
|
+
break;
|
|
352
|
+
case "devtools-url":
|
|
353
|
+
refuseExtra(0);
|
|
354
|
+
command = "devtools-url";
|
|
355
|
+
break;
|
|
356
|
+
case "profile": {
|
|
357
|
+
refuseExtra(2);
|
|
358
|
+
const action = need(0, "create");
|
|
359
|
+
if (action !== "create")
|
|
360
|
+
throw new CommandError("bad_args", `profile ${action} is not a command`, "profile create <name> --mode stealth|debug");
|
|
361
|
+
const name = need(1, "name");
|
|
362
|
+
if (!isValidName(name))
|
|
363
|
+
throw new CommandError("bad_args", `invalid profile name ${name}`, "use letters, digits, dot, dash, underscore");
|
|
364
|
+
if (values.mode === undefined || !isProfileMode(values.mode))
|
|
365
|
+
throw new CommandError("bad_args", `profile create needs --mode ${profileModes.join("|")}`);
|
|
366
|
+
return { kind: "create-profile", profile: name, mode: values.mode, isJson: values.json };
|
|
367
|
+
}
|
|
368
|
+
case "session":
|
|
369
|
+
if (rest[0] === "history") {
|
|
370
|
+
refuseExtra(2);
|
|
371
|
+
if (rest[1] !== undefined && rest[1] !== "clear")
|
|
372
|
+
throw new CommandError("bad_args", `session history ${rest[1]} is not a command`, "session history [--format sh|jsonl] [--out <file>], session history clear");
|
|
373
|
+
const format = values.format ?? "sh";
|
|
374
|
+
if (!isHistoryFormat(format))
|
|
375
|
+
throw new CommandError("bad_args", `--format must be one of ${historyFormats.join(", ")}, got ${format}`);
|
|
376
|
+
return {
|
|
377
|
+
kind: "history",
|
|
378
|
+
profile,
|
|
379
|
+
session: values.session ?? sessionFallback(),
|
|
380
|
+
format,
|
|
381
|
+
out,
|
|
382
|
+
isClear: rest[1] === "clear",
|
|
383
|
+
isJson: values.json,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (rest[0] === "close") {
|
|
387
|
+
refuseExtra(2);
|
|
388
|
+
command = "session-close";
|
|
389
|
+
args = { pattern: rest[1] };
|
|
390
|
+
shouldStartDaemon = false;
|
|
391
|
+
}
|
|
392
|
+
else if (rest[0] === "label") {
|
|
393
|
+
refuseExtra(2);
|
|
394
|
+
command = "session-label";
|
|
395
|
+
args = { label: need(1, "text") };
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
refuseExtra(0);
|
|
399
|
+
command = "session";
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
case "sessions":
|
|
403
|
+
refuseExtra(1);
|
|
404
|
+
command = "sessions";
|
|
405
|
+
args = { pattern: rest[0] };
|
|
406
|
+
shouldStartDaemon = false;
|
|
407
|
+
break;
|
|
408
|
+
case "daemon": {
|
|
409
|
+
refuseExtra(1);
|
|
410
|
+
const action = need(0, "status|stop|logs");
|
|
411
|
+
if (action === "logs")
|
|
412
|
+
return { kind: "logs", profile, isJson: values.json };
|
|
413
|
+
if (action !== "status" && action !== "stop")
|
|
414
|
+
throw new CommandError("bad_args", `daemon ${action} is not a command`, "daemon status|stop|logs");
|
|
415
|
+
command = action === "status" ? "daemon-status" : "daemon-stop";
|
|
416
|
+
shouldStartDaemon = false;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case "pipe":
|
|
420
|
+
refuseExtra(0);
|
|
421
|
+
return { kind: "pipe", profile, session: values.session ?? sessionFallback(), isBail: values.bail };
|
|
422
|
+
case "audit": {
|
|
423
|
+
refuseExtra(0);
|
|
424
|
+
const count = values.count === undefined ? 20 : Number(values.count);
|
|
425
|
+
if (!Number.isInteger(count) || count <= 0)
|
|
426
|
+
throw new CommandError("bad_args", `--count must be a positive integer, got ${values.count}`);
|
|
427
|
+
return { kind: "audit", count, isJson: values.json };
|
|
428
|
+
}
|
|
429
|
+
case undefined:
|
|
430
|
+
throw new CommandError("bad_args", "no command given", usage);
|
|
431
|
+
default:
|
|
432
|
+
throw new CommandError("bad_args", `unknown command ${verb}`, usage);
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
profile,
|
|
436
|
+
session: values.session ?? sessionFallback(),
|
|
437
|
+
isJson: values.json,
|
|
438
|
+
timeoutMs,
|
|
439
|
+
command,
|
|
440
|
+
args,
|
|
441
|
+
shouldStartDaemon,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
// The daemon runs in another directory, so file paths leave the CLI absolute. Inline JSON passes through.
|
|
445
|
+
function schemaArg(raw) {
|
|
446
|
+
return raw.trimStart().startsWith("{") ? raw : resolve(raw);
|
|
447
|
+
}
|
|
448
|
+
// These never touch the daemon: logs, audit and history read files, and a profile must exist before its
|
|
449
|
+
// daemon starts.
|
|
450
|
+
export async function localActionData(action) {
|
|
451
|
+
switch (action.kind) {
|
|
452
|
+
case "logs": {
|
|
453
|
+
const { logPath } = profilePaths(action.profile);
|
|
454
|
+
const lines = (await readFile(logPath, "utf8").catch(() => ""))
|
|
455
|
+
.split("\n")
|
|
456
|
+
.filter((line) => line !== "")
|
|
457
|
+
.slice(-50);
|
|
458
|
+
return { lines, fields: { path: logPath, lines } };
|
|
459
|
+
}
|
|
460
|
+
case "audit": {
|
|
461
|
+
const { entries, unreadableLines } = await readAuditLog(auditLogPathFrom());
|
|
462
|
+
const shown = entries.slice(-action.count);
|
|
463
|
+
const lines = [
|
|
464
|
+
...(entries.length === 0 ? ["no copies recorded"] : shown.map(auditLine)),
|
|
465
|
+
...(unreadableLines.length > 0
|
|
466
|
+
? [`unreadable lines in ${auditLogPathFrom()}: ${unreadableLines.join(" ")}`]
|
|
467
|
+
: []),
|
|
468
|
+
];
|
|
469
|
+
return { lines, fields: { path: auditLogPathFrom(), entries: shown, unreadableLines } };
|
|
470
|
+
}
|
|
471
|
+
case "create-profile": {
|
|
472
|
+
if (!isProfileMode(action.mode))
|
|
473
|
+
throw new CommandError("bad_args", `mode must be one of ${profileModes.join(", ")}`);
|
|
474
|
+
const { mode, isNew } = await fixProfileMode(profilePaths(action.profile), action.profile, action.mode);
|
|
475
|
+
const line = `${isNew ? "created" : "exists"} ${mode} profile ${action.profile}`;
|
|
476
|
+
return { lines: [line], fields: { profile: action.profile, mode, isNew } };
|
|
477
|
+
}
|
|
478
|
+
case "history": {
|
|
479
|
+
const path = join(profilePaths(action.profile).sessionsDir, sessionFolderName(action.session), historyFileName);
|
|
480
|
+
const steps = await readHistory(path);
|
|
481
|
+
if (action.isClear) {
|
|
482
|
+
await clearHistory(path);
|
|
483
|
+
return {
|
|
484
|
+
lines: [`cleared ${steps.length} steps from session ${action.session}`],
|
|
485
|
+
fields: { session: action.session, clearedSteps: steps.length },
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const script = formatHistory(steps, action.format, { session: action.session, profile: action.profile });
|
|
489
|
+
if (action.out === undefined)
|
|
490
|
+
return { lines: [script], fields: { session: action.session, format: action.format, steps, script } };
|
|
491
|
+
await writeFile(action.out, `${script}\n`, { mode: action.format === "sh" ? 0o755 : 0o644 });
|
|
492
|
+
return {
|
|
493
|
+
lines: [`history: ${action.out}`, `steps: ${steps.length}`],
|
|
494
|
+
fields: { session: action.session, format: action.format, steps, path: action.out },
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
function print(response, isJson) {
|
|
500
|
+
if (response.ok) {
|
|
501
|
+
process.stdout.write(isJson ? `${JSON.stringify({ ok: true, data: response.data.fields })}\n` : `${response.data.lines.join("\n")}\n`);
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
const { error } = response;
|
|
505
|
+
if (isJson) {
|
|
506
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error })}\n`);
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
process.stderr.write(`error ${error.code}: ${error.message}\n${error.hint ? `hint: ${error.hint}\n` : ""}`);
|
|
510
|
+
}
|
|
511
|
+
return exitCodeFor(error.code);
|
|
512
|
+
}
|
|
513
|
+
async function main(argv) {
|
|
514
|
+
if (argv[0] === "__daemon") {
|
|
515
|
+
const profileFlagIndex = argv.indexOf("--profile");
|
|
516
|
+
const profile = profileFlagIndex === -1 ? "stealth" : argv[profileFlagIndex + 1];
|
|
517
|
+
if (profile === undefined || !isValidName(profile))
|
|
518
|
+
throw new Error(`__daemon needs a valid --profile, got ${String(profile)}`);
|
|
519
|
+
// Loaded here only: Patchright takes most of a CLI call's startup time, and clients never need it.
|
|
520
|
+
const { isClosedTargetRejection, runDaemon } = await import("./daemon.js");
|
|
521
|
+
process.on("unhandledRejection", (reason) => {
|
|
522
|
+
if (!isClosedTargetRejection(reason))
|
|
523
|
+
throw reason;
|
|
524
|
+
process.stdout.write(`${new Date().toISOString()} ignored a rejection from a closed target: ${String(reason).split("\n")[0]}\n`);
|
|
525
|
+
});
|
|
526
|
+
await runDaemon(profile);
|
|
527
|
+
return new Promise(() => { });
|
|
528
|
+
}
|
|
529
|
+
const isJson = argv.includes("--json");
|
|
530
|
+
const sessionFallback = () => resolveSessionName(process.env, process.ppid, lookupProcess);
|
|
531
|
+
try {
|
|
532
|
+
const parsed = parseCli(argv, process.env, sessionFallback);
|
|
533
|
+
if ("kind" in parsed) {
|
|
534
|
+
switch (parsed.kind) {
|
|
535
|
+
case "completions":
|
|
536
|
+
process.stdout.write(zshCompletionScript);
|
|
537
|
+
return 0;
|
|
538
|
+
case "pipe":
|
|
539
|
+
return await runPipe({
|
|
540
|
+
input: process.stdin,
|
|
541
|
+
output: process.stdout,
|
|
542
|
+
runner: new CommandRunner(process.env, () => parsed.session),
|
|
543
|
+
isBail: parsed.isBail,
|
|
544
|
+
});
|
|
545
|
+
case "logs":
|
|
546
|
+
case "audit":
|
|
547
|
+
case "create-profile":
|
|
548
|
+
case "history":
|
|
549
|
+
return print({ id: 0, ok: true, data: await localActionData(parsed) }, parsed.isJson);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const connection = new DaemonConnection(parsed.profile);
|
|
553
|
+
try {
|
|
554
|
+
const response = await connection.request({
|
|
555
|
+
...parsed,
|
|
556
|
+
argv,
|
|
557
|
+
onStream: (stream) => process.stdout.write(`${parsed.isJson ? JSON.stringify(stream.fields) : stream.line}\n`),
|
|
558
|
+
});
|
|
559
|
+
return print(response, parsed.isJson);
|
|
560
|
+
}
|
|
561
|
+
finally {
|
|
562
|
+
connection.close();
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
return print({ id: 0, ok: false, error: toCommandError(err).toBody() }, isJson);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// bin/patchrome.js calls runCli; the daemon is spawned with this file as the entry script.
|
|
570
|
+
export const runCli = main;
|
|
571
|
+
if (import.meta.main)
|
|
572
|
+
process.exitCode = await main(process.argv.slice(2));
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CommandArgs, type CommandName, type DaemonResponse, type DaemonStreamLine } from "./protocol.ts";
|
|
2
|
+
export interface CommandRequest {
|
|
3
|
+
session: string;
|
|
4
|
+
command: CommandName;
|
|
5
|
+
args: CommandArgs;
|
|
6
|
+
timeoutMs: number;
|
|
7
|
+
argv: string[];
|
|
8
|
+
shouldStartDaemon: boolean;
|
|
9
|
+
onStream?: (stream: DaemonStreamLine["stream"]) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare class DaemonConnection {
|
|
12
|
+
#private;
|
|
13
|
+
constructor(profile: string, env?: NodeJS.ProcessEnv);
|
|
14
|
+
request(request: CommandRequest): Promise<DaemonResponse>;
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|