rechrome 1.28.2 → 1.29.1
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/package.json +1 -1
- package/rechrome.js +199 -32
- package/rechrome.ts +199 -32
- package/serve.js +240 -7
- package/serve.ts +240 -7
package/README.md
CHANGED
|
@@ -88,13 +88,14 @@ On the host (the machine with Chrome):
|
|
|
88
88
|
rech listener add share --listen local --prefix=rechrome --port 13776 --profile you@example.com
|
|
89
89
|
tailscale serve --bg --set-path=/rechrome 13776
|
|
90
90
|
rech listener set share --public-url https://host.example.ts.net/rechrome/
|
|
91
|
-
rech
|
|
91
|
+
rech share you@example.com # prints the URL to share — it contains a secret key
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
`rech listener add` prints these follow-up lines with your port filled in. For scripts,
|
|
95
95
|
`rech listener port share` prints the port (`$(rech listener port share)` in bash or PowerShell).
|
|
96
96
|
|
|
97
|
-
On the other machine, inside the project that should use it
|
|
97
|
+
On the other machine, inside the project that should use it (or just open the link in a
|
|
98
|
+
browser: it shows these commands, per shell, with a Copy button):
|
|
98
99
|
|
|
99
100
|
```bash
|
|
100
101
|
rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'
|
|
@@ -106,13 +107,13 @@ rech open https://example.com
|
|
|
106
107
|
interactions, but not `eval`/`run-code` or filesystem commands (see [Remote access](#remote-access)).
|
|
107
108
|
|
|
108
109
|
On a trusted LAN without a proxy, `rech setup --listen lan --profile you@example.com` binds the
|
|
109
|
-
profile to your LAN address directly (plain HTTP); share the result of `rech
|
|
110
|
+
profile to your LAN address directly (plain HTTP); share the result of `rech share`.
|
|
110
111
|
|
|
111
112
|
### 5. Manage access
|
|
112
113
|
|
|
113
114
|
```bash
|
|
114
|
-
rech
|
|
115
|
-
rech
|
|
115
|
+
rech share ls # everything shared: listener × profile, local and public URLs (keys hidden)
|
|
116
|
+
rech share you@example.com # print one URL again (add --save to use it in this project)
|
|
116
117
|
rech listener allow share teammate@example.com
|
|
117
118
|
rech listener deny share teammate@example.com
|
|
118
119
|
rech listener rotate-key share # revoke: every URL for this listener stops working
|
|
@@ -193,7 +194,7 @@ Connection parameters also accept URL fragments:
|
|
|
193
194
|
RECHROME_URL='https://your-host.ts.net/rechrome/?profile=qa#key=DAEMON_KEY' rech status
|
|
194
195
|
```
|
|
195
196
|
|
|
196
|
-
`rech setup` prints and saves this URI format. Retrieve it later with `rech
|
|
197
|
+
`rech setup` prints and saves this URI format. Retrieve it later with `rech share qa` (alias: `rech profile qa --print-uri`), or omit `qa` to use the configured profile. `profiles` remains an alias. The command prints only the URI to stdout, using the configured `RECHROME_URL` endpoint; `--listener local` selects a local listener instead. For example: `rech profile qa --print-uri --listener local`. The output contains a secret daemon key.
|
|
197
198
|
|
|
198
199
|
Direct connections use the root path, such as `http://127.0.0.1:13775/?profile=qa#key=DAEMON_KEY`. A prefix is optional and only added when explicitly configured with `--prefix`, for example for a proxy mounted at `/rechrome/`. Tailscale can also serve at the root without a prefix.
|
|
199
200
|
|
|
@@ -207,7 +208,7 @@ cp .env.example .env.local
|
|
|
207
208
|
|
|
208
209
|
| Variable | Description | Default |
|
|
209
210
|
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
|
210
|
-
| `RECHROME_URL` | Connection URL, saved by `rech setup` / `rech connect` / `rech
|
|
211
|
+
| `RECHROME_URL` | Connection URL, saved by `rech setup` / `rech connect` / `rech share --save`. Also accepts `?extension_id=`, `?token=`, `?profile=` query params | — |
|
|
211
212
|
| `PLAYWRIGHT_CLI` | Override the playwright-cli command/path (defaults to the bundled `@playwright/cli`; set this only for a custom or forked CLI) | bundled `@playwright/cli` |
|
|
212
213
|
| `RECH_HOST` | Legacy bind address, used only before listeners.json is configured | `127.0.0.1` |
|
|
213
214
|
| `PLAYWRIGHT_MCP_EXTENSION_ID` | Chrome extension ID (client overrides server) | — |
|
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -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;
|
|
@@ -1091,7 +1258,7 @@ export function notConnectedMessage(): string {
|
|
|
1091
1258
|
return [
|
|
1092
1259
|
`rech: not connected to a rechrome daemon (${ENV_KEY} is not set).`,
|
|
1093
1260
|
` On the machine with Chrome: rech setup`,
|
|
1094
|
-
` On another machine: rech connect '<URL printed by \`rech
|
|
1261
|
+
` On another machine: rech connect '<URL printed by \`rech share\` on that machine>'`,
|
|
1095
1262
|
].join("\n");
|
|
1096
1263
|
}
|
|
1097
1264
|
|
|
@@ -1109,7 +1276,7 @@ export function unknownCommandHint(output: string, rechCommands: Iterable<string
|
|
|
1109
1276
|
const best = scored[0] && scored[0].d <= Math.max(1, Math.floor(unknown.length / 3)) ? scored[0].c : null;
|
|
1110
1277
|
return [
|
|
1111
1278
|
`rech: unknown command "${unknown}".${best ? ` Did you mean "${best}"?` : ""}`,
|
|
1112
|
-
` rech --help rechrome commands (setup, status, profile,
|
|
1279
|
+
` rech --help rechrome commands (setup, status, profile, share, connect, listener…)`,
|
|
1113
1280
|
` rech pw --help browser commands (open, click, screenshot…)`,
|
|
1114
1281
|
].join("\n");
|
|
1115
1282
|
}
|
|
@@ -2026,7 +2193,7 @@ export function listenerNextSteps(listener: Listener, profile?: string): string[
|
|
|
2026
2193
|
` tailscale serve --bg${mount ? ` --set-path=${mount}` : ""} ${listener.port}`,
|
|
2027
2194
|
`Then record where it is reachable and print the URL to share:`,
|
|
2028
2195
|
` rech listener set ${listener.name} --public-url https://<your-host>${prefix}`,
|
|
2029
|
-
` rech
|
|
2196
|
+
` rech share ${profile ? JSON.stringify(profile) : "<profile>"}`,
|
|
2030
2197
|
];
|
|
2031
2198
|
}
|
|
2032
2199
|
|
|
@@ -2078,7 +2245,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
2078
2245
|
const config = await requireListeners();
|
|
2079
2246
|
rotateListenerKey(config, name);
|
|
2080
2247
|
await writeListeners(config);
|
|
2081
|
-
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}`);
|
|
2082
2249
|
}
|
|
2083
2250
|
|
|
2084
2251
|
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
|
|
@@ -2098,7 +2265,7 @@ async function urlList(): Promise<void> {
|
|
|
2098
2265
|
for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
|
|
2099
2266
|
}
|
|
2100
2267
|
printTable(rows);
|
|
2101
|
-
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>`);
|
|
2102
2269
|
}
|
|
2103
2270
|
|
|
2104
2271
|
/** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
|
|
@@ -2116,7 +2283,7 @@ async function saveProjectUrl(url: string): Promise<string> {
|
|
|
2116
2283
|
|
|
2117
2284
|
async function connect(url: string): Promise<void> {
|
|
2118
2285
|
const parsed = parseUrl(url);
|
|
2119
|
-
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>");
|
|
2120
2287
|
const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
|
|
2121
2288
|
.catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
|
|
2122
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).");
|
|
@@ -2546,7 +2713,7 @@ async function status(): Promise<void> {
|
|
|
2546
2713
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
2547
2714
|
console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : `not reachable at ${serviceUrl(url)}`}`);
|
|
2548
2715
|
if (pingResponse?.status === 401)
|
|
2549
|
-
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>'\``);
|
|
2550
2717
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
2551
2718
|
// ReferenceError that took down the whole of `rech status`, so the one command
|
|
2552
2719
|
// that reports "the relay is wedged" died exactly when the relay was wedged,
|
|
@@ -2567,7 +2734,7 @@ async function status(): Promise<void> {
|
|
|
2567
2734
|
const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
|
|
2568
2735
|
const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
|
|
2569
2736
|
console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
|
|
2570
|
-
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)`);
|
|
2571
2738
|
}
|
|
2572
2739
|
|
|
2573
2740
|
|
|
@@ -2594,7 +2761,7 @@ export type RechHandlers = {
|
|
|
2594
2761
|
};
|
|
2595
2762
|
|
|
2596
2763
|
/** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
|
|
2597
|
-
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"]);
|
|
2598
2765
|
|
|
2599
2766
|
const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
|
|
2600
2767
|
|
|
@@ -2621,7 +2788,7 @@ Examples:
|
|
|
2621
2788
|
rech setup --profile you@example.com set up Chrome on this machine
|
|
2622
2789
|
rech open https://example.com open a page in this project's session
|
|
2623
2790
|
rech screenshot saved to <project>/.rechrome/output/
|
|
2624
|
-
rech
|
|
2791
|
+
rech share you@example.com URL to give another machine (secret)
|
|
2625
2792
|
rech connect '<url>' use that URL in this project
|
|
2626
2793
|
|
|
2627
2794
|
Run \`rech <command> --help\` for a command's options. Tutorial: https://github.com/snomiao/rechrome#tutorial`;
|
|
@@ -2645,24 +2812,24 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
2645
2812
|
.command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
|
|
2646
2813
|
.command(["profile [name]", "profiles [name]"], "List Chrome profiles and whether each is connected", y => y
|
|
2647
2814
|
.positional("name", { type: "string", describe: "ls/list lists all (the default)" })
|
|
2648
|
-
.option("print-uri", { type: "boolean", describe: "Same as `rech
|
|
2815
|
+
.option("print-uri", { type: "boolean", describe: "Same as `rech share <name>`" })
|
|
2649
2816
|
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
|
|
2650
2817
|
a => {
|
|
2651
|
-
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`
|
|
2652
2819
|
if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
|
|
2653
|
-
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`);
|
|
2654
2821
|
})
|
|
2655
2822
|
// Share with and connect from other machines
|
|
2656
|
-
.command(
|
|
2657
|
-
.positional("profile", { type: "string", describe: "Profile
|
|
2658
|
-
.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)" })
|
|
2659
2826
|
.option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
|
|
2660
2827
|
.option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
|
|
2661
2828
|
a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
|
|
2662
2829
|
? handlers.urlList()
|
|
2663
2830
|
: handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
|
|
2664
2831
|
.command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
|
|
2665
|
-
.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 #" })
|
|
2666
2833
|
.example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", ""),
|
|
2667
2834
|
a => handlers.connect(a.url))
|
|
2668
2835
|
.command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
|