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/pipe.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CommandError } from "./protocol.js";
|
|
4
|
+
import { parseJsonInput } from "./validate.js";
|
|
5
|
+
// `patchrome pipe` reads one request per line and writes one JSON response per line, for any language that
|
|
6
|
+
// can start a process. A request is the command's words as a JSON array, or {"id", "argv"} to pick the id
|
|
7
|
+
// the response carries; without an id it is the line number. Words of one session run in order, different
|
|
8
|
+
// sessions at once, and streamed events arrive as {"id", "stream"} lines before their response.
|
|
9
|
+
const requestSchema = z.union([
|
|
10
|
+
z.array(z.string()).min(1),
|
|
11
|
+
z.strictObject({
|
|
12
|
+
id: z.union([z.string(), z.number()]).optional(),
|
|
13
|
+
argv: z.array(z.string()).min(1),
|
|
14
|
+
// `session history --format jsonl` writes notes next to a step; they are for people and do not run.
|
|
15
|
+
notes: z.array(z.string()).optional(),
|
|
16
|
+
}),
|
|
17
|
+
]);
|
|
18
|
+
const requestHint = `send ["open", "https://example.com"] or {"id": 1, "argv": ["text", "--inline"]}`;
|
|
19
|
+
export async function runPipe({ input, output, runner, isBail }) {
|
|
20
|
+
const queues = new Map();
|
|
21
|
+
const inFlight = new Set();
|
|
22
|
+
let hasFailed = false;
|
|
23
|
+
let lineNumber = 0;
|
|
24
|
+
const write = (message) => output.write(`${JSON.stringify(message)}\n`);
|
|
25
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
26
|
+
for await (const line of lines) {
|
|
27
|
+
lineNumber++;
|
|
28
|
+
if (isBail && hasFailed)
|
|
29
|
+
break;
|
|
30
|
+
if (line.trim() === "")
|
|
31
|
+
continue;
|
|
32
|
+
let id = lineNumber;
|
|
33
|
+
let argv;
|
|
34
|
+
try {
|
|
35
|
+
const request = parseJsonInput(requestSchema, line, `line ${lineNumber}`, requestHint);
|
|
36
|
+
if (Array.isArray(request)) {
|
|
37
|
+
argv = request;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
argv = request.argv;
|
|
41
|
+
id = request.id ?? lineNumber;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
hasFailed = true;
|
|
46
|
+
write({
|
|
47
|
+
id,
|
|
48
|
+
ok: false,
|
|
49
|
+
error: (err instanceof CommandError ? err : new CommandError("bad_args", String(err))).toBody(),
|
|
50
|
+
});
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const key = runner.queueKey(argv);
|
|
54
|
+
const isStreaming = runner.isStreaming(argv);
|
|
55
|
+
const previous = queues.get(key) ?? Promise.resolve();
|
|
56
|
+
const running = previous.then(async () => {
|
|
57
|
+
if (isBail && hasFailed)
|
|
58
|
+
return;
|
|
59
|
+
const result = await runner.run(argv, { onStream: (stream) => write({ id, stream: stream.fields }) });
|
|
60
|
+
if (!result.ok)
|
|
61
|
+
hasFailed = true;
|
|
62
|
+
write({ id, ...result });
|
|
63
|
+
});
|
|
64
|
+
// A stream holds its place only until it starts, so a `watch` does not block the session behind it.
|
|
65
|
+
const settled = isStreaming ? previous : running;
|
|
66
|
+
queues.set(key, settled);
|
|
67
|
+
inFlight.add(running);
|
|
68
|
+
void running.finally(() => inFlight.delete(running));
|
|
69
|
+
}
|
|
70
|
+
await Promise.all(inFlight);
|
|
71
|
+
runner.close();
|
|
72
|
+
return hasFailed ? 1 : 0;
|
|
73
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ProfilePaths } from "./paths.ts";
|
|
2
|
+
export declare const profileModes: readonly ["stealth", "debug"];
|
|
3
|
+
export type ProfileMode = (typeof profileModes)[number];
|
|
4
|
+
export declare function isProfileMode(value: string): value is ProfileMode;
|
|
5
|
+
export declare function defaultModeFor(profile: string): ProfileMode;
|
|
6
|
+
export declare function readProfileMode(paths: ProfilePaths): Promise<ProfileMode | undefined>;
|
|
7
|
+
export declare function fixProfileMode(paths: ProfilePaths, profile: string, requested: ProfileMode | undefined): Promise<{
|
|
8
|
+
mode: ProfileMode;
|
|
9
|
+
isNew: boolean;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CommandError } from "./protocol.js";
|
|
4
|
+
import { parseJsonInput } from "./validate.js";
|
|
5
|
+
export const profileModes = ["stealth", "debug"];
|
|
6
|
+
export function isProfileMode(value) {
|
|
7
|
+
return profileModes.includes(value);
|
|
8
|
+
}
|
|
9
|
+
// A profile nobody created explicitly takes its mode from its name, so `--profile debug` just works.
|
|
10
|
+
export function defaultModeFor(profile) {
|
|
11
|
+
return profile === "debug" ? "debug" : "stealth";
|
|
12
|
+
}
|
|
13
|
+
export async function readProfileMode(paths) {
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = await readFile(paths.profileConfigPath, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err.code === "ENOENT")
|
|
20
|
+
return undefined;
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
const { mode } = parseConfig(raw, paths.profileConfigPath);
|
|
24
|
+
return mode;
|
|
25
|
+
}
|
|
26
|
+
function parseConfig(raw, path) {
|
|
27
|
+
return parseJsonInput(z.object({ mode: z.enum(profileModes) }), raw, path, `mode must be one of ${profileModes.join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
// Mode is fixed once written: a stealth profile's cookies must never end up behind an open debugging port.
|
|
30
|
+
export async function fixProfileMode(paths, profile, requested) {
|
|
31
|
+
const existing = await readProfileMode(paths);
|
|
32
|
+
if (existing !== undefined) {
|
|
33
|
+
if (requested !== undefined && requested !== existing) {
|
|
34
|
+
throw new CommandError("bad_args", `profile ${profile} is already a ${existing} profile`, `a profile's mode is fixed; create another profile for ${requested}`);
|
|
35
|
+
}
|
|
36
|
+
return { mode: existing, isNew: false };
|
|
37
|
+
}
|
|
38
|
+
const mode = requested ?? defaultModeFor(profile);
|
|
39
|
+
await mkdir(paths.profileDir, { recursive: true });
|
|
40
|
+
await writeFile(paths.profileConfigPath, `${JSON.stringify({ mode }, null, 2)}\n`);
|
|
41
|
+
return { mode, isNew: true };
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface ProtocolProperty {
|
|
2
|
+
name: string;
|
|
3
|
+
type?: string;
|
|
4
|
+
$ref?: string;
|
|
5
|
+
items?: {
|
|
6
|
+
type?: string;
|
|
7
|
+
$ref?: string;
|
|
8
|
+
};
|
|
9
|
+
optional?: boolean;
|
|
10
|
+
description?: string;
|
|
11
|
+
enum?: string[];
|
|
12
|
+
}
|
|
13
|
+
interface ProtocolMember {
|
|
14
|
+
name: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
experimental?: boolean;
|
|
17
|
+
deprecated?: boolean;
|
|
18
|
+
parameters?: ProtocolProperty[];
|
|
19
|
+
returns?: ProtocolProperty[];
|
|
20
|
+
}
|
|
21
|
+
interface ProtocolDomain {
|
|
22
|
+
domain: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
experimental?: boolean;
|
|
25
|
+
deprecated?: boolean;
|
|
26
|
+
commands?: ProtocolMember[];
|
|
27
|
+
events?: ProtocolMember[];
|
|
28
|
+
}
|
|
29
|
+
export interface ProtocolSchema {
|
|
30
|
+
domains: ProtocolDomain[];
|
|
31
|
+
}
|
|
32
|
+
export declare function parseProtocolSchema(raw: string): ProtocolSchema;
|
|
33
|
+
export declare function protocolHelp(schema: ProtocolSchema, topic: string | undefined): string[];
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CommandError } from "./protocol.js";
|
|
3
|
+
import { parseJsonInput } from "./validate.js";
|
|
4
|
+
export function parseProtocolSchema(raw) {
|
|
5
|
+
// Only the list is checked: the members' shape is Chrome's, and help prints whatever fields it finds.
|
|
6
|
+
const { domains } = parseJsonInput(z.object({ domains: z.array(z.looseObject({ domain: z.string() })) }), raw, "the browser's /json/protocol");
|
|
7
|
+
return { domains };
|
|
8
|
+
}
|
|
9
|
+
// No topic lists domains, `Page` lists its commands and events, `Page.navigate` describes one of them.
|
|
10
|
+
export function protocolHelp(schema, topic) {
|
|
11
|
+
if (topic === undefined) {
|
|
12
|
+
return schema.domains.map((domain) => `${domain.domain}${flags(domain)} ${domain.commands?.length ?? 0} commands, ${domain.events?.length ?? 0} events`);
|
|
13
|
+
}
|
|
14
|
+
const [domainName, memberName, ...extra] = topic.split(".");
|
|
15
|
+
const domain = schema.domains.find((candidate) => candidate.domain.toLowerCase() === domainName?.toLowerCase());
|
|
16
|
+
if (domain === undefined || extra.length > 0) {
|
|
17
|
+
throw new CommandError("bad_args", `this Chrome has no CDP domain ${domainName}`, "run `patchrome --profile debug cdp help` to list domains");
|
|
18
|
+
}
|
|
19
|
+
if (memberName === undefined) {
|
|
20
|
+
return [
|
|
21
|
+
`${domain.domain}${flags(domain)}`,
|
|
22
|
+
...(domain.description === undefined ? [] : [firstLine(domain.description)]),
|
|
23
|
+
"",
|
|
24
|
+
"commands",
|
|
25
|
+
...(domain.commands ?? []).map((command) => ` ${domain.domain}.${command.name}${flags(command)}${command.description === undefined ? "" : ` ${firstLine(command.description)}`}`),
|
|
26
|
+
"",
|
|
27
|
+
"events",
|
|
28
|
+
...(domain.events ?? []).map((event) => ` ${domain.domain}.${event.name}${flags(event)}${event.description === undefined ? "" : ` ${firstLine(event.description)}`}`),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
const command = domain.commands?.find((candidate) => candidate.name === memberName);
|
|
32
|
+
const event = domain.events?.find((candidate) => candidate.name === memberName);
|
|
33
|
+
const member = command ?? event;
|
|
34
|
+
if (member === undefined) {
|
|
35
|
+
throw new CommandError("bad_args", `${domain.domain} has no command or event ${memberName}`, `run \`patchrome --profile debug cdp help ${domain.domain}\``);
|
|
36
|
+
}
|
|
37
|
+
const heading = `${command === undefined ? "event" : "command"} ${domain.domain}.${member.name}${flags(member)}`;
|
|
38
|
+
return [
|
|
39
|
+
heading,
|
|
40
|
+
...(member.description === undefined ? [] : [member.description]),
|
|
41
|
+
...propertyBlock(command === undefined ? "fields" : "params", member.parameters),
|
|
42
|
+
...propertyBlock("returns", member.returns),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
function propertyBlock(title, properties) {
|
|
46
|
+
if (properties === undefined || properties.length === 0)
|
|
47
|
+
return [];
|
|
48
|
+
return [
|
|
49
|
+
"",
|
|
50
|
+
title,
|
|
51
|
+
...properties.map((property) => {
|
|
52
|
+
const kind = property.$ref ??
|
|
53
|
+
(property.type === "array"
|
|
54
|
+
? `${property.items?.$ref ?? property.items?.type ?? "unknown"}[]`
|
|
55
|
+
: (property.type ?? "unknown"));
|
|
56
|
+
const choices = property.enum === undefined ? "" : ` (${property.enum.join("|")})`;
|
|
57
|
+
return ` ${property.name}${property.optional === true ? "?" : ""}: ${kind}${choices}${property.description === undefined ? "" : ` ${firstLine(property.description)}`}`;
|
|
58
|
+
}),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
function flags(item) {
|
|
62
|
+
return `${item.experimental === true ? " [experimental]" : ""}${item.deprecated === true ? " [deprecated]" : ""}`;
|
|
63
|
+
}
|
|
64
|
+
function firstLine(text) {
|
|
65
|
+
return text.split("\n")[0] ?? text;
|
|
66
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ReplayHint } from "./history.ts";
|
|
2
|
+
export declare const errorCodes: readonly ["tab_gone", "ref_stale", "timeout", "navigation_failed", "unsupported_in_stealth", "daemon_unreachable", "daemon_outdated", "bad_args", "copy_denied"];
|
|
3
|
+
export type ErrorCode = (typeof errorCodes)[number];
|
|
4
|
+
export declare const commandNames: readonly ["open", "tabs", "switch", "close", "goto", "snapshot", "click", "fill", "press", "type", "challenge", "screenshot", "text", "eval", "extract", "wait", "watch", "network-list", "network-get", "network-har-start", "network-har-stop", "route-block", "route-mock", "route-list", "route-clear", "login", "cookies", "state-save", "state-load", "state-import", "console", "errors", "trace-start", "trace-stop", "cdp", "cdp-help", "devtools-url", "session", "sessions", "session-close", "session-label", "daemon-status", "daemon-stop"];
|
|
5
|
+
export type CommandName = (typeof commandNames)[number];
|
|
6
|
+
export declare function isCommandName(value: string): value is CommandName;
|
|
7
|
+
export type CommandArgs = Record<string, string | number | boolean | undefined>;
|
|
8
|
+
export interface DaemonRequest {
|
|
9
|
+
id: number;
|
|
10
|
+
session: string;
|
|
11
|
+
command: CommandName;
|
|
12
|
+
args: CommandArgs;
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
argv?: string[];
|
|
15
|
+
buildId: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ErrorBody {
|
|
18
|
+
code: ErrorCode;
|
|
19
|
+
message: string;
|
|
20
|
+
hint?: string;
|
|
21
|
+
}
|
|
22
|
+
export type DaemonResponse = {
|
|
23
|
+
id: number;
|
|
24
|
+
ok: true;
|
|
25
|
+
data: CommandData;
|
|
26
|
+
} | {
|
|
27
|
+
id: number;
|
|
28
|
+
ok: false;
|
|
29
|
+
error: ErrorBody;
|
|
30
|
+
};
|
|
31
|
+
export interface DaemonStreamLine {
|
|
32
|
+
id: number;
|
|
33
|
+
stream: {
|
|
34
|
+
line: string;
|
|
35
|
+
fields: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export interface CommandData {
|
|
39
|
+
lines: string[];
|
|
40
|
+
fields: Record<string, unknown>;
|
|
41
|
+
replay?: ReplayHint;
|
|
42
|
+
}
|
|
43
|
+
export declare class CommandError extends Error {
|
|
44
|
+
readonly code: ErrorCode;
|
|
45
|
+
readonly hint: string | undefined;
|
|
46
|
+
constructor(code: ErrorCode, message: string, hint?: string);
|
|
47
|
+
toBody(): ErrorBody;
|
|
48
|
+
}
|
|
49
|
+
export declare function exitCodeFor(code: ErrorCode): number;
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Wire format between the CLI and the daemon: one JSON object per line over a unix socket.
|
|
2
|
+
export const errorCodes = [
|
|
3
|
+
"tab_gone",
|
|
4
|
+
"ref_stale",
|
|
5
|
+
"timeout",
|
|
6
|
+
"navigation_failed",
|
|
7
|
+
"unsupported_in_stealth",
|
|
8
|
+
"daemon_unreachable",
|
|
9
|
+
"daemon_outdated",
|
|
10
|
+
"bad_args",
|
|
11
|
+
"copy_denied",
|
|
12
|
+
];
|
|
13
|
+
export const commandNames = [
|
|
14
|
+
"open",
|
|
15
|
+
"tabs",
|
|
16
|
+
"switch",
|
|
17
|
+
"close",
|
|
18
|
+
"goto",
|
|
19
|
+
"snapshot",
|
|
20
|
+
"click",
|
|
21
|
+
"fill",
|
|
22
|
+
"press",
|
|
23
|
+
"type",
|
|
24
|
+
"challenge",
|
|
25
|
+
"screenshot",
|
|
26
|
+
"text",
|
|
27
|
+
"eval",
|
|
28
|
+
"extract",
|
|
29
|
+
"wait",
|
|
30
|
+
"watch",
|
|
31
|
+
"network-list",
|
|
32
|
+
"network-get",
|
|
33
|
+
"network-har-start",
|
|
34
|
+
"network-har-stop",
|
|
35
|
+
"route-block",
|
|
36
|
+
"route-mock",
|
|
37
|
+
"route-list",
|
|
38
|
+
"route-clear",
|
|
39
|
+
"login",
|
|
40
|
+
"cookies",
|
|
41
|
+
"state-save",
|
|
42
|
+
"state-load",
|
|
43
|
+
"state-import",
|
|
44
|
+
"console",
|
|
45
|
+
"errors",
|
|
46
|
+
"trace-start",
|
|
47
|
+
"trace-stop",
|
|
48
|
+
"cdp",
|
|
49
|
+
"cdp-help",
|
|
50
|
+
"devtools-url",
|
|
51
|
+
"session",
|
|
52
|
+
"sessions",
|
|
53
|
+
"session-close",
|
|
54
|
+
"session-label",
|
|
55
|
+
"daemon-status",
|
|
56
|
+
"daemon-stop",
|
|
57
|
+
];
|
|
58
|
+
export function isCommandName(value) {
|
|
59
|
+
return commandNames.includes(value);
|
|
60
|
+
}
|
|
61
|
+
export class CommandError extends Error {
|
|
62
|
+
code;
|
|
63
|
+
hint;
|
|
64
|
+
constructor(code, message, hint) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.code = code;
|
|
67
|
+
this.hint = hint;
|
|
68
|
+
}
|
|
69
|
+
toBody() {
|
|
70
|
+
return this.hint === undefined
|
|
71
|
+
? { code: this.code, message: this.message }
|
|
72
|
+
: { code: this.code, message: this.message, hint: this.hint };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function exitCodeFor(code) {
|
|
76
|
+
switch (code) {
|
|
77
|
+
case "bad_args":
|
|
78
|
+
return 2;
|
|
79
|
+
case "tab_gone":
|
|
80
|
+
case "ref_stale":
|
|
81
|
+
case "timeout":
|
|
82
|
+
case "navigation_failed":
|
|
83
|
+
case "unsupported_in_stealth":
|
|
84
|
+
case "daemon_unreachable":
|
|
85
|
+
case "daemon_outdated":
|
|
86
|
+
case "copy_denied":
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
package/dist/refs.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function parseRef(input: string): string;
|
|
2
|
+
export declare function refsIn(snapshotText: string): Set<string>;
|
|
3
|
+
export declare class SnapshotGenerations {
|
|
4
|
+
#private;
|
|
5
|
+
recordNavigation(): void;
|
|
6
|
+
recordSnapshot(snapshot: string): void;
|
|
7
|
+
get latestSnapshot(): string;
|
|
8
|
+
assertRefCurrent(ref: string): void;
|
|
9
|
+
}
|
package/dist/refs.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CommandError } from "./protocol.js";
|
|
2
|
+
// A ref is `@e12`, or `@f1e12` when Playwright prefixes the frame, resolved against the tab's latest snapshot. Staleness is tracked per tab, not per ref,
|
|
3
|
+
// because a stale aria-ref locator does not fail on its own: it waits out the whole timeout.
|
|
4
|
+
export function parseRef(input) {
|
|
5
|
+
const match = input.match(/^@?((?:f\d+)?e\d+)$/);
|
|
6
|
+
if (!match?.[1]) {
|
|
7
|
+
throw new CommandError("bad_args", `not a ref: ${input}`, "refs look like @e12 or @f1e12, taken from the latest snapshot");
|
|
8
|
+
}
|
|
9
|
+
return match[1];
|
|
10
|
+
}
|
|
11
|
+
export function refsIn(snapshotText) {
|
|
12
|
+
return new Set([...snapshotText.matchAll(/\[ref=((?:f\d+)?e\d+)\]/g)].map((match) => match[1] ?? ""));
|
|
13
|
+
}
|
|
14
|
+
export class SnapshotGenerations {
|
|
15
|
+
#navigationCount = 0;
|
|
16
|
+
#snapshotAtNavigation;
|
|
17
|
+
#latestRefs = new Set();
|
|
18
|
+
#latestSnapshot = "";
|
|
19
|
+
recordNavigation() {
|
|
20
|
+
this.#navigationCount++;
|
|
21
|
+
}
|
|
22
|
+
recordSnapshot(snapshot) {
|
|
23
|
+
this.#snapshotAtNavigation = this.#navigationCount;
|
|
24
|
+
this.#latestRefs = refsIn(snapshot);
|
|
25
|
+
this.#latestSnapshot = snapshot;
|
|
26
|
+
}
|
|
27
|
+
get latestSnapshot() {
|
|
28
|
+
return this.#latestSnapshot;
|
|
29
|
+
}
|
|
30
|
+
// Playwright renumbers frames on every snapshot, so a ref copied from an older snapshot of another
|
|
31
|
+
// page can survive the navigation check and then fail inside Playwright as "Invalid frame".
|
|
32
|
+
assertRefCurrent(ref) {
|
|
33
|
+
this.#assertNoNavigation();
|
|
34
|
+
if (!this.#latestRefs.has(ref)) {
|
|
35
|
+
throw new CommandError("ref_stale", `@${ref} is not in the latest snapshot of this tab`, "run `patchrome snapshot` and copy a ref from it");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
#assertNoNavigation() {
|
|
39
|
+
if (this.#snapshotAtNavigation === undefined) {
|
|
40
|
+
throw new CommandError("ref_stale", "no snapshot taken on this tab", "run `patchrome snapshot` first");
|
|
41
|
+
}
|
|
42
|
+
if (this.#snapshotAtNavigation !== this.#navigationCount) {
|
|
43
|
+
throw new CommandError("ref_stale", "the page navigated since the last snapshot", "run `patchrome snapshot` again");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Tab } from "./sessions.ts";
|
|
2
|
+
export type RouteRule = {
|
|
3
|
+
kind: "block";
|
|
4
|
+
glob: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: "mock";
|
|
7
|
+
glob: string;
|
|
8
|
+
file: string;
|
|
9
|
+
body: Buffer;
|
|
10
|
+
contentType: string;
|
|
11
|
+
};
|
|
12
|
+
export declare class RouteTable {
|
|
13
|
+
#private;
|
|
14
|
+
track(tab: Tab): void;
|
|
15
|
+
block(session: string, glob: string): Promise<RouteRule>;
|
|
16
|
+
mock(session: string, glob: string, file: string): Promise<RouteRule>;
|
|
17
|
+
rulesOf(session: string): RouteRule[];
|
|
18
|
+
clear(session: string): Promise<number>;
|
|
19
|
+
forget(session: string): Promise<void>;
|
|
20
|
+
}
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
import { urlGlobMatches } from "./glob.js";
|
|
4
|
+
import { CommandError } from "./protocol.js";
|
|
5
|
+
const contentTypes = {
|
|
6
|
+
".json": "application/json",
|
|
7
|
+
".html": "text/html",
|
|
8
|
+
".htm": "text/html",
|
|
9
|
+
".js": "text/javascript",
|
|
10
|
+
".css": "text/css",
|
|
11
|
+
".txt": "text/plain",
|
|
12
|
+
".svg": "image/svg+xml",
|
|
13
|
+
".png": "image/png",
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".jpeg": "image/jpeg",
|
|
16
|
+
};
|
|
17
|
+
// Rules belong to a session and apply to every tab it owns, including popups opened later. A tab only
|
|
18
|
+
// intercepts requests while its session has rules, since interception turns off Chrome's cache.
|
|
19
|
+
export class RouteTable {
|
|
20
|
+
#rules = new Map();
|
|
21
|
+
#installed = new Map();
|
|
22
|
+
#tabsBySession = new Map();
|
|
23
|
+
track(tab) {
|
|
24
|
+
let tabs = this.#tabsBySession.get(tab.session);
|
|
25
|
+
if (!tabs) {
|
|
26
|
+
tabs = new Set();
|
|
27
|
+
this.#tabsBySession.set(tab.session, tabs);
|
|
28
|
+
}
|
|
29
|
+
tabs.add(tab);
|
|
30
|
+
tab.page.on("close", () => {
|
|
31
|
+
tabs.delete(tab);
|
|
32
|
+
this.#installed.delete(tab.page);
|
|
33
|
+
});
|
|
34
|
+
if (this.rulesOf(tab.session).length > 0)
|
|
35
|
+
void this.#install(tab);
|
|
36
|
+
}
|
|
37
|
+
async block(session, glob) {
|
|
38
|
+
return this.#add(session, { kind: "block", glob });
|
|
39
|
+
}
|
|
40
|
+
async mock(session, glob, file) {
|
|
41
|
+
let body;
|
|
42
|
+
try {
|
|
43
|
+
body = await readFile(file);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
throw new CommandError("bad_args", `cannot read mock file ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
47
|
+
}
|
|
48
|
+
return this.#add(session, {
|
|
49
|
+
kind: "mock",
|
|
50
|
+
glob,
|
|
51
|
+
file,
|
|
52
|
+
body,
|
|
53
|
+
contentType: contentTypes[extname(file).toLowerCase()] ?? "application/octet-stream",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
rulesOf(session) {
|
|
57
|
+
return this.#rules.get(session) ?? [];
|
|
58
|
+
}
|
|
59
|
+
async clear(session) {
|
|
60
|
+
const count = this.rulesOf(session).length;
|
|
61
|
+
this.#rules.delete(session);
|
|
62
|
+
await Promise.all([...(this.#tabsBySession.get(session) ?? [])].map(async (tab) => {
|
|
63
|
+
const matcher = this.#installed.get(tab.page);
|
|
64
|
+
if (!matcher)
|
|
65
|
+
return;
|
|
66
|
+
this.#installed.delete(tab.page);
|
|
67
|
+
await tab.page.unroute(matcher).catch(() => { });
|
|
68
|
+
}));
|
|
69
|
+
return count;
|
|
70
|
+
}
|
|
71
|
+
async forget(session) {
|
|
72
|
+
await this.clear(session);
|
|
73
|
+
this.#tabsBySession.delete(session);
|
|
74
|
+
}
|
|
75
|
+
async #add(session, rule) {
|
|
76
|
+
this.#rules.set(session, [...this.rulesOf(session), rule]);
|
|
77
|
+
await Promise.all([...(this.#tabsBySession.get(session) ?? [])].map((tab) => this.#install(tab)));
|
|
78
|
+
return rule;
|
|
79
|
+
}
|
|
80
|
+
async #install(tab) {
|
|
81
|
+
if (this.#installed.has(tab.page))
|
|
82
|
+
return;
|
|
83
|
+
const matcher = (url) => this.#ruleFor(tab.session, url.href) !== undefined;
|
|
84
|
+
this.#installed.set(tab.page, matcher);
|
|
85
|
+
await tab.page
|
|
86
|
+
.route(matcher, (route) => this.#handle(tab.session, route))
|
|
87
|
+
.catch(() => {
|
|
88
|
+
this.#installed.delete(tab.page);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// The newest matching rule wins, so an agent can narrow an earlier broad block with a mock.
|
|
92
|
+
#ruleFor(session, url) {
|
|
93
|
+
return this.rulesOf(session).findLast((rule) => urlGlobMatches(rule.glob, url));
|
|
94
|
+
}
|
|
95
|
+
async #handle(session, route) {
|
|
96
|
+
const rule = this.#ruleFor(session, route.request().url());
|
|
97
|
+
if (!rule)
|
|
98
|
+
return route.fallback();
|
|
99
|
+
switch (rule.kind) {
|
|
100
|
+
case "block":
|
|
101
|
+
return route.abort("blockedbyclient");
|
|
102
|
+
case "mock":
|
|
103
|
+
return route.fulfill({ status: 200, contentType: rule.contentType, body: rule.body });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { CommandError, type DaemonStreamLine, type ErrorBody } from "./protocol.ts";
|
|
2
|
+
export type RunResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
data: Record<string, unknown>;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
error: ErrorBody;
|
|
8
|
+
};
|
|
9
|
+
export interface RunOptions {
|
|
10
|
+
onStream?: (stream: DaemonStreamLine["stream"]) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare class CommandRunner {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(env: NodeJS.ProcessEnv, sessionFallback: () => string);
|
|
15
|
+
queueKey(argv: string[]): string;
|
|
16
|
+
isStreaming(argv: string[]): boolean;
|
|
17
|
+
run(argv: string[], options?: RunOptions): Promise<RunResult>;
|
|
18
|
+
close(): void;
|
|
19
|
+
}
|
|
20
|
+
export declare function toCommandError(err: unknown): CommandError;
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { localActionData, parseCli } from "./cli.js";
|
|
2
|
+
import { DaemonConnection } from "./client.js";
|
|
3
|
+
import { CommandError } from "./protocol.js";
|
|
4
|
+
// Runs CLI words the way the `patchrome` command does, over one kept-open connection per profile. `pipe`
|
|
5
|
+
// and the library sit on this, so a script's words mean exactly what they mean on the command line.
|
|
6
|
+
export class CommandRunner {
|
|
7
|
+
#env;
|
|
8
|
+
#sessionFallback;
|
|
9
|
+
#connections = new Map();
|
|
10
|
+
constructor(env, sessionFallback) {
|
|
11
|
+
this.#env = env;
|
|
12
|
+
this.#sessionFallback = sessionFallback;
|
|
13
|
+
}
|
|
14
|
+
// Which queue the words run in: commands of one session in one profile run in order.
|
|
15
|
+
queueKey(argv) {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = parseCli(argv, this.#env, this.#sessionFallback);
|
|
18
|
+
if ("kind" in parsed)
|
|
19
|
+
return "local";
|
|
20
|
+
return `${parsed.profile}\n${parsed.session}`;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return "local";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Streaming commands run outside their session's queue, so the next words need not wait for them.
|
|
27
|
+
isStreaming(argv) {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = parseCli(argv, this.#env, this.#sessionFallback);
|
|
30
|
+
return (!("kind" in parsed) &&
|
|
31
|
+
(parsed.command === "watch" || (parsed.command === "console" && parsed.args.follow === true)));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async run(argv, options = {}) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = parseCli(argv, this.#env, this.#sessionFallback);
|
|
40
|
+
if ("kind" in parsed) {
|
|
41
|
+
switch (parsed.kind) {
|
|
42
|
+
case "completions":
|
|
43
|
+
case "pipe":
|
|
44
|
+
throw new CommandError("bad_args", `${parsed.kind} runs only as its own command`, `run \`patchrome ${parsed.kind === "pipe" ? "pipe" : "completions zsh"}\` from a shell`);
|
|
45
|
+
case "logs":
|
|
46
|
+
case "audit":
|
|
47
|
+
case "create-profile":
|
|
48
|
+
case "history":
|
|
49
|
+
return { ok: true, data: (await localActionData(parsed)).fields };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const response = await this.#connectionFor(parsed.profile).request({
|
|
53
|
+
...parsed,
|
|
54
|
+
argv,
|
|
55
|
+
onStream: options.onStream,
|
|
56
|
+
});
|
|
57
|
+
return response.ok ? { ok: true, data: response.data.fields } : { ok: false, error: response.error };
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return { ok: false, error: toCommandError(err).toBody() };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
close() {
|
|
64
|
+
for (const connection of this.#connections.values())
|
|
65
|
+
connection.close();
|
|
66
|
+
this.#connections.clear();
|
|
67
|
+
}
|
|
68
|
+
#connectionFor(profile) {
|
|
69
|
+
let connection = this.#connections.get(profile);
|
|
70
|
+
if (connection === undefined) {
|
|
71
|
+
connection = new DaemonConnection(profile, this.#env);
|
|
72
|
+
this.#connections.set(profile, connection);
|
|
73
|
+
}
|
|
74
|
+
return connection;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function toCommandError(err) {
|
|
78
|
+
return err instanceof CommandError
|
|
79
|
+
? err
|
|
80
|
+
: new CommandError("bad_args", err instanceof Error ? err.message : String(err));
|
|
81
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ProcessInfo {
|
|
2
|
+
pid: number;
|
|
3
|
+
ppid: number;
|
|
4
|
+
command: string;
|
|
5
|
+
tty: string;
|
|
6
|
+
}
|
|
7
|
+
export type ProcessLookup = (pid: number) => ProcessInfo | undefined;
|
|
8
|
+
export declare function resolveSessionName(env: NodeJS.ProcessEnv, startPid: number, lookup: ProcessLookup): string;
|
|
9
|
+
export declare function lookupProcess(pid: number): ProcessInfo | undefined;
|