replica-bridge 0.0.0-stage → 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
@@ -1,3 +1,14 @@
1
- # Temporary Holding Version
1
+ # replica-bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Lets the ChatReplica iPhone app use Codex on this computer. Needs Codex installed and signed in (`codex login`).
4
+
5
+ npx replica-bridge
6
+
7
+ The first run sets this computer up with the relay, shows a QR code to scan in the app (远程 › 添加连接 › 配对新设备),
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,6 +1,17 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
5
+ "type": "module",
6
+ "bin": {
7
+ "replica-bridge": "src/bridge/main.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=20.11"
11
+ },
12
+ "dependencies": {
13
+ "qrcode": "1.5.4",
14
+ "ws": "8.18.3"
15
+ },
16
+ "license": "UNLICENSED"
17
+ }
@@ -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,39 @@
1
+ // First run: make this computer's key pair and enroll it, anonymously, with a relay.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import { b64url, generateKeyPair } from "../protocol.js";
5
+ import { relayRequest } from "../http.js";
6
+ import { defaultEnvPath, loadRelayConfig } from "../relay/config.js";
7
+ import { loadState, saveState } from "./state.js";
8
+
9
+
10
+ /** The project's public relay, which the app also signs in to by default (Sources/Core/RelayPairing.swift). */
11
+ export const PUBLIC_RELAY = "https://agens.office-cli.onl";
12
+
13
+ /** With no --relay: REPLICA_RELAY, else the relay on this machine over loopback, else the public relay. */
14
+ export function defaultRelayURL() {
15
+ if (process.env.REPLICA_RELAY) return process.env.REPLICA_RELAY;
16
+ return fs.existsSync(defaultEnvPath()) ? `http://127.0.0.1:${loadRelayConfig().port}` : PUBLIC_RELAY;
17
+ }
18
+
19
+ export async function enroll(relay , options = {}) {
20
+ const keys = generateKeyPair();
21
+ const hostName = options.name ?? os.hostname();
22
+ const { hostId, hostToken } = await relayRequest (relay, "POST", "/v1/hosts/enroll", { body: { publicKey: b64url(keys.publicKey), name: hostName } });
23
+ const state = { relay, publicRelay: options.publicRelay, hostId, hostToken, hostName, hostPrivateKey: b64url(keys.privateKey), hostPublicKey: b64url(keys.publicKey), devices: [] };
24
+ saveState(state);
25
+ return state;
26
+ }
27
+
28
+ /** The saved state, enrolling first if this computer never has. */
29
+ export async function ensureEnrolled(relay , say , name ) {
30
+ try {
31
+ return loadState();
32
+ } catch (error) {
33
+ if (!/not set up yet/.test((error ).message)) throw error;
34
+ }
35
+ const url = relay ?? defaultRelayURL();
36
+ const state = await enroll(url, { name });
37
+ say(`Set up ${state.hostName} with ${url} (${state.hostId}).`);
38
+ return state;
39
+ }
@@ -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
+ }
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ // replica-bridge: runs on the computer that has Codex. See docs/RELAY.md.
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
10
+ // init [--relay <url>] [--public-url <url>] [--name <name>]
11
+ // config [--public-url <url>]
12
+ // devices
13
+ // revoke <deviceId>
14
+ import fs from "node:fs";
15
+ import readline from "node:readline/promises";
16
+ import { parseArgs, format } from "node:util";
17
+ import { relayRequest } from "../http.js";
18
+ import { spawnCommandSync } from "./command.js";
19
+ import { defaultRelayURL, enroll, ensureEnrolled } from "./enroll.js";
20
+ import { PACKAGE_VERSION, clearPid, installService, logPath, readServiceInfo, runningPid, servicePlan, uninstallService, writePid } from "./install.js";
21
+ import { pair, publicRelayURL } from "./pair.js";
22
+ import { BridgeService } from "./service.js";
23
+ import { loadState, saveState } from "./state.js";
24
+
25
+ // A leading option means no command: `replica-bridge --pair` is the default command with --pair.
26
+ const argv = process.argv.slice(2);
27
+ const [command, ...rest] = argv[0]?.startsWith("-") ? [undefined, ...argv] : argv;
28
+ const { values, positionals } = parseArgs({
29
+ args: rest,
30
+ allowPositionals: true,
31
+ options: {
32
+ relay: { type: "string" },
33
+ "public-url": { type: "string" },
34
+ name: { type: "string" },
35
+ confirm: { type: "boolean", default: false },
36
+ yes: { type: "boolean", default: false },
37
+ png: { type: "string" },
38
+ timeout: { type: "string", default: "600" },
39
+ pair: { type: "boolean", default: false },
40
+ install: { type: "boolean", default: false },
41
+ "no-install": { type: "boolean", default: false },
42
+ help: { type: "boolean", short: "h", default: false },
43
+ version: { type: "boolean", short: "v", default: false },
44
+ codex: { type: "string" },
45
+ "stream-ttl": { type: "string", default: "1800" },
46
+ "no-login-shell": { type: "boolean", default: false },
47
+ },
48
+ });
49
+
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>.
56
+ The relay is ${defaultRelayURL()} unless --relay or REPLICA_RELAY says otherwise.`;
57
+
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) });
76
+ service.start();
77
+ return service;
78
+ }
79
+
80
+ /** Warns, without stopping, when Codex does not answer the way the bridge will run it (through the login shell). */
81
+ function checkCodex() {
82
+ const codex = values.codex ?? "codex";
83
+ const loginShell = process.platform !== "win32" && !values["no-login-shell"] && process.env.SHELL;
84
+ const result = loginShell
85
+ ? spawnCommandSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000 })
86
+ : spawnCommandSync(codex, ["--version"], { encoding: "utf8", timeout: 15_000 });
87
+ if (result.status === 0) return;
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.`);
89
+ }
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
+
134
+ async function main() {
135
+ if (values.version) { console.log(PACKAGE_VERSION); return; }
136
+ if (values.help || command === "help") { console.log(usage); return; }
137
+ switch (command) {
138
+ case undefined:
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
+ }
149
+ checkCodex();
150
+ const state = await ensureEnrolled(values.relay, console.log, values.name);
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();
158
+ try {
159
+ install();
160
+ process.exit(0);
161
+ } catch (error) {
162
+ console.error(`replica-bridge: could not set up the background service: ${(error ).message}`);
163
+ bridge = startBridge();
164
+ }
165
+ }
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).`);
168
+ break;
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;
182
+ case "init": {
183
+ const relay = values.relay ?? defaultRelayURL();
184
+ const state = await enroll(relay, { name: values.name, publicRelay: values["public-url"] });
185
+ console.log(`Set up ${state.hostName} as ${state.hostId}. Next: "npx replica-bridge".`);
186
+ console.log("Nobody owns it yet: the first phone that scans its QR code and is approved here claims it.");
187
+ break;
188
+ }
189
+ case "config": {
190
+ const state = loadState();
191
+ if (values["public-url"] !== undefined) state.publicRelay = values["public-url"] || undefined;
192
+ saveState(state);
193
+ console.log(`relay ${state.relay}\npublic URL in QR codes ${publicRelayURL(state)}${state.publicRelay ? "" : " (from the relay's env file)"}\nhost ${state.hostName} (${state.hostId})\nowner ${state.owner ? `${state.owner.login} (${state.owner.provider})` : "none yet: pair a phone to claim it"}`);
194
+ break;
195
+ }
196
+ case "pair":
197
+ await ensureEnrolled(values.relay, console.log, values.name);
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.`);
200
+ break;
201
+ case "start": {
202
+ await ensureEnrolled(values.relay, console.log, values.name);
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); });
208
+ break;
209
+ }
210
+ case "devices": {
211
+ const state = loadState();
212
+ const { owner, devices } = await relayRequest (state.relay, "GET", "/v1/host/devices", { token: state.hostToken });
213
+ console.log(owner ? `Owner: ${owner.login} (${owner.provider})` : "No owner yet.");
214
+ if (!devices.length) console.log("No paired phones.");
215
+ for (const d of devices) console.log(`${d.deviceId} ${d.name} (${d.login}) paired ${new Date(d.createdAt).toLocaleString()} ${d.revokedAt ? "removed" : d.online ? "online" : "offline"}`);
216
+ break;
217
+ }
218
+ case "revoke": {
219
+ const deviceId = positionals[0];
220
+ if (!deviceId) throw new Error("usage: replica-bridge revoke <deviceId>");
221
+ const state = loadState();
222
+ await relayRequest(state.relay, "DELETE", `/v1/host/devices/${encodeURIComponent(deviceId)}`, { token: state.hostToken });
223
+ state.devices = state.devices.filter((d) => d.deviceId !== deviceId);
224
+ saveState(state);
225
+ console.log(`Removed ${deviceId} from this computer.`);
226
+ break;
227
+ }
228
+ default:
229
+ console.log(usage);
230
+ process.exitCode = 1;
231
+ }
232
+ }
233
+
234
+ main().catch((error) => {
235
+ console.error(`replica-bridge: ${(error ).message}`);
236
+ process.exit(1);
237
+ });