rechrome 1.28.1 → 1.29.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/README.md +8 -7
- package/daemon-manager.js +21 -2
- package/daemon-manager.ts +33 -2
- package/package.json +1 -1
- package/rechrome.js +269 -45
- package/rechrome.ts +269 -45
- package/serve.js +86 -0
- package/serve.ts +86 -0
package/rechrome.js
CHANGED
|
@@ -13,7 +13,7 @@ import { pathToFileURL } from "url";
|
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { spawn as cpSpawn } from "child_process";
|
|
15
15
|
import { readFile, writeFile, rename, chmod, mkdir } from "node:fs/promises";
|
|
16
|
-
import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
|
|
16
|
+
import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.js";
|
|
17
17
|
|
|
18
18
|
export const ENV_KEY = "RECHROME_URL";
|
|
19
19
|
export const DEFAULT_PORT = 13775;
|
|
@@ -671,6 +671,40 @@ export function validateChromeProfileSelector(selector: string): void {
|
|
|
671
671
|
);
|
|
672
672
|
}
|
|
673
673
|
|
|
674
|
+
export type ProfileCandidate = { id: string; label: string; fields: string[]; localPart?: string };
|
|
675
|
+
|
|
676
|
+
/** Several profiles match; an interactive caller can offer them as a choice. */
|
|
677
|
+
export class AmbiguousProfileError extends Error {
|
|
678
|
+
constructor(message: string, readonly candidates: ProfileCandidate[]) { super(message); }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** share can't pick a listener on its own; an interactive caller can ask. */
|
|
682
|
+
export class ShareListenerError extends Error {
|
|
683
|
+
constructor(message: string, readonly kind: "several" | "none", readonly listeners: Listener[]) { super(message); }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Looser profile matching, tried after the exact rules: the email's part before "@", then the
|
|
688
|
+
* start (3+ characters) of an email/name/folder. No substring matching: "h" must not pick a
|
|
689
|
+
* profile because its email happens to contain an h. Each stage counts only when it picks out
|
|
690
|
+
* exactly one profile; if a stage matches several, stop and name them rather than guess.
|
|
691
|
+
*/
|
|
692
|
+
export function matchProfileLoosely(value: string, candidates: ProfileCandidate[]): ProfileCandidate | null {
|
|
693
|
+
const needle = value.trim().toLowerCase();
|
|
694
|
+
if (!needle) return null;
|
|
695
|
+
const stages: Array<[string, (c: ProfileCandidate) => boolean]> = [
|
|
696
|
+
["email name", c => c.localPart?.toLowerCase() === needle],
|
|
697
|
+
["prefix", c => needle.length >= 3 && c.fields.some(f => f.toLowerCase().startsWith(needle))],
|
|
698
|
+
];
|
|
699
|
+
for (const [, test] of stages) {
|
|
700
|
+
const hits = candidates.filter(test);
|
|
701
|
+
if (hits.length === 1) return hits[0];
|
|
702
|
+
if (hits.length > 1)
|
|
703
|
+
throw new AmbiguousProfileError(`Profile "${value}" matches several profiles: ${hits.map(c => c.label).join(", ")}. Use one of those, or see \`rech profile\`.`, hits);
|
|
704
|
+
}
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
674
708
|
export async function resolveGlobalProfile(
|
|
675
709
|
registry: Record<string, TokenEntry>,
|
|
676
710
|
chromeProfiles: Record<string, ChromeProfileInfo> | null,
|
|
@@ -690,18 +724,32 @@ export async function resolveGlobalProfile(
|
|
|
690
724
|
}
|
|
691
725
|
|
|
692
726
|
const profiles = Object.entries(chromeProfiles);
|
|
693
|
-
let match
|
|
694
|
-
try {
|
|
695
|
-
match = resolveChromeProfileSelector(profiles, value);
|
|
696
|
-
} catch (err) {
|
|
697
|
-
throw err;
|
|
698
|
-
}
|
|
727
|
+
let match = resolveChromeProfileSelector(profiles, value);
|
|
699
728
|
|
|
700
729
|
if (!match) {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
730
|
+
// No exact match: accept a looser one only when it is unique, and say which profile it chose.
|
|
731
|
+
const chromeIds = new Set(profiles.flatMap(([dir, info]) => [dir, info.user_name ?? ""]));
|
|
732
|
+
for (const [key, entry] of Object.entries(registry)) if (profiles.some(([dir]) => dir === entry.profileDir)) chromeIds.add(key);
|
|
733
|
+
const candidates: ProfileCandidate[] = [
|
|
734
|
+
...profiles.map(([dir, info]) => ({
|
|
735
|
+
id: `chrome:${dir}`,
|
|
736
|
+
label: info.user_name ? `${info.user_name} (${info.name ?? dir})` : `${info.name ?? dir} [${dir}]`,
|
|
737
|
+
fields: [info.user_name ?? "", info.name ?? "", dir].filter(Boolean),
|
|
738
|
+
localPart: info.user_name?.split("@")[0],
|
|
739
|
+
})),
|
|
740
|
+
// Registered profiles Chrome doesn't list (managed test profiles).
|
|
741
|
+
...Object.keys(registry).filter(k => !chromeIds.has(k)).map(k => ({ id: `registry:${k}`, label: k, fields: [k], localPart: k.includes("@") ? k.split("@")[0] : undefined })),
|
|
742
|
+
];
|
|
743
|
+
const loose = matchProfileLoosely(value, candidates);
|
|
744
|
+
if (!loose) {
|
|
745
|
+
throw new Error(`Profile "${value}" does not match any Chrome profile. See available profiles with \`rech profile\`.`);
|
|
746
|
+
}
|
|
747
|
+
console.error(`[rech] profile "${value}" → ${loose.label}`);
|
|
748
|
+
if (loose.id.startsWith("registry:")) {
|
|
749
|
+
const key = loose.id.slice("registry:".length);
|
|
750
|
+
return { email: key, entry: registry[key] };
|
|
751
|
+
}
|
|
752
|
+
match = profiles.find(([dir]) => `chrome:${dir}` === loose.id)!;
|
|
705
753
|
}
|
|
706
754
|
|
|
707
755
|
const [dir, info] = match;
|
|
@@ -883,6 +931,12 @@ async function listProfiles(): Promise<void> {
|
|
|
883
931
|
export function profileConnectionUri(profile: string, configuredUrl: string | undefined, listeners: Listener[], listenerName?: string): string {
|
|
884
932
|
let url = configuredUrl;
|
|
885
933
|
if (listenerName || !url) {
|
|
934
|
+
if (listenerName) {
|
|
935
|
+
const named = listeners.find(l => l.name === listenerName);
|
|
936
|
+
if (!named) throw new Error(`Unknown listener "${listenerName}". See rech listener ls.`);
|
|
937
|
+
if (named.profiles !== "*" && !named.profiles.includes(profile))
|
|
938
|
+
throw new Error(`Listener "${listenerName}" does not allow "${profile}". Allow it with: rech listener allow ${listenerName} ${JSON.stringify(profile)}`);
|
|
939
|
+
}
|
|
886
940
|
const candidates = listeners.filter(l => (l.profiles === "*" || l.profiles.includes(profile)) && (!listenerName || l.name === listenerName));
|
|
887
941
|
if (candidates.length !== 1) throw new Error("Choose a listener with --listener <name>, or set RECHROME_URL to the desired endpoint.");
|
|
888
942
|
const listener = candidates[0];
|
|
@@ -898,16 +952,129 @@ export function profileConnectionUri(profile: string, configuredUrl: string | un
|
|
|
898
952
|
return result.toString();
|
|
899
953
|
}
|
|
900
954
|
|
|
955
|
+
/** Prompts only when a person is at the terminal; scripts and agents get errors, never a hang. */
|
|
956
|
+
export const isInteractive = () => !!process.stdin.isTTY && !!process.stderr.isTTY;
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Numbered choice on `output` (stderr, so stdout stays clean for piping). Enter takes the
|
|
960
|
+
* default; q or end of input cancels (null). Invalid answers ask again.
|
|
961
|
+
*/
|
|
962
|
+
export async function promptChoice<T>(
|
|
963
|
+
question: string, options: { label: string; value: T }[], defaultIndex = 0,
|
|
964
|
+
io: { input: NodeJS.ReadableStream; output: NodeJS.WritableStream } = { input: process.stdin, output: process.stderr },
|
|
965
|
+
): Promise<T | null> {
|
|
966
|
+
const { createInterface } = await import("readline");
|
|
967
|
+
const rl = createInterface({ input: io.input, output: io.output, terminal: false });
|
|
968
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
969
|
+
try {
|
|
970
|
+
io.output.write(`${question}\n`);
|
|
971
|
+
options.forEach((o, i) => io.output.write(` ${String(i + 1).padStart(2)}. ${o.label}${i === defaultIndex ? " (default)" : ""}\n`));
|
|
972
|
+
while (true) {
|
|
973
|
+
io.output.write(`Choice [${defaultIndex + 1}, q to cancel]: `);
|
|
974
|
+
const next = await lines.next();
|
|
975
|
+
if (next.done) return null;
|
|
976
|
+
const answer = String(next.value).trim().toLowerCase();
|
|
977
|
+
if (answer === "q") return null;
|
|
978
|
+
if (answer === "") return options[defaultIndex]?.value ?? null;
|
|
979
|
+
const index = Number(answer) - 1;
|
|
980
|
+
if (Number.isInteger(index) && options[index]) return options[index].value;
|
|
981
|
+
io.output.write(`Enter a number from 1 to ${options.length}.\n`);
|
|
982
|
+
}
|
|
983
|
+
} finally { rl.close(); }
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** Registered profiles as choices, one per Chrome profile folder (the registry may alias one folder twice). */
|
|
987
|
+
function registeredProfileChoices(registry: Record<string, TokenEntry>): { label: string; value: string }[] {
|
|
988
|
+
const byDir = new Map<string, string>();
|
|
989
|
+
for (const key of Object.keys(registry)) {
|
|
990
|
+
const dir = registry[key].profileDir;
|
|
991
|
+
const kept = byDir.get(dir);
|
|
992
|
+
if (!kept || (!kept.includes("@") && key.includes("@"))) byDir.set(dir, key);
|
|
993
|
+
}
|
|
994
|
+
return [...byDir.entries()].map(([dir, key]) => ({ label: key === dir ? key : `${key} [${dir}]`, value: key }));
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Which listener `rech share <profile>` uses when none is named: a scoped one that allows the
|
|
999
|
+
* profile, preferring one with a public URL. Never the management listener, whose key gives
|
|
1000
|
+
* full access to every profile.
|
|
1001
|
+
*/
|
|
1002
|
+
export function chooseShareListener(profile: string, listeners: Listener[]): string {
|
|
1003
|
+
const scoped = listeners.filter(l => l.profiles !== "*");
|
|
1004
|
+
const allowing = scoped.filter(l => (l.profiles as string[]).includes(profile));
|
|
1005
|
+
const pick = allowing.length === 1 ? allowing : allowing.filter(l => l.publicUrl);
|
|
1006
|
+
if (pick.length === 1) return pick[0].name;
|
|
1007
|
+
if (allowing.length > 1)
|
|
1008
|
+
throw new ShareListenerError(`"${profile}" is shared on several listeners (${allowing.map(l => l.name).join(", ")}). Pick one with --listener <name>.`, "several", allowing);
|
|
1009
|
+
throw new ShareListenerError([
|
|
1010
|
+
`"${profile}" isn't shared on any listener yet.`,
|
|
1011
|
+
scoped.length ? ` Allow it on one: rech listener allow ${scoped[0].name} ${JSON.stringify(profile)} (listeners: ${scoped.map(l => l.name).join(", ")})` : "",
|
|
1012
|
+
` Or create one: rech listener add share --listen local --prefix=rechrome --port 13776 --profile ${JSON.stringify(profile)}`,
|
|
1013
|
+
].filter(Boolean).join("\n"), "none", scoped);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
901
1016
|
async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean } = {}): Promise<void> {
|
|
902
1017
|
const url = process.env[ENV_KEY];
|
|
903
|
-
|
|
904
|
-
if (!selector) throw new Error("Specify a profile: rech url <profile>");
|
|
1018
|
+
const interactive = isInteractive();
|
|
905
1019
|
const registry = await readTokenRegistry();
|
|
906
1020
|
const cache = await readChromeProfileCache();
|
|
1021
|
+
const cancelled = () => new Error("Cancelled; nothing shared.");
|
|
1022
|
+
const current = resolveEffectiveProfile(url ? parseUrl(url).profileDirectory : undefined);
|
|
1023
|
+
const pickProfile = async (question: string, choices = registeredProfileChoices(registry)) => {
|
|
1024
|
+
const def = Math.max(0, choices.findIndex(c => c.value === current || registry[c.value]?.profileDir === current));
|
|
1025
|
+
return (await promptChoice(question, choices, def)) ?? (() => { throw cancelled(); })();
|
|
1026
|
+
};
|
|
1027
|
+
if (!selector) {
|
|
1028
|
+
// No profile given: ask, defaulting to the current one (?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY).
|
|
1029
|
+
if (interactive && Object.keys(registry).length) selector = await pickProfile("Share which profile?");
|
|
1030
|
+
else {
|
|
1031
|
+
selector = current;
|
|
1032
|
+
if (!selector) throw new Error("No current profile to share. Name one: rech share <profile> (see rech profile; rech share ls lists what is shared)");
|
|
1033
|
+
console.error(`[rech] sharing the current profile: ${selector}`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
907
1036
|
// A configured remote profile may not exist in this machine's local registry.
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1037
|
+
let profile: string;
|
|
1038
|
+
if (url && parseUrl(url).profileDirectory === selector && !listener) profile = selector;
|
|
1039
|
+
else {
|
|
1040
|
+
try { profile = (await resolveGlobalProfile(registry, cache, selector)).email; }
|
|
1041
|
+
catch (error) {
|
|
1042
|
+
if (!interactive || !(error instanceof Error)) throw error;
|
|
1043
|
+
if (error instanceof AmbiguousProfileError) {
|
|
1044
|
+
const choice = await pickProfile(`"${selector}" matches several profiles. Which one?`,
|
|
1045
|
+
error.candidates.map(c => ({ label: c.label, value: c.id.replace(/^(chrome|registry):/, "") })));
|
|
1046
|
+
profile = (await resolveGlobalProfile(registry, cache, choice)).email;
|
|
1047
|
+
} else if (/does not match/.test(error.message)) {
|
|
1048
|
+
profile = (await resolveGlobalProfile(registry, cache, await pickProfile(`No profile matches "${selector}". Share which one?`))).email;
|
|
1049
|
+
} else throw error;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const config = await readListeners();
|
|
1053
|
+
const listeners = config?.listeners ?? [];
|
|
1054
|
+
// On a host, share through a scoped listener by default, never the management key.
|
|
1055
|
+
if (!listener && config) {
|
|
1056
|
+
try { listener = chooseShareListener(profile, listeners); }
|
|
1057
|
+
catch (error) {
|
|
1058
|
+
if (!interactive || !(error instanceof ShareListenerError)) throw error;
|
|
1059
|
+
const describe = (l: Listener) => `${l.name} ${l.publicUrl ?? `${listenerAddress(l)}${normalizePrefix(l.prefix)}`}`;
|
|
1060
|
+
if (error.kind === "several") {
|
|
1061
|
+
listener = (await promptChoice(`"${profile}" is on several listeners. Share through which?`, error.listeners.map(l => ({ label: describe(l), value: l.name })))) ?? undefined;
|
|
1062
|
+
if (!listener) throw cancelled();
|
|
1063
|
+
} else {
|
|
1064
|
+
if (!error.listeners.length) throw error; // nothing to allow it on: the error names `rech listener add`
|
|
1065
|
+
// Allowing is a config change, so the default is to cancel.
|
|
1066
|
+
const target = await promptChoice(`"${profile}" isn't shared on any listener yet. Allow it on:`,
|
|
1067
|
+
[...error.listeners.map(l => ({ label: describe(l), value: l.name as string | null })), { label: "Cancel (change nothing)", value: null }], error.listeners.length);
|
|
1068
|
+
if (!target) throw cancelled();
|
|
1069
|
+
allowProfiles(config, target, [profile]);
|
|
1070
|
+
await writeListeners(config);
|
|
1071
|
+
console.error(`[rech] allowed "${profile}" on ${target}`);
|
|
1072
|
+
listener = target;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
if (listeners.find(l => l.name === listener)?.profiles === "*")
|
|
1077
|
+
console.error(`[rech] "${listener}" is the local management listener: its key controls every profile. Don't share this URL.`);
|
|
911
1078
|
let uri = profileConnectionUri(profile, url, listeners, listener);
|
|
912
1079
|
// Prefer where a proxy exposes the listener, when it has been recorded.
|
|
913
1080
|
const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
|
|
@@ -977,15 +1144,45 @@ async function callServe(
|
|
|
977
1144
|
console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]`);
|
|
978
1145
|
process.exit(1);
|
|
979
1146
|
}
|
|
980
|
-
|
|
1147
|
+
const text = await res.text();
|
|
1148
|
+
try {
|
|
1149
|
+
return JSON.parse(text);
|
|
1150
|
+
} catch {
|
|
1151
|
+
// Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
|
|
1152
|
+
const detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
|
|
1153
|
+
if (throwOnFailure) throw new Error(detail);
|
|
1154
|
+
console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
|
|
1155
|
+
process.exit(1);
|
|
1156
|
+
}
|
|
981
1157
|
}
|
|
982
1158
|
|
|
1159
|
+
const BOOLEAN_OPEN_FLAGS = new Set(["--headed", "--persistent", "--in-memory", "--extension"]);
|
|
1160
|
+
|
|
983
1161
|
export function normalizeCommandArgs(args: string[]): string[] {
|
|
984
1162
|
const normalized = [...args];
|
|
985
1163
|
if (normalized[0] === "tabs" || normalized[0] === "list") normalized[0] = "tab-list";
|
|
1164
|
+
// `rech open hello.com`: profile-scoped listeners accept only HTTP(S)/about:blank targets, so
|
|
1165
|
+
// give a bare host an https:// scheme the way a browser address bar would.
|
|
1166
|
+
if (["open", "goto", "tab-new"].includes(normalized[0])) {
|
|
1167
|
+
// The target is the first positional. A token after a `--flag` without `=` is that flag's
|
|
1168
|
+
// value (`open --profile my-profile url`), unless the flag is a known boolean.
|
|
1169
|
+
const takesValue = (flag: string) => flag.startsWith("-") && !flag.includes("=") && !BOOLEAN_OPEN_FLAGS.has(flag);
|
|
1170
|
+
const i = normalized.findIndex((a, idx) => idx > 0 && !a.startsWith("-") && !takesValue(normalized[idx - 1]!));
|
|
1171
|
+
if (i > 0) normalized[i] = withDefaultScheme(normalized[i]!);
|
|
1172
|
+
}
|
|
986
1173
|
return normalized;
|
|
987
1174
|
}
|
|
988
1175
|
|
|
1176
|
+
/** `hello.com` -> `https://hello.com`, `localhost:3000` -> `http://localhost:3000`; URLs with a scheme and paths are unchanged. */
|
|
1177
|
+
export function withDefaultScheme(target: string): string {
|
|
1178
|
+
if (/^[./\\~]/.test(target) || /^[a-z]:[\\/]/i.test(target)) return target; // a file path, not a host
|
|
1179
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target) && !/^[^/:]+:\d+(\/|$|[?#])/.test(target)) return target;
|
|
1180
|
+
// Loopback by exact hostname: `localhost.example.com` / `127.example.com` are public hosts.
|
|
1181
|
+
const host = (target.match(/^(\[[^\]]*\]|[^/:?#]*)/)?.[1] ?? "").toLowerCase();
|
|
1182
|
+
const loopback = host === "localhost" || host === "[::1]" || /^127(\.\d{1,3}){3}$/.test(host);
|
|
1183
|
+
return loopback ? `http://${target}` : `https://${target}`;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
989
1186
|
// Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
|
|
990
1187
|
// Only flags before the first positional (the playwright subcommand) are rech globals — a
|
|
991
1188
|
// --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
|
|
@@ -1061,7 +1258,7 @@ export function notConnectedMessage(): string {
|
|
|
1061
1258
|
return [
|
|
1062
1259
|
`rech: not connected to a rechrome daemon (${ENV_KEY} is not set).`,
|
|
1063
1260
|
` On the machine with Chrome: rech setup`,
|
|
1064
|
-
` On another machine: rech connect '<URL printed by \`rech
|
|
1261
|
+
` On another machine: rech connect '<URL printed by \`rech share\` on that machine>'`,
|
|
1065
1262
|
].join("\n");
|
|
1066
1263
|
}
|
|
1067
1264
|
|
|
@@ -1079,7 +1276,7 @@ export function unknownCommandHint(output: string, rechCommands: Iterable<string
|
|
|
1079
1276
|
const best = scored[0] && scored[0].d <= Math.max(1, Math.floor(unknown.length / 3)) ? scored[0].c : null;
|
|
1080
1277
|
return [
|
|
1081
1278
|
`rech: unknown command "${unknown}".${best ? ` Did you mean "${best}"?` : ""}`,
|
|
1082
|
-
` rech --help rechrome commands (setup, status, profile,
|
|
1279
|
+
` rech --help rechrome commands (setup, status, profile, share, connect, listener…)`,
|
|
1083
1280
|
` rech pw --help browser commands (open, click, screenshot…)`,
|
|
1084
1281
|
].join("\n");
|
|
1085
1282
|
}
|
|
@@ -1116,7 +1313,9 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
1116
1313
|
}
|
|
1117
1314
|
if (stderr) {
|
|
1118
1315
|
if (stderr.includes('Extension connection timeout')) {
|
|
1119
|
-
|
|
1316
|
+
// The daemon resolves registered profiles' bridge tokens itself and then asks for a reload;
|
|
1317
|
+
// only blame a missing install when neither side had credentials.
|
|
1318
|
+
const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] || /reload the .*extension/i.test(stderr);
|
|
1120
1319
|
const last = hasToken
|
|
1121
1320
|
? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
|
|
1122
1321
|
: ` -> extension[not installed] (run: rech setup)`;
|
|
@@ -1405,6 +1604,8 @@ function daemonManager(): DaemonManager {
|
|
|
1405
1604
|
isWindows: IS_WINDOWS,
|
|
1406
1605
|
override: process.env.RECH_DAEMON_MANAGER,
|
|
1407
1606
|
});
|
|
1607
|
+
if (isDeprecatedPm2Fallback(_daemonMgr, { isWindows: IS_WINDOWS, override: process.env.RECH_DAEMON_MANAGER }))
|
|
1608
|
+
console.error(`[rech] warning: using pm2 because oxmgr is not on PATH. ${PM2_DEPRECATION}`);
|
|
1408
1609
|
return _daemonMgr;
|
|
1409
1610
|
}
|
|
1410
1611
|
|
|
@@ -1441,7 +1642,7 @@ async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
|
|
|
1441
1642
|
}
|
|
1442
1643
|
|
|
1443
1644
|
// Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
|
|
1444
|
-
//
|
|
1645
|
+
// Match a name in it with listsProcess, not a substring test.
|
|
1445
1646
|
async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
|
|
1446
1647
|
const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
1447
1648
|
return await new Response(proc.stdout).text();
|
|
@@ -1489,18 +1690,22 @@ export function resolvePlaywrightCli(root: string = import.meta.dir): string {
|
|
|
1489
1690
|
/**
|
|
1490
1691
|
* Make sure a daemon process manager is available, offering to install oxmgr
|
|
1491
1692
|
* (default No; --yes approves). An explicit RECH_DAEMON_MANAGER=pm2 is respected,
|
|
1492
|
-
* since installing oxmgr would not satisfy it.
|
|
1693
|
+
* since installing oxmgr would not satisfy it. When only the deprecated pm2 fallback
|
|
1694
|
+
* is available (POSIX without oxmgr), the offer is made too, but declining keeps pm2.
|
|
1493
1695
|
*/
|
|
1494
1696
|
async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<string>, yes = false): Promise<void> {
|
|
1697
|
+
let fallback: DaemonManager | undefined;
|
|
1495
1698
|
try {
|
|
1496
|
-
daemonManager();
|
|
1497
|
-
return;
|
|
1699
|
+
fallback = daemonManager();
|
|
1700
|
+
if (!isDeprecatedPm2Fallback(fallback, { isWindows: IS_WINDOWS, override: process.env.RECH_DAEMON_MANAGER })) return;
|
|
1498
1701
|
} catch (error) {
|
|
1499
1702
|
if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
|
|
1500
1703
|
}
|
|
1501
1704
|
const command = oxmgrInstallCommand(process.env);
|
|
1502
|
-
const
|
|
1705
|
+
const reason = fallback ? "Only the deprecated pm2 is available" : "oxmgr is missing";
|
|
1706
|
+
const answer = yes ? "yes" : (await ask(` ${reason}. Install oxmgr globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
|
|
1503
1707
|
if (!/^(y|yes)$/i.test(answer)) {
|
|
1708
|
+
if (fallback) return; // keep the working pm2 setup
|
|
1504
1709
|
throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
|
|
1505
1710
|
}
|
|
1506
1711
|
console.log(` Installing oxmgr: ${command.join(" ")}`);
|
|
@@ -1555,6 +1760,22 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
1555
1760
|
|
|
1556
1761
|
// Drop any prior registration (current + legacy names) before re-adding.
|
|
1557
1762
|
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
1763
|
+
// Migrating from pm2 to oxmgr: a serve still registered in pm2 would hold the port and be
|
|
1764
|
+
// resurrected at login, fighting the oxmgr-managed one. Remove it from pm2 too.
|
|
1765
|
+
const pm2Bin = mgr.id === "oxmgr" ? Bun.which("pm2") : null;
|
|
1766
|
+
if (pm2Bin) {
|
|
1767
|
+
const pm2: DaemonManager = { id: "pm2", bin: pm2Bin };
|
|
1768
|
+
const listed = await pmList(pm2).catch(() => "");
|
|
1769
|
+
const stale = [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES].filter(name => listsProcess("pm2", listed, name));
|
|
1770
|
+
if (stale.length) {
|
|
1771
|
+
console.log(` Migrating from pm2: removing ${stale.join(", ")}`);
|
|
1772
|
+
for (const name of stale) await runPm(pm2, ["delete", name]);
|
|
1773
|
+
// --force: pm2 won't save an empty list otherwise, keeping the old dump that `pm2 resurrect`
|
|
1774
|
+
// would bring back at login to fight the oxmgr-managed serve over the port.
|
|
1775
|
+
if (await runPm(pm2, ["save", "--force"]) !== 0)
|
|
1776
|
+
console.warn(" pm2 save failed; run `pm2 save --force` so pm2 doesn't resurrect the old serve at login.");
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1558
1779
|
|
|
1559
1780
|
let startCode: number;
|
|
1560
1781
|
if (mgr.id === "pm2") {
|
|
@@ -1593,7 +1814,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
1593
1814
|
async function daemonUninstall(): Promise<void> {
|
|
1594
1815
|
const mgr = daemonManager();
|
|
1595
1816
|
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
1596
|
-
if (mgr.id === "pm2") await runPm(mgr, ["save"]);
|
|
1817
|
+
if (mgr.id === "pm2") await runPm(mgr, ["save", "--force"]); // an emptied list must still overwrite the dump
|
|
1597
1818
|
else await runPm(mgr, ["service", "uninstall"]);
|
|
1598
1819
|
console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
|
|
1599
1820
|
}
|
|
@@ -1972,7 +2193,7 @@ export function listenerNextSteps(listener: Listener, profile?: string): string[
|
|
|
1972
2193
|
` tailscale serve --bg${mount ? ` --set-path=${mount}` : ""} ${listener.port}`,
|
|
1973
2194
|
`Then record where it is reachable and print the URL to share:`,
|
|
1974
2195
|
` rech listener set ${listener.name} --public-url https://<your-host>${prefix}`,
|
|
1975
|
-
` rech
|
|
2196
|
+
` rech share ${profile ? JSON.stringify(profile) : "<profile>"}`,
|
|
1976
2197
|
];
|
|
1977
2198
|
}
|
|
1978
2199
|
|
|
@@ -2024,7 +2245,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
2024
2245
|
const config = await requireListeners();
|
|
2025
2246
|
rotateListenerKey(config, name);
|
|
2026
2247
|
await writeListeners(config);
|
|
2027
|
-
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech
|
|
2248
|
+
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
2028
2249
|
}
|
|
2029
2250
|
|
|
2030
2251
|
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
|
|
@@ -2044,7 +2265,7 @@ async function urlList(): Promise<void> {
|
|
|
2044
2265
|
for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
|
|
2045
2266
|
}
|
|
2046
2267
|
printTable(rows);
|
|
2047
|
-
console.log(`\nPrint a full URL (contains the secret key): rech
|
|
2268
|
+
console.log(`\nPrint a full URL (contains the secret key): rech share <profile>`);
|
|
2048
2269
|
}
|
|
2049
2270
|
|
|
2050
2271
|
/** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
|
|
@@ -2062,7 +2283,7 @@ async function saveProjectUrl(url: string): Promise<string> {
|
|
|
2062
2283
|
|
|
2063
2284
|
async function connect(url: string): Promise<void> {
|
|
2064
2285
|
const parsed = parseUrl(url);
|
|
2065
|
-
if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech
|
|
2286
|
+
if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
|
|
2066
2287
|
const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
|
|
2067
2288
|
.catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
|
|
2068
2289
|
if (response.status === 401) throw new Error("The daemon rejected this key; ask the host for a fresh URL (keys change on rech listener rotate-key).");
|
|
@@ -2492,25 +2713,28 @@ async function status(): Promise<void> {
|
|
|
2492
2713
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
2493
2714
|
console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : `not reachable at ${serviceUrl(url)}`}`);
|
|
2494
2715
|
if (pingResponse?.status === 401)
|
|
2495
|
-
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech
|
|
2716
|
+
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
|
|
2496
2717
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
2497
2718
|
// ReferenceError that took down the whole of `rech status`, so the one command
|
|
2498
2719
|
// that reports "the relay is wedged" died exactly when the relay was wedged,
|
|
2499
2720
|
// printing a stack trace instead of the restart hint.
|
|
2500
|
-
if (pingBody?.degraded)
|
|
2501
|
-
console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${daemonManager().id} restart ${PM_PROCESS_NAME}\``);
|
|
2502
2721
|
// The daemon line is about this machine; a client of a remote host has no local daemon to report.
|
|
2503
2722
|
const isHost = !!(await readListeners().catch(() => null));
|
|
2723
|
+
// No oxmgr/pm2 on PATH must not take down `rech status`: report it instead of throwing.
|
|
2724
|
+
let mgr: DaemonManager | undefined;
|
|
2725
|
+
if (isHost) try { mgr = daemonManager(); } catch { /* reported below */ }
|
|
2726
|
+
if (pingBody?.degraded)
|
|
2727
|
+
console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; ${isHost ? `force it now with \`${mgr?.id ?? "oxmgr"} restart ${PM_PROCESS_NAME}\`` : "the daemon host can restart it"}`);
|
|
2504
2728
|
if (isHost) {
|
|
2505
|
-
const daemonRegistered = (await pmList()
|
|
2506
|
-
console.log(`daemon: ${daemonRegistered ? `${
|
|
2729
|
+
const daemonRegistered = mgr ? listsProcess(mgr.id, await pmList(mgr).catch(() => ""), PM_PROCESS_NAME) : false;
|
|
2730
|
+
console.log(`daemon: ${daemonRegistered ? `${mgr!.id} (${PM_PROCESS_NAME})` : mgr ? "not installed" : "not installed (no oxmgr or pm2 on PATH)"}`);
|
|
2507
2731
|
}
|
|
2508
2732
|
// Same resolution as a command: ?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY.
|
|
2509
2733
|
const effective = resolveEffectiveProfile(parsed.profileDirectory);
|
|
2510
2734
|
const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
|
|
2511
2735
|
const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
|
|
2512
2736
|
console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
|
|
2513
|
-
if (isHost) console.log(`\nMore: rech profile (profiles) · rech
|
|
2737
|
+
if (isHost) console.log(`\nMore: rech profile (profiles) · rech share ls (who can connect, and where)`);
|
|
2514
2738
|
}
|
|
2515
2739
|
|
|
2516
2740
|
|
|
@@ -2537,7 +2761,7 @@ export type RechHandlers = {
|
|
|
2537
2761
|
};
|
|
2538
2762
|
|
|
2539
2763
|
/** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
|
|
2540
|
-
export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "
|
|
2764
|
+
export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "share", "connect", "setup", "tray", "provision-profile", "uninstall"]);
|
|
2541
2765
|
|
|
2542
2766
|
const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
|
|
2543
2767
|
|
|
@@ -2564,7 +2788,7 @@ Examples:
|
|
|
2564
2788
|
rech setup --profile you@example.com set up Chrome on this machine
|
|
2565
2789
|
rech open https://example.com open a page in this project's session
|
|
2566
2790
|
rech screenshot saved to <project>/.rechrome/output/
|
|
2567
|
-
rech
|
|
2791
|
+
rech share you@example.com URL to give another machine (secret)
|
|
2568
2792
|
rech connect '<url>' use that URL in this project
|
|
2569
2793
|
|
|
2570
2794
|
Run \`rech <command> --help\` for a command's options. Tutorial: https://github.com/snomiao/rechrome#tutorial`;
|
|
@@ -2588,24 +2812,24 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
2588
2812
|
.command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
|
|
2589
2813
|
.command(["profile [name]", "profiles [name]"], "List Chrome profiles and whether each is connected", y => y
|
|
2590
2814
|
.positional("name", { type: "string", describe: "ls/list lists all (the default)" })
|
|
2591
|
-
.option("print-uri", { type: "boolean", describe: "Same as `rech
|
|
2815
|
+
.option("print-uri", { type: "boolean", describe: "Same as `rech share <name>`" })
|
|
2592
2816
|
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
|
|
2593
2817
|
a => {
|
|
2594
|
-
if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech
|
|
2818
|
+
if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech share`
|
|
2595
2819
|
if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
|
|
2596
|
-
throw new Error(`To
|
|
2820
|
+
throw new Error(`To share "${a.name}" with another machine: rech share ${JSON.stringify(a.name)}. To list profiles: rech profile`);
|
|
2597
2821
|
})
|
|
2598
2822
|
// Share with and connect from other machines
|
|
2599
|
-
.command(
|
|
2600
|
-
.positional("profile", { type: "string", describe: "Profile
|
|
2601
|
-
.option("listener", { type: "string", requiresArg: true, describe: "Listener to
|
|
2823
|
+
.command("share [profile]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
|
|
2824
|
+
.positional("profile", { type: "string", describe: "Profile: email, name, folder, or a unique part of one; ls/list lists everything shared" })
|
|
2825
|
+
.option("listener", { type: "string", requiresArg: true, describe: "Listener to share through (default: the one that allows the profile)" })
|
|
2602
2826
|
.option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
|
|
2603
2827
|
.option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
|
|
2604
2828
|
a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
|
|
2605
2829
|
? handlers.urlList()
|
|
2606
2830
|
: handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
|
|
2607
2831
|
.command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
|
|
2608
|
-
.positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech
|
|
2832
|
+
.positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
|
|
2609
2833
|
.example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", ""),
|
|
2610
2834
|
a => handlers.connect(a.url))
|
|
2611
2835
|
.command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
|