replica-bridge 0.1.0 → 0.2.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 +7 -1
- package/package.json +2 -2
- package/src/bridge/command.js +28 -0
- package/src/bridge/install.js +253 -0
- package/src/bridge/main.js +130 -30
- package/src/bridge/service.js +21 -9
package/README.md
CHANGED
|
@@ -5,4 +5,10 @@ Lets the ChatReplica iPhone app use Codex on this computer. Needs Codex installe
|
|
|
5
5
|
npx replica-bridge
|
|
6
6
|
|
|
7
7
|
The first run sets this computer up with the relay, shows a QR code to scan in the app (远程 › 添加连接 › 配对新设备),
|
|
8
|
-
|
|
8
|
+
then offers to keep running in the background and start at login (macOS launchd, Linux systemd, Windows Task
|
|
9
|
+
Scheduler), so the terminal can be closed. Run it again for the status.
|
|
10
|
+
|
|
11
|
+
npx replica-bridge pair pair another phone
|
|
12
|
+
npx replica-bridge uninstall stop and remove the background service
|
|
13
|
+
|
|
14
|
+
`npx replica-bridge --help` lists the other commands.
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "replica-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"replica-bridge": "src/bridge/main.js"
|
|
8
8
|
},
|
|
9
9
|
"engines": {
|
|
10
|
-
"node": ">=20"
|
|
10
|
+
"node": ">=20.11"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"qrcode": "1.5.4",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Running Codex, npm and the service managers the same way on macOS, Linux and Windows.
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
/** One argument as cmd.exe reads it. */
|
|
6
|
+
export function quoteForCmd(arg ) {
|
|
7
|
+
return /^[\w\-.:\\/=@]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '""')}"`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* On Windows npm installs commands such as codex as `codex.cmd`, which Node runs only through cmd.exe
|
|
12
|
+
* (spawn without a shell fails with EINVAL or ENOENT), so the command line goes through the shell there.
|
|
13
|
+
*/
|
|
14
|
+
export function spawnCommand(command , args , options = {}) {
|
|
15
|
+
if (process.platform !== "win32") return spawn(command, args, options);
|
|
16
|
+
return spawn([command, ...args].map(quoteForCmd).join(" "), [], { ...options, shell: true, windowsHide: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function spawnCommandSync(command , args , options = {}) {
|
|
20
|
+
if (process.platform !== "win32") return spawnSync(command, args, options);
|
|
21
|
+
return spawnSync([command, ...args].map(quoteForCmd).join(" "), [], { ...options, shell: true, windowsHide: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Stops a command from spawnCommand; on Windows that is cmd.exe, so the whole tree goes (taskkill /T). */
|
|
25
|
+
export function killCommand(child ) {
|
|
26
|
+
if (process.platform === "win32" && child.pid && child.exitCode === null) spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
27
|
+
else child.kill();
|
|
28
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Keeping the bridge running after its terminal closes, the way Codex keeps its app-server: a per-user background
|
|
2
|
+
// service that starts at login and restarts when it fails. macOS: a launchd agent. Linux: a systemd user service.
|
|
3
|
+
// Windows: a Task Scheduler task at logon that runs a hidden script, which restarts the bridge when it exits.
|
|
4
|
+
// The service runs a copy installed under ~/.config/replica-bridge/app, not the npx cache, which npm may clear.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { spawnCommandSync } from "./command.js";
|
|
9
|
+
import { bridgeHome } from "./state.js";
|
|
10
|
+
|
|
11
|
+
export const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../package.json"), "utf8")).version;
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
export const serviceLabel = () => process.env.REPLICA_BRIDGE_LABEL ?? "replica-bridge";
|
|
37
|
+
export const logPath = (home = bridgeHome()) => path.join(home, "bridge.log");
|
|
38
|
+
const infoPath = () => path.join(bridgeHome(), "service.json");
|
|
39
|
+
const pidPath = () => path.join(bridgeHome(), "bridge.pid");
|
|
40
|
+
|
|
41
|
+
const xml = (text ) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
42
|
+
/** systemd unit-file quoting: specifiers (%) doubled, backslashes and quotes escaped. */
|
|
43
|
+
const unitQuote = (text ) => `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
44
|
+
/** A VBScript string literal. */
|
|
45
|
+
const vbs = (text ) => `"${text.replace(/"/g, '""')}"`;
|
|
46
|
+
|
|
47
|
+
/** The service files and the commands that load and unload them; pure, so each platform can be checked anywhere. */
|
|
48
|
+
export function servicePlan(spec ) {
|
|
49
|
+
const log = logPath(spec.bridgeHome);
|
|
50
|
+
switch (spec.platform) {
|
|
51
|
+
case "darwin": {
|
|
52
|
+
const label = `com.chatreplica.${spec.label}`;
|
|
53
|
+
const plist = path.join(spec.home, "Library", "LaunchAgents", `${label}.plist`);
|
|
54
|
+
const target = `gui/${spec.uid}`;
|
|
55
|
+
const env = Object.entries(spec.env).map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`).join("\n");
|
|
56
|
+
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
57
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
58
|
+
<plist version="1.0">
|
|
59
|
+
<dict>
|
|
60
|
+
<key>Label</key><string>${xml(label)}</string>
|
|
61
|
+
<key>ProgramArguments</key>
|
|
62
|
+
<array>
|
|
63
|
+
${spec.program.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
|
|
64
|
+
</array>
|
|
65
|
+
<key>EnvironmentVariables</key>
|
|
66
|
+
<dict>
|
|
67
|
+
${env}
|
|
68
|
+
</dict>
|
|
69
|
+
<key>RunAtLoad</key><true/>
|
|
70
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
71
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
72
|
+
<key>ProcessType</key><string>Background</string>
|
|
73
|
+
<key>StandardOutPath</key><string>${xml(log)}</string>
|
|
74
|
+
<key>StandardErrorPath</key><string>${xml(log)}</string>
|
|
75
|
+
</dict>
|
|
76
|
+
</plist>
|
|
77
|
+
`;
|
|
78
|
+
return {
|
|
79
|
+
files: [{ path: plist, content }],
|
|
80
|
+
install: [
|
|
81
|
+
{ command: "launchctl", args: ["bootout", `${target}/${label}`], optional: true },
|
|
82
|
+
{ command: "launchctl", args: ["bootstrap", target, plist] },
|
|
83
|
+
],
|
|
84
|
+
uninstall: [{ command: "launchctl", args: ["bootout", `${target}/${label}`], optional: true }],
|
|
85
|
+
where: `launchd agent ${label} (${plist})`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
case "linux": {
|
|
89
|
+
const unit = `${spec.label}.service`;
|
|
90
|
+
const file = path.join(spec.home, ".config", "systemd", "user", unit);
|
|
91
|
+
const content = `[Unit]
|
|
92
|
+
Description=replica-bridge: ChatReplica phones use Codex on this computer
|
|
93
|
+
After=network-online.target
|
|
94
|
+
Wants=network-online.target
|
|
95
|
+
|
|
96
|
+
[Service]
|
|
97
|
+
ExecStart=${spec.program.map(unitQuote).join(" ")}
|
|
98
|
+
Restart=on-failure
|
|
99
|
+
RestartSec=10
|
|
100
|
+
${Object.entries(spec.env).map(([k, v]) => `Environment=${unitQuote(`${k}=${v}`)}`).join("\n")}
|
|
101
|
+
StandardOutput=append:${log}
|
|
102
|
+
StandardError=append:${log}
|
|
103
|
+
|
|
104
|
+
[Install]
|
|
105
|
+
WantedBy=default.target
|
|
106
|
+
`;
|
|
107
|
+
return {
|
|
108
|
+
files: [{ path: file, content }],
|
|
109
|
+
install: [
|
|
110
|
+
{ command: "systemctl", args: ["--user", "daemon-reload"] },
|
|
111
|
+
{ command: "systemctl", args: ["--user", "enable", unit] },
|
|
112
|
+
{ command: "systemctl", args: ["--user", "restart", unit] },
|
|
113
|
+
],
|
|
114
|
+
uninstall: [
|
|
115
|
+
{ command: "systemctl", args: ["--user", "disable", "--now", unit], optional: true },
|
|
116
|
+
],
|
|
117
|
+
where: `systemd user service ${unit} (${file})`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
case "win32": {
|
|
121
|
+
const script = path.join(spec.bridgeHome, "run-bridge.vbs");
|
|
122
|
+
// wscript shows no window; the loop brings the bridge back ten seconds after it fails. Exit code 0 means another
|
|
123
|
+
// bridge took this computer over (or it was told to stop), so the loop ends.
|
|
124
|
+
const content = [
|
|
125
|
+
`' replica-bridge background service (Task Scheduler task "${spec.label}"). Made by "replica-bridge install".`,
|
|
126
|
+
`Set shell = CreateObject("WScript.Shell")`,
|
|
127
|
+
...Object.entries({ ...spec.env, REPLICA_BRIDGE_LOG: log }).map(([k, v]) => `shell.Environment("Process")(${vbs(k)}) = ${vbs(v)}`),
|
|
128
|
+
`Do`,
|
|
129
|
+
` code = shell.Run(${vbs(spec.program.map((a) => `"${a}"`).join(" "))}, 0, True)`,
|
|
130
|
+
` If code = 0 Then Exit Do`,
|
|
131
|
+
` WScript.Sleep 10000`,
|
|
132
|
+
`Loop`,
|
|
133
|
+
"",
|
|
134
|
+
].join("\r\n");
|
|
135
|
+
return {
|
|
136
|
+
files: [{ path: script, content }],
|
|
137
|
+
install: [
|
|
138
|
+
{ command: "schtasks", args: ["/End", "/TN", spec.label], optional: true },
|
|
139
|
+
{ command: "schtasks", args: ["/Create", "/F", "/TN", spec.label, "/SC", "ONLOGON", "/RL", "LIMITED", "/TR", `wscript.exe "${script}"`] },
|
|
140
|
+
{ command: "schtasks", args: ["/Run", "/TN", spec.label] },
|
|
141
|
+
],
|
|
142
|
+
uninstall: [
|
|
143
|
+
{ command: "schtasks", args: ["/End", "/TN", spec.label], optional: true },
|
|
144
|
+
{ command: "schtasks", args: ["/Delete", "/F", "/TN", spec.label], optional: true },
|
|
145
|
+
],
|
|
146
|
+
where: `Task Scheduler task "${spec.label}" (${script})`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
throw new Error(`no background service for ${spec.platform}: keep "replica-bridge start" running yourself`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What the service passes on: PATH (so it finds codex and node) and the bridge's and Codex's own settings. */
|
|
155
|
+
function serviceEnv() {
|
|
156
|
+
const env = {};
|
|
157
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
158
|
+
if (value !== undefined && (key === "PATH" || key === "Path" || /^(CODEX_|REPLICA_|RELAY_ENV$)/.test(key)) && key !== "REPLICA_BRIDGE_LOG") env[key] = value;
|
|
159
|
+
}
|
|
160
|
+
env.REPLICA_BRIDGE_SERVICE = "1";
|
|
161
|
+
return env;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The script the service runs. From npx (inside node_modules) a copy of this version is installed under
|
|
166
|
+
* ~/.config/replica-bridge/app, since npm may clear its npx cache; from a working copy, the working copy itself.
|
|
167
|
+
*/
|
|
168
|
+
function installedScript(say ) {
|
|
169
|
+
const current = process.argv[1] ? fs.realpathSync(process.argv[1]) : "";
|
|
170
|
+
if (!current.split(path.sep).includes("node_modules")) return current;
|
|
171
|
+
const app = path.join(bridgeHome(), "app");
|
|
172
|
+
const spec = process.env.REPLICA_BRIDGE_PACKAGE ?? `replica-bridge@${PACKAGE_VERSION}`;
|
|
173
|
+
say(`Installing ${spec} in ${app}…`);
|
|
174
|
+
fs.mkdirSync(app, { recursive: true });
|
|
175
|
+
// npx's own npm_* settings (a temporary prefix, for one) must not leak into this install.
|
|
176
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^npm_/i.test(key)));
|
|
177
|
+
const result = spawnCommandSync("npm", ["install", "--prefix", app, "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", spec], { stdio: "inherit", env });
|
|
178
|
+
if (result.status !== 0) throw new Error(`npm install ${spec} failed`);
|
|
179
|
+
return path.join(app, "node_modules", "replica-bridge", "src", "bridge", "main.js");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function run(steps ) {
|
|
183
|
+
for (const step of steps) {
|
|
184
|
+
const result = spawnCommandSync(step.command, step.args, { encoding: "utf8" });
|
|
185
|
+
if (result.status !== 0 && !step.optional) {
|
|
186
|
+
throw new Error(`${step.command} ${step.args.join(" ")} failed: ${(result.stderr || result.stdout || result.error?.message || "").toString().trim()}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function readServiceInfo() {
|
|
192
|
+
try { return JSON.parse(fs.readFileSync(infoPath(), "utf8")) ; } catch { return null; }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function spec(program ) {
|
|
196
|
+
return { platform: process.platform, label: serviceLabel(), home: os.homedir(), bridgeHome: bridgeHome(), program, env: serviceEnv(), uid: os.userInfo().uid };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Installs (or replaces) the background service and starts it. `startArgs` are the options `start` keeps. */
|
|
200
|
+
export function installService(startArgs , say ) {
|
|
201
|
+
servicePlan(spec([])); // fails early on a platform without a service manager
|
|
202
|
+
stopRunning();
|
|
203
|
+
const program = [process.execPath, installedScript(say), "start", ...startArgs];
|
|
204
|
+
const plan = servicePlan(spec(program));
|
|
205
|
+
fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
|
|
206
|
+
for (const file of plan.files) {
|
|
207
|
+
fs.mkdirSync(path.dirname(file.path), { recursive: true });
|
|
208
|
+
fs.writeFileSync(file.path, file.content);
|
|
209
|
+
}
|
|
210
|
+
run(plan.install);
|
|
211
|
+
const info = { platform: process.platform, label: serviceLabel(), version: PACKAGE_VERSION, program, installedAt: new Date().toISOString() };
|
|
212
|
+
fs.writeFileSync(infoPath(), JSON.stringify(info, null, 2) + "\n");
|
|
213
|
+
return plan;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function uninstallService() {
|
|
217
|
+
const info = readServiceInfo();
|
|
218
|
+
if (!info) return null;
|
|
219
|
+
const plan = servicePlan({ ...spec(info.program), label: info.label });
|
|
220
|
+
run(plan.uninstall);
|
|
221
|
+
stopRunning();
|
|
222
|
+
for (const file of plan.files) fs.rmSync(file.path, { force: true });
|
|
223
|
+
if (process.platform === "linux") run([{ command: "systemctl", args: ["--user", "daemon-reload"], optional: true }]);
|
|
224
|
+
fs.rmSync(infoPath(), { force: true });
|
|
225
|
+
return plan;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// MARK: The running service's pid, so `status` can tell whether it runs and a second bridge does not start beside it.
|
|
229
|
+
|
|
230
|
+
export function writePid() {
|
|
231
|
+
fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
|
|
232
|
+
fs.writeFileSync(pidPath(), String(process.pid));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function clearPid() {
|
|
236
|
+
try { if (fs.readFileSync(pidPath(), "utf8").trim() === String(process.pid)) fs.rmSync(pidPath()); } catch { /* gone */ }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The background bridge's pid while it runs. */
|
|
240
|
+
export function runningPid() {
|
|
241
|
+
let pid ;
|
|
242
|
+
try { pid = Number(fs.readFileSync(pidPath(), "utf8").trim()); } catch { return null; }
|
|
243
|
+
if (!pid || pid === process.pid) return null;
|
|
244
|
+
try { process.kill(pid, 0); return pid; } catch (error) { return (error ).code === "EPERM" ? pid : null; }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The service manager stops its own process; on Windows the loop's bridge is left, so it goes by pid. */
|
|
248
|
+
function stopRunning() {
|
|
249
|
+
const pid = runningPid();
|
|
250
|
+
if (!pid) return;
|
|
251
|
+
try { process.kill(pid); } catch { /* already gone */ }
|
|
252
|
+
fs.rmSync(pidPath(), { force: true });
|
|
253
|
+
}
|
package/src/bridge/main.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// replica-bridge: runs on the computer that has Codex. See docs/RELAY.md.
|
|
3
|
-
// The first
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// (no command) what `npx replica-bridge` runs. The first time: set this computer up with the relay, show a QR
|
|
4
|
+
// code, and once a phone is paired offer to keep the bridge running in the background (install).
|
|
5
|
+
// Later: show the status, updating the background service when this is a newer version.
|
|
6
|
+
// [--relay <url>] [--name <name>] [--pair] [--install | --no-install] [--codex <path>]
|
|
7
|
+
// pair [--relay <url>] [--confirm] [--yes] [--png <file>] [--timeout <seconds>]
|
|
8
|
+
// install | uninstall | status
|
|
9
|
+
// start [--codex <path>] [--stream-ttl <seconds>] [--no-login-shell] the bridge in this terminal
|
|
7
10
|
// init [--relay <url>] [--public-url <url>] [--name <name>]
|
|
8
11
|
// config [--public-url <url>]
|
|
9
|
-
// pair [--relay <url>] [--confirm] [--yes] [--png <file>] [--timeout <seconds>]
|
|
10
|
-
// start [--codex <path>] [--stream-ttl <seconds>] [--no-login-shell]
|
|
11
12
|
// devices
|
|
12
13
|
// revoke <deviceId>
|
|
13
|
-
import
|
|
14
|
-
import
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import readline from "node:readline/promises";
|
|
16
|
+
import { parseArgs, format } from "node:util";
|
|
15
17
|
import { relayRequest } from "../http.js";
|
|
18
|
+
import { spawnCommandSync } from "./command.js";
|
|
16
19
|
import { defaultRelayURL, enroll, ensureEnrolled } from "./enroll.js";
|
|
20
|
+
import { PACKAGE_VERSION, clearPid, installService, logPath, readServiceInfo, runningPid, servicePlan, uninstallService, writePid } from "./install.js";
|
|
17
21
|
import { pair, publicRelayURL } from "./pair.js";
|
|
18
22
|
import { BridgeService } from "./service.js";
|
|
19
23
|
import { loadState, saveState } from "./state.js";
|
|
@@ -33,62 +37,152 @@ const { values, positionals } = parseArgs({
|
|
|
33
37
|
png: { type: "string" },
|
|
34
38
|
timeout: { type: "string", default: "600" },
|
|
35
39
|
pair: { type: "boolean", default: false },
|
|
40
|
+
install: { type: "boolean", default: false },
|
|
41
|
+
"no-install": { type: "boolean", default: false },
|
|
36
42
|
help: { type: "boolean", short: "h", default: false },
|
|
43
|
+
version: { type: "boolean", short: "v", default: false },
|
|
37
44
|
codex: { type: "string" },
|
|
38
45
|
"stream-ttl": { type: "string", default: "1800" },
|
|
39
46
|
"no-login-shell": { type: "boolean", default: false },
|
|
40
47
|
},
|
|
41
48
|
});
|
|
42
49
|
|
|
43
|
-
const usage = `
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
devices list paired phones
|
|
50
|
-
revoke <id> remove a phone
|
|
50
|
+
const usage = `replica-bridge ${PACKAGE_VERSION}: lets the ChatReplica app use Codex on this computer (docs/RELAY.md)
|
|
51
|
+
npx replica-bridge the only command most people need: sets up, pairs a phone, keeps running
|
|
52
|
+
in the background; run again for the status
|
|
53
|
+
npx replica-bridge pair pair another phone
|
|
54
|
+
npx replica-bridge uninstall stop and remove the background service
|
|
55
|
+
Others: status, install, start (in this terminal), init, config, devices, revoke <id>.
|
|
51
56
|
The relay is ${defaultRelayURL()} unless --relay or REPLICA_RELAY says otherwise.`;
|
|
52
57
|
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
// The Windows service has no terminal to write to: its output goes to the log file it names.
|
|
59
|
+
if (process.env.REPLICA_BRIDGE_LOG) {
|
|
60
|
+
const log = fs.createWriteStream(process.env.REPLICA_BRIDGE_LOG, { flags: "a" });
|
|
61
|
+
console.log = console.warn = console.error = (...args ) => { log.write(format(...args) + "\n"); };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The options `start` keeps when the background service runs it. */
|
|
65
|
+
function startArgs() {
|
|
66
|
+
const args = [];
|
|
67
|
+
if (values.codex) args.push("--codex", values.codex);
|
|
68
|
+
if (values["no-login-shell"]) args.push("--no-login-shell");
|
|
69
|
+
if (values["stream-ttl"] !== "1800") args.push("--stream-ttl", values["stream-ttl"] );
|
|
70
|
+
return args;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function startBridge() {
|
|
74
|
+
// Exit 0: the background services restart the bridge only when it fails, so the newer bridge keeps the computer.
|
|
75
|
+
const service = new BridgeService({ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]), onReplaced: () => process.exit(0) });
|
|
55
76
|
service.start();
|
|
56
|
-
|
|
77
|
+
return service;
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
/** Warns, without stopping, when Codex does not answer the way the bridge will run it (through the login shell). */
|
|
60
81
|
function checkCodex() {
|
|
61
82
|
const codex = values.codex ?? "codex";
|
|
62
|
-
const loginShell = !values["no-login-shell"] && process.env.SHELL;
|
|
83
|
+
const loginShell = process.platform !== "win32" && !values["no-login-shell"] && process.env.SHELL;
|
|
63
84
|
const result = loginShell
|
|
64
|
-
?
|
|
65
|
-
:
|
|
85
|
+
? spawnCommandSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000 })
|
|
86
|
+
: spawnCommandSync(codex, ["--version"], { encoding: "utf8", timeout: 15_000 });
|
|
66
87
|
if (result.status === 0) return;
|
|
67
88
|
console.warn(`Codex did not answer "${codex} --version". Install it (npm install -g @openai/codex) and sign in (codex login); the phone cannot use this computer until then.`);
|
|
68
89
|
}
|
|
69
90
|
|
|
91
|
+
function serviceSupported() {
|
|
92
|
+
try { servicePlan({ platform: process.platform, label: "x", home: "/", bridgeHome: "/", program: [], env: {}, uid: 0 }); return true; } catch { return false; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function ask(question ) {
|
|
96
|
+
const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
97
|
+
try { return !/^n(o)?$/i.test((await prompt.question(question)).trim()); } finally { prompt.close(); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** --install, --no-install, or ask in a terminal; never without one. */
|
|
101
|
+
async function wantBackground() {
|
|
102
|
+
if (values["no-install"] || !serviceSupported()) return false;
|
|
103
|
+
if (values.install) return true;
|
|
104
|
+
if (!process.stdin.isTTY) return false;
|
|
105
|
+
return ask("Keep it running in the background and start it at login, so you can close this terminal? [Y/n] ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function install() {
|
|
109
|
+
const plan = installService(startArgs(), console.log);
|
|
110
|
+
console.log(`Running in the background: ${plan.where}. It starts at login and comes back if it stops, so this terminal can be closed.`);
|
|
111
|
+
console.log(`Log: ${logPath()}. Remove it with "npx replica-bridge uninstall".`);
|
|
112
|
+
if (process.platform === "linux") console.log(`To keep it running while you are logged out: loginctl enable-linger ${process.env.USER ?? "$USER"}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function pairOrExplain() {
|
|
116
|
+
try {
|
|
117
|
+
await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error(`replica-bridge: ${(error ).message}. Pair later with "npx replica-bridge pair".`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function status() {
|
|
124
|
+
let state;
|
|
125
|
+
try { state = loadState(); } catch { console.log(`Not set up yet: run "npx replica-bridge".`); return; }
|
|
126
|
+
const info = readServiceInfo();
|
|
127
|
+
const pid = runningPid();
|
|
128
|
+
console.log(`${state.hostName} (${state.hostId}), relay ${state.relay}`);
|
|
129
|
+
console.log(`Owner: ${state.owner ? `${state.owner.login} (${state.owner.provider})` : "none yet"}; paired phones: ${state.devices.map((d) => d.name).join(", ") || "none"}`);
|
|
130
|
+
if (info) console.log(`Background service ${info.version}: ${pid ? `running (pid ${pid})` : "installed, not running right now"}. Log: ${logPath()}`);
|
|
131
|
+
else console.log(pid ? `Running (pid ${pid}).` : `Not running: "npx replica-bridge" sets up the background service.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
70
134
|
async function main() {
|
|
135
|
+
if (values.version) { console.log(PACKAGE_VERSION); return; }
|
|
71
136
|
if (values.help || command === "help") { console.log(usage); return; }
|
|
72
137
|
switch (command) {
|
|
73
138
|
case undefined:
|
|
74
139
|
case "up": {
|
|
140
|
+
const installed = readServiceInfo();
|
|
141
|
+
if (installed && !values.pair && !values.install) {
|
|
142
|
+
if (installed.version !== PACKAGE_VERSION) {
|
|
143
|
+
console.log(`Updating the background service from ${installed.version} to ${PACKAGE_VERSION}.`);
|
|
144
|
+
install();
|
|
145
|
+
}
|
|
146
|
+
status();
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
75
149
|
checkCodex();
|
|
76
150
|
const state = await ensureEnrolled(values.relay, console.log, values.name);
|
|
77
|
-
|
|
78
|
-
if (
|
|
151
|
+
if (installed && !values.install) { await pairOrExplain(); break; } // the background service serves the phone
|
|
152
|
+
if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()}); stop it first`);
|
|
153
|
+
let bridge = startBridge();
|
|
154
|
+
if (values.pair || !state.devices.length) await pairOrExplain();
|
|
155
|
+
if (loadState().devices.length && await wantBackground()) {
|
|
156
|
+
// One bridge per computer: the relay keeps only the newest connection for it.
|
|
157
|
+
bridge.stop();
|
|
79
158
|
try {
|
|
80
|
-
|
|
159
|
+
install();
|
|
160
|
+
process.exit(0);
|
|
81
161
|
} catch (error) {
|
|
82
|
-
console.error(`replica-bridge: ${(error ).message}
|
|
162
|
+
console.error(`replica-bridge: could not set up the background service: ${(error ).message}`);
|
|
163
|
+
bridge = startBridge();
|
|
83
164
|
}
|
|
84
165
|
}
|
|
85
|
-
|
|
166
|
+
for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { bridge.stop(); process.exit(0); });
|
|
167
|
+
console.log(`Running in this terminal: paired phones can use Codex on ${state.hostName} until it closes (Ctrl-C stops it).`);
|
|
86
168
|
break;
|
|
87
169
|
}
|
|
170
|
+
case "install":
|
|
171
|
+
await ensureEnrolled(values.relay, console.log, values.name);
|
|
172
|
+
install();
|
|
173
|
+
break;
|
|
174
|
+
case "uninstall": {
|
|
175
|
+
const plan = uninstallService();
|
|
176
|
+
console.log(plan ? `Removed the background service (${plan.where}). This computer stays paired; "npx replica-bridge" sets it up again.` : "No background service is installed.");
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "status":
|
|
180
|
+
status();
|
|
181
|
+
break;
|
|
88
182
|
case "init": {
|
|
89
183
|
const relay = values.relay ?? defaultRelayURL();
|
|
90
184
|
const state = await enroll(relay, { name: values.name, publicRelay: values["public-url"] });
|
|
91
|
-
console.log(`Set up ${state.hostName} as ${state.hostId}. Next: "
|
|
185
|
+
console.log(`Set up ${state.hostName} as ${state.hostId}. Next: "npx replica-bridge".`);
|
|
92
186
|
console.log("Nobody owns it yet: the first phone that scans its QR code and is approved here claims it.");
|
|
93
187
|
break;
|
|
94
188
|
}
|
|
@@ -102,11 +196,17 @@ async function main() {
|
|
|
102
196
|
case "pair":
|
|
103
197
|
await ensureEnrolled(values.relay, console.log, values.name);
|
|
104
198
|
await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
|
|
199
|
+
if (!runningPid()) console.log(`Nothing serves this computer right now: run "npx replica-bridge" so the phone can connect.`);
|
|
105
200
|
break;
|
|
106
|
-
case "start":
|
|
201
|
+
case "start": {
|
|
107
202
|
await ensureEnrolled(values.relay, console.log, values.name);
|
|
108
|
-
|
|
203
|
+
const asService = process.env.REPLICA_BRIDGE_SERVICE === "1";
|
|
204
|
+
if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()})${asService ? "" : '; "npx replica-bridge uninstall" stops the background one'}`);
|
|
205
|
+
const bridge = startBridge();
|
|
206
|
+
if (asService) { writePid(); process.on("exit", clearPid); }
|
|
207
|
+
for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { bridge.stop(); process.exit(0); });
|
|
109
208
|
break;
|
|
209
|
+
}
|
|
110
210
|
case "devices": {
|
|
111
211
|
const state = loadState();
|
|
112
212
|
const { owner, devices } = await relayRequest (state.relay, "GET", "/v1/host/devices", { token: state.hostToken });
|
package/src/bridge/service.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// The bridge does not run or configure Codex: when no app-server answers it asks Codex to start its own managed
|
|
4
4
|
// one (`codex app-server daemon start`). A stream outlives the phone's connection for `streamTTL`, buffering what
|
|
5
5
|
// the app-server says, so a phone that drops off the network resumes where it left off.
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
7
6
|
|
|
8
7
|
import crypto from "node:crypto";
|
|
9
8
|
import os from "node:os";
|
|
@@ -15,6 +14,7 @@ import {
|
|
|
15
14
|
} from "../protocol.js";
|
|
16
15
|
|
|
17
16
|
import { relaySocketURL } from "../http.js";
|
|
17
|
+
import { killCommand, spawnCommand } from "./command.js";
|
|
18
18
|
import { hostKeyPair, loadState } from "./state.js";
|
|
19
19
|
|
|
20
20
|
|
|
@@ -28,6 +28,11 @@ import { hostKeyPair, loadState } from "./state.js";
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
31
36
|
|
|
32
37
|
|
|
33
38
|
|
|
@@ -92,10 +97,16 @@ export class BridgeService {
|
|
|
92
97
|
ws.on("unexpected-response", (_req, res) => this.log(`relay refused the host connection: HTTP ${res.statusCode}`));
|
|
93
98
|
ws.on("error", (error) => this.log(`relay connection error: ${error.message}`));
|
|
94
99
|
ws.on("close", (code) => {
|
|
100
|
+
if (ws !== this.relay) return; // a socket this bridge already replaced itself
|
|
95
101
|
for (const conn of this.conns.values()) this.detach(conn);
|
|
96
102
|
this.conns.clear();
|
|
97
103
|
if (this.stopped) return;
|
|
98
|
-
if (code === CloseCode.replaced)
|
|
104
|
+
if (code === CloseCode.replaced) {
|
|
105
|
+
this.log("another bridge for this computer connected to the relay, which keeps only the newest; stopping this one");
|
|
106
|
+
this.stop();
|
|
107
|
+
this.options.onReplaced?.();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
99
110
|
const delay = Math.min(30_000, 1000 * 2 ** this.retry++);
|
|
100
111
|
setTimeout(() => this.connectRelay(), delay);
|
|
101
112
|
});
|
|
@@ -251,19 +262,19 @@ export class BridgeService {
|
|
|
251
262
|
*/
|
|
252
263
|
openProxy() {
|
|
253
264
|
return new Promise((resolve, reject) => {
|
|
254
|
-
const child =
|
|
265
|
+
const child = spawnCommand(this.options.codexPath ?? "codex", ["app-server", "proxy"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
255
266
|
let stderr = "";
|
|
256
267
|
child.stderr .on("data", (chunk) => { stderr = (stderr + chunk).slice(-2000); });
|
|
257
268
|
const pipe = Duplex.from({ readable: child.stdout , writable: child.stdin }) ;
|
|
258
269
|
// The HTTP client that carries the WebSocket handshake tunes its socket; a pipe has nothing to tune.
|
|
259
270
|
for (const name of ["setNoDelay", "setKeepAlive", "setTimeout", "ref", "unref"]) pipe[name] = () => pipe;
|
|
260
271
|
const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: MAX_FRAME_BYTES });
|
|
261
|
-
const fail = (why ) => { ws.removeAllListeners("open"); child
|
|
272
|
+
const fail = (why ) => { ws.removeAllListeners("open"); killCommand(child); reject(new Error(why)); };
|
|
262
273
|
child.once("error", (error) => fail(`could not run codex: ${error.message}`));
|
|
263
274
|
child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
|
|
264
275
|
ws.once("error", (error) => { if (ws.readyState !== WebSocket.OPEN) fail(error.message); });
|
|
265
276
|
ws.once("open", () => {
|
|
266
|
-
ws.on("close", () => child
|
|
277
|
+
ws.on("close", () => killCommand(child));
|
|
267
278
|
resolve(ws);
|
|
268
279
|
});
|
|
269
280
|
});
|
|
@@ -272,8 +283,8 @@ export class BridgeService {
|
|
|
272
283
|
/** Runs a command to completion; true when it exits 0 within 30 s. Output is dropped (an rc file may print). */
|
|
273
284
|
run(command , args ) {
|
|
274
285
|
return new Promise((resolve, reject) => {
|
|
275
|
-
const child =
|
|
276
|
-
const timer = setTimeout(() => { child
|
|
286
|
+
const child = spawnCommand(command, args, { stdio: "ignore" });
|
|
287
|
+
const timer = setTimeout(() => { killCommand(child); resolve(false); }, 30_000);
|
|
277
288
|
child.once("error", (error) => { clearTimeout(timer); reject(error); });
|
|
278
289
|
child.once("exit", (code) => { clearTimeout(timer); resolve(code === 0); });
|
|
279
290
|
});
|
|
@@ -289,8 +300,9 @@ export class BridgeService {
|
|
|
289
300
|
// people configure Codex (config.toml, auth.json, cc-switch, keys exported in their shell rc), the daemon should
|
|
290
301
|
// see what their terminal sees, so ask through their login shell; fall back to a direct start.
|
|
291
302
|
const codex = this.options.codexPath ?? "codex";
|
|
292
|
-
// launchd services may have no SHELL; the account's login shell is the same answer.
|
|
293
|
-
|
|
303
|
+
// launchd services may have no SHELL; the account's login shell is the same answer. Windows has no login shell
|
|
304
|
+
// (a SHELL from Git Bash is a POSIX path Node cannot run), so Codex is asked directly there.
|
|
305
|
+
const shell = process.platform === "win32" ? null : process.env.SHELL || os.userInfo().shell;
|
|
294
306
|
const started = this.options.loginShell !== false && shell
|
|
295
307
|
? await this.run(shell, ["-lic", 'exec "$0" app-server daemon start', codex]).catch(() => false)
|
|
296
308
|
: false;
|