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 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
- and keeps running: paired phones can use Codex here while it does. `npx replica-bridge --help` lists the other commands.
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.1.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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
+ }
@@ -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 pair or start sets the computer up by itself (anonymous enrollment); init does it explicitly.
4
- // (no command) [--relay <url>] [--name <name>] [--pair] [--codex <path>]
5
- // what `npx replica-bridge` runs: set up if needed, keep the bridge running, and show a QR code while no
6
- // phone is paired yet (or with --pair)
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 { spawnSync } from "node:child_process";
14
- import { parseArgs } from "node:util";
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 = `usage: replica-bridge [command] (docs/RELAY.md)
44
- (none) set up, keep running, and show a QR code until a phone is paired (--pair shows one anyway)
45
- pair show a QR code for another phone
46
- start keep running without showing a QR code
47
- init set up only (--relay <url>, --name <name>)
48
- config show the relay, this computer and its owner
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
- function startService() {
54
- const service = new BridgeService({ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]) });
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
- for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { service.stop(); process.exit(0); });
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
- ? spawnSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000 })
65
- : spawnSync(codex, ["--version"], { encoding: "utf8", timeout: 15_000 });
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
- startService();
78
- if (values.pair || !state.devices.length) {
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
- await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
159
+ install();
160
+ process.exit(0);
81
161
  } catch (error) {
82
- console.error(`replica-bridge: ${(error ).message}. Pair later with "replica-bridge pair".`);
162
+ console.error(`replica-bridge: could not set up the background service: ${(error ).message}`);
163
+ bridge = startBridge();
83
164
  }
84
165
  }
85
- console.log(`Running: paired phones can use Codex on ${state.hostName} while this keeps running (Ctrl-C stops it).`);
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: "replica-bridge start" (keep it running), then "replica-bridge pair".`);
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
- startService();
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 });
@@ -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) this.log("another bridge with the same identity connected; reconnecting anyway");
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 = spawn(this.options.codexPath ?? "codex", ["app-server", "proxy"], { stdio: ["pipe", "pipe", "pipe"] });
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.kill(); reject(new Error(why)); };
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.kill());
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 = spawn(command, args, { stdio: "ignore" });
276
- const timer = setTimeout(() => { child.kill(); resolve(false); }, 30_000);
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
- const shell = process.env.SHELL || os.userInfo().shell;
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;