use-pounce 0.1.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 ADDED
@@ -0,0 +1,38 @@
1
+ # use-pounce
2
+
3
+ Pair your phone with this machine in one command:
4
+
5
+ ```sh
6
+ npx use-pounce
7
+ ```
8
+
9
+ That starts the [Pounce](https://use-pounce.com) bridge in the background, prints a QR code in your terminal, and waits. Scan it with the Pounce app (or your camera) and your phone is connected to this machine's coding agents — Claude Code, Codex, Cursor, and friends.
10
+
11
+ **Works over SSH.** Pairing doesn't need the phone and the machine to share a network: the QR carries an [iroh](https://github.com/n0-computer/iroh) p2p tunnel identity, so you can `ssh` into a server, run `npx use-pounce`, scan, and drive that server's agents from anywhere. No port-forwarding, no VPN.
12
+
13
+ The bridge keeps running after you close the terminal.
14
+
15
+ ## Commands
16
+
17
+ ```
18
+ pounce start the bridge (background) + show the pairing QR + wait
19
+ pounce qr same, but don't wait for the phone
20
+ pounce status bridge / tunnel / phone status
21
+ pounce stop stop the background bridge and its tunnel
22
+ pounce logs [-f] show (or follow) the bridge log
23
+
24
+ --port <n> bridge port (default 8099)
25
+ --token <t> pairing token (default: random, kept in ~/.pounce)
26
+ --lan skip the tunnel — QR pairs on this Wi-Fi only
27
+ --foreground run the bridge attached to this terminal
28
+ ```
29
+
30
+ Installed globally (`npm i -g use-pounce`), the command is just `pounce`.
31
+
32
+ ## What it does
33
+
34
+ - Bridge: an HTTP server on port 8099 that reads your coding-agent sessions from disk and drives the agent CLIs. State lives in `~/.pounce/`.
35
+ - Tunnel: a `pounce-tunnel` binary (downloaded to `~/.pounce/bin/` on first run) that accepts iroh QUIC streams from the phone and proxies them to the bridge. Off-LAN is optional — without it, pairing works on the same Wi-Fi.
36
+ - Auth: requests need the pairing token from the QR. The token is minted randomly per machine.
37
+
38
+ Part of the [Pounce monorepo](https://github.com/pounce-ai/pounce).
package/bin/pounce.mjs ADDED
@@ -0,0 +1,487 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx use-pounce` — pair your phone with this machine in one command.
4
+ *
5
+ * Starts the Pounce bridge in the background (or reuses a running one), makes
6
+ * sure the pounce-tunnel binary is installed so pairing works from ANY network
7
+ * (the phone dials an iroh p2p tunnel by node id — no port-forwarding, works
8
+ * on a machine you're SSH'd into), prints the pairing QR in the terminal, and
9
+ * waits for the phone to connect. The bridge keeps running after exit.
10
+ *
11
+ * pounce start + QR + wait for the phone
12
+ * pounce qr start + QR, don't wait
13
+ * pounce status bridge/tunnel/phone status
14
+ * pounce stop stop the background bridge (and its tunnel)
15
+ * pounce logs [-f] show the bridge log
16
+ *
17
+ * --port <n> bridge port (default 8099)
18
+ * --token <t> pairing token (default: random, persisted in ~/.pounce)
19
+ * --lan skip the tunnel — QR pairs on this Wi-Fi only
20
+ * --foreground run the bridge attached to this terminal instead
21
+ */
22
+ import { spawn, spawnSync } from "node:child_process";
23
+ import crypto from "node:crypto";
24
+ import {
25
+ chmodSync,
26
+ copyFileSync,
27
+ existsSync,
28
+ mkdirSync,
29
+ mkdtempSync,
30
+ openSync,
31
+ readdirSync,
32
+ readFileSync,
33
+ rmSync,
34
+ writeFileSync,
35
+ } from "node:fs";
36
+ import os from "node:os";
37
+ import path from "node:path";
38
+ import { fileURLToPath } from "node:url";
39
+ import qrcode from "qrcode-terminal";
40
+
41
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
42
+ const PKG = JSON.parse(readFileSync(path.join(HERE, "..", "package.json"), "utf8"));
43
+ const IS_WIN = process.platform === "win32";
44
+
45
+ const POUNCE_DIR = path.join(os.homedir(), ".pounce");
46
+ const BIN_DIR = path.join(POUNCE_DIR, "bin");
47
+ const LOG_FILE = path.join(POUNCE_DIR, "bridge.log");
48
+ // { pid, port, startedAt } — per port, so a --port test bridge never clobbers
49
+ // the default one's stop/status bookkeeping.
50
+ const metaFile = (port) => path.join(POUNCE_DIR, `cli-bridge-${port}.json`);
51
+ const TOKEN_FILE = path.join(POUNCE_DIR, "cli-token");
52
+ const TUNNEL_BIN =
53
+ process.env.POUNCE_TUNNEL_BIN ||
54
+ path.join(BIN_DIR, IS_WIN ? "pounce-tunnel.exe" : "pounce-tunnel");
55
+
56
+ // Where tunnel binaries are published: GitHub release tagged tunnel-v*, one
57
+ // pounce-tunnel-<rust-triple>.tar.gz per platform. POUNCE_TUNNEL_URL overrides
58
+ // with a direct asset URL.
59
+ const RELEASES_API = "https://api.github.com/repos/pounce-ai/pounce/releases?per_page=30";
60
+
61
+ // The bundled bridge (published package); falls back to the unbundled source
62
+ // when running from the monorepo before a build.
63
+ const LAUNCHER = [
64
+ path.join(HERE, "..", "dist", "launcher.mjs"),
65
+ path.join(HERE, "..", "src", "launcher-entry.mjs"),
66
+ ].find(existsSync);
67
+
68
+ // --- tiny terminal helpers ----------------------------------------------------
69
+ const tty = process.stdout.isTTY && !process.env.NO_COLOR;
70
+ const c = (code, s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
71
+ const bold = (s) => c(1, s);
72
+ const dim = (s) => c(2, s);
73
+ const green = (s) => c(32, s);
74
+ const yellow = (s) => c(33, s);
75
+ const red = (s) => c(31, s);
76
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
77
+
78
+ async function getJson(url, { timeoutMs = 3000, token = null } = {}) {
79
+ const headers = { "user-agent": `use-pounce/${PKG.version}` };
80
+ if (token) headers.authorization = `Bearer ${token}`;
81
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers });
82
+ if (!res.ok) {
83
+ const e = new Error(`${url} -> ${res.status}`);
84
+ e.status = res.status;
85
+ throw e;
86
+ }
87
+ return res.json();
88
+ }
89
+
90
+ // --- bridge lifecycle ---------------------------------------------------------
91
+ async function bridgeAlive(port) {
92
+ try {
93
+ return (await getJson(`http://127.0.0.1:${port}/health`, { timeoutMs: 900 })).ok === true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /** Loopback-only status surface; exposes the running bridge's actual token. */
100
+ function uiInfo(port) {
101
+ return getJson(`http://127.0.0.1:${port}/ui`, { timeoutMs: 3000 });
102
+ }
103
+
104
+ /** The token a bridge WE start will use: --token, else BRIDGE_TOKEN, else a
105
+ * random one minted once per machine. A well-known default would let anyone
106
+ * who learns the tunnel node id connect — bad for the SSH/remote use case. */
107
+ function effectiveToken(opts) {
108
+ if (opts.token) return opts.token;
109
+ if (process.env.BRIDGE_TOKEN) return process.env.BRIDGE_TOKEN;
110
+ try {
111
+ const t = readFileSync(TOKEN_FILE, "utf8").trim();
112
+ if (t) return t;
113
+ } catch {}
114
+ const t = crypto.randomBytes(12).toString("base64url");
115
+ mkdirSync(POUNCE_DIR, { recursive: true });
116
+ writeFileSync(TOKEN_FILE, `${t}\n`, { mode: 0o600 });
117
+ return t;
118
+ }
119
+
120
+ function daemonMeta(port) {
121
+ try {
122
+ return JSON.parse(readFileSync(metaFile(port), "utf8"));
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ function pidAlive(pid) {
129
+ try {
130
+ process.kill(pid, 0);
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ async function startDaemon({ port, token, foreground }) {
138
+ if (!LAUNCHER)
139
+ throw new Error(
140
+ "bridge launcher missing — run `bun run build` in apps/cli (or reinstall the package)",
141
+ );
142
+ mkdirSync(POUNCE_DIR, { recursive: true });
143
+ const env = {
144
+ ...process.env,
145
+ BRIDGE_PORT: String(port),
146
+ BRIDGE_TOKEN: token,
147
+ POUNCE_CLI_VERSION: PKG.version,
148
+ };
149
+ if (foreground) {
150
+ const child = spawn(process.execPath, [LAUNCHER], { env, stdio: "inherit" });
151
+ child.on("exit", (code) => process.exit(code ?? 0));
152
+ return null; // never returns to the pairing flow — the bridge owns the terminal
153
+ }
154
+ const log = openSync(LOG_FILE, "a");
155
+ const child = spawn(process.execPath, [LAUNCHER], {
156
+ env,
157
+ detached: true,
158
+ stdio: ["ignore", log, log],
159
+ windowsHide: true,
160
+ });
161
+ child.unref();
162
+ writeFileSync(
163
+ metaFile(port),
164
+ JSON.stringify({ pid: child.pid, port, startedAt: new Date().toISOString() }, null, 2),
165
+ );
166
+ const deadline = Date.now() + 20_000;
167
+ while (Date.now() < deadline) {
168
+ if (await bridgeAlive(port)) return child.pid;
169
+ if (child.exitCode != null) break;
170
+ await sleep(250);
171
+ }
172
+ throw new Error(`bridge didn't come up on port ${port} — see ${LOG_FILE}`);
173
+ }
174
+
175
+ // --- tunnel binary ------------------------------------------------------------
176
+ function rustTriple() {
177
+ const arch = { arm64: "aarch64", x64: "x86_64" }[process.arch];
178
+ const osPart = { darwin: "apple-darwin", linux: "unknown-linux-gnu", win32: "pc-windows-msvc" }[
179
+ process.platform
180
+ ];
181
+ return arch && osPart ? `${arch}-${osPart}` : null;
182
+ }
183
+
184
+ /** Make sure ~/.pounce/bin/pounce-tunnel exists, downloading a platform build
185
+ * from the GitHub release when missing. Returns "present" | "downloaded" |
186
+ * a `{ skipped: reason }` — pairing continues LAN-only on skip. */
187
+ async function ensureTunnelBinary(opts) {
188
+ if (existsSync(TUNNEL_BIN)) return "present";
189
+ if (opts.lan) return { skipped: "--lan" };
190
+ const triple = rustTriple();
191
+ if (!triple) return { skipped: `no tunnel build for ${process.platform}/${process.arch}` };
192
+ try {
193
+ let assetUrl = process.env.POUNCE_TUNNEL_URL || null;
194
+ if (!assetUrl) {
195
+ const releases = await getJson(RELEASES_API, { timeoutMs: 10_000 });
196
+ const wanted = `pounce-tunnel-${triple}${IS_WIN ? ".zip" : ".tar.gz"}`;
197
+ for (const r of releases) {
198
+ if (!r.tag_name?.startsWith("tunnel-v")) continue;
199
+ const asset = (r.assets || []).find((a) => a.name === wanted);
200
+ if (asset) {
201
+ assetUrl = asset.browser_download_url;
202
+ break;
203
+ }
204
+ }
205
+ if (!assetUrl) return { skipped: `no ${wanted} on any tunnel-v* release yet` };
206
+ }
207
+ process.stdout.write(dim(` downloading pounce-tunnel (${triple})…\n`));
208
+ const res = await fetch(assetUrl, { signal: AbortSignal.timeout(120_000) });
209
+ if (!res.ok) throw new Error(`download -> ${res.status}`);
210
+ const buf = Buffer.from(await res.arrayBuffer());
211
+ const tmp = mkdtempSync(path.join(os.tmpdir(), "pounce-tunnel-"));
212
+ try {
213
+ const archive = path.join(tmp, IS_WIN ? "t.zip" : "t.tar.gz");
214
+ writeFileSync(archive, buf);
215
+ const r = IS_WIN
216
+ ? spawnSync("tar", ["-xf", archive, "-C", tmp]) // bsdtar ships with Win10+
217
+ : spawnSync("tar", ["xzf", archive, "-C", tmp]);
218
+ if (r.status !== 0) throw new Error("extract failed");
219
+ const name = IS_WIN ? "pounce-tunnel.exe" : "pounce-tunnel";
220
+ const found = readdirSync(tmp, { recursive: true }).find(
221
+ (f) => path.basename(String(f)) === name,
222
+ );
223
+ if (!found) throw new Error(`${name} not in archive`);
224
+ mkdirSync(BIN_DIR, { recursive: true });
225
+ const src = path.join(tmp, String(found));
226
+ // copy, not rename — os.tmpdir() can be a different filesystem (EXDEV)
227
+ copyFileSync(src, TUNNEL_BIN);
228
+ chmodSync(TUNNEL_BIN, 0o755);
229
+ } finally {
230
+ rmSync(tmp, { recursive: true, force: true });
231
+ }
232
+ return "downloaded";
233
+ } catch (e) {
234
+ return { skipped: `download failed (${e?.message || e})` };
235
+ }
236
+ }
237
+
238
+ /** Poll the bridge until its tunnel is up and has an identity. Also (re)spawns
239
+ * the tunnel on bridges that started before the binary was installed.
240
+ * Resolves { tunnel, why } — `why` explains a null tunnel when known. */
241
+ async function waitForTunnel(port, token, { timeoutMs = 25_000 } = {}) {
242
+ const deadline = Date.now() + timeoutMs;
243
+ let legacy = false;
244
+ while (Date.now() < deadline) {
245
+ try {
246
+ if (!legacy) {
247
+ const r = await getJson(`http://127.0.0.1:${port}/v1/tunnel/ensure`, { token });
248
+ if (r.eligible === false)
249
+ return { tunnel: null, why: "the machine's tunnel serves the default port (8099) only" };
250
+ if (r.running && r.tunnel?.nodeId) return { tunnel: r.tunnel, why: null };
251
+ if (!r.binary) return { tunnel: null, why: "bridge found no tunnel binary" };
252
+ } else {
253
+ // Bridge predates /v1/tunnel/ensure — /v1/pair still serves a stored
254
+ // identity (can't confirm the tunnel process, so trust the file). Only
255
+ // on the default port: the identity always targets the default bridge.
256
+ if (port !== 8099)
257
+ return { tunnel: null, why: "the machine's tunnel serves the default port (8099) only" };
258
+ const r = await getJson(`http://127.0.0.1:${port}/v1/pair`, { token });
259
+ if (r.pairing?.nodeId)
260
+ return { tunnel: { nodeId: r.pairing.nodeId, relay: r.pairing.relay }, why: null };
261
+ }
262
+ } catch (e) {
263
+ if (e?.status === 404) {
264
+ legacy = true;
265
+ continue;
266
+ }
267
+ if (e?.status === 401)
268
+ throw new Error(
269
+ "running bridge rejected our token — is another Pounce bridge configured differently?",
270
+ );
271
+ }
272
+ await sleep(600);
273
+ }
274
+ return {
275
+ tunnel: null,
276
+ why: `didn't come up in ${Math.round(timeoutMs / 1000)}s — see ${LOG_FILE}`,
277
+ };
278
+ }
279
+
280
+ // --- pairing UI ---------------------------------------------------------------
281
+ function deepLink({ pairUrl, token, tunnel }) {
282
+ let link = `pounce://connect?url=${encodeURIComponent(pairUrl)}&token=${encodeURIComponent(token)}`;
283
+ if (tunnel?.nodeId) {
284
+ link += `&node=${encodeURIComponent(tunnel.nodeId)}&host=${encodeURIComponent(os.hostname().replace(/\.local$/, ""))}`;
285
+ if (tunnel.relay) link += `&relay=${encodeURIComponent(tunnel.relay)}`;
286
+ }
287
+ return link;
288
+ }
289
+
290
+ async function waitForPhone(port) {
291
+ for (;;) {
292
+ try {
293
+ const ui = await uiInfo(port);
294
+ // "Fresh" activity only — a phone that synced before we launched
295
+ // shouldn't count as this scan succeeding.
296
+ if (ui.lastSeenMsAgo != null && ui.lastSeenMsAgo < 5000) return;
297
+ } catch {}
298
+ await sleep(1500);
299
+ }
300
+ }
301
+
302
+ // --- commands -----------------------------------------------------------------
303
+ async function cmdUp(opts, { wait }) {
304
+ console.log(`\n${bold("🐾 Pounce")} ${dim(`v${PKG.version}`)} — pair your phone\n`);
305
+
306
+ // Tunnel binary first: a bridge spawned after it exists picks it up itself.
307
+ const tunnelState = await ensureTunnelBinary(opts);
308
+
309
+ let reused = false;
310
+ let pid = null;
311
+ if (await bridgeAlive(opts.port)) {
312
+ reused = true;
313
+ } else {
314
+ pid = await startDaemon({
315
+ port: opts.port,
316
+ token: effectiveToken(opts),
317
+ foreground: opts.foreground,
318
+ });
319
+ if (opts.foreground) return; // bridge owns the terminal now
320
+ }
321
+
322
+ // Trust the RUNNING bridge's own state (its token may predate this run —
323
+ // e.g. the desktop app's bridge or an earlier launchd install).
324
+ const ui = await uiInfo(opts.port);
325
+ const token = ui.token;
326
+ const pairUrl = ui.pairUrl || `http://127.0.0.1:${opts.port}`;
327
+ console.log(
328
+ ` ${dim("bridge")} ${pairUrl} ${dim(reused ? "(already running)" : `(started · pid ${pid} · logs: pounce logs)`)}`,
329
+ );
330
+
331
+ let tunnel = null;
332
+ if (tunnelState.skipped) {
333
+ console.log(
334
+ ` ${dim("tunnel")} ${yellow("off")} — ${tunnelState.skipped}; QR pairs on this Wi-Fi only`,
335
+ );
336
+ } else {
337
+ const r = await waitForTunnel(opts.port, token);
338
+ tunnel = r.tunnel;
339
+ console.log(
340
+ tunnel
341
+ ? ` ${dim("tunnel")} ${green("ready")} — pairs from any network ${dim(`(iroh ${tunnel.nodeId.slice(0, 8)}…)`)}`
342
+ : ` ${dim("tunnel")} ${yellow("off")} — ${r.why}; QR pairs on this Wi-Fi only`,
343
+ );
344
+ }
345
+
346
+ const link = deepLink({ pairUrl, token, tunnel });
347
+ console.log(
348
+ `\n ${bold("Scan with the Pounce app")} ${dim("(Settings → Scan QR)")} ${bold("or your camera:")}\n`,
349
+ );
350
+ qrcode.generate(link, { small: true });
351
+ console.log(`\n ${dim("or open on the phone:")}\n ${dim(link)}\n`);
352
+
353
+ if (!wait) return;
354
+ console.log(
355
+ ` Waiting for your phone… ${dim("(Ctrl-C is safe — the bridge keeps running; `pounce stop` stops it)")}`,
356
+ );
357
+ await waitForPhone(opts.port);
358
+ console.log(`\n ${green("✓ Phone connected")} — you're all set.\n`);
359
+ process.exit(0); // detached daemon keeps the event loop alive otherwise
360
+ }
361
+
362
+ async function cmdStatus(opts) {
363
+ const alive = await bridgeAlive(opts.port);
364
+ if (!alive) {
365
+ console.log(
366
+ `bridge: ${red("not running")} on port ${opts.port} ${dim(`(run \`pounce\` to start it)`)}`,
367
+ );
368
+ return;
369
+ }
370
+ const ui = await uiInfo(opts.port);
371
+ const meta = daemonMeta(opts.port);
372
+ const mine = meta && meta.port === opts.port && pidAlive(meta.pid);
373
+ console.log(
374
+ `bridge: ${green("running")} on ${ui.pairUrl}${mine ? dim(` (pid ${meta.pid})`) : dim(" (not started by this CLI)")}`,
375
+ );
376
+ console.log(
377
+ `tunnel: ${ui.tunnel?.nodeId ? green(`ready (${ui.tunnel.nodeId.slice(0, 12)}…)`) : yellow("off — LAN only")}`,
378
+ );
379
+ console.log(
380
+ `phone: ${ui.connected ? green(`connected (${ui.devices || 1} device${(ui.devices || 1) === 1 ? "" : "s"})`) : dim("not connected")}`,
381
+ );
382
+ }
383
+
384
+ async function cmdStop(opts) {
385
+ const meta = daemonMeta(opts.port);
386
+ const alive = await bridgeAlive(opts.port);
387
+ if (meta && pidAlive(meta.pid)) {
388
+ process.kill(meta.pid, "SIGTERM"); // its handler reaps the tunnel too
389
+ const deadline = Date.now() + 5000;
390
+ while (pidAlive(meta.pid) && Date.now() < deadline) await sleep(150);
391
+ rmSync(metaFile(opts.port), { force: true });
392
+ console.log(pidAlive(meta.pid) ? red(`pid ${meta.pid} didn't exit`) : green("bridge stopped"));
393
+ } else if (alive) {
394
+ console.log(
395
+ yellow(
396
+ "a bridge is running, but it wasn't started by this CLI (desktop app or launchd?) — not touching it",
397
+ ),
398
+ );
399
+ return;
400
+ } else {
401
+ console.log(dim("bridge isn't running"));
402
+ }
403
+ // Orphaned tunnels squat the iroh identity — sweep them. Only when stopping
404
+ // the default-port bridge: that's the one the tunnel singleton belongs to.
405
+ if (!IS_WIN && opts.port === 8099)
406
+ spawnSync("pkill", ["-f", "pounce-tunnel serve"], { stdio: "ignore" });
407
+ }
408
+
409
+ function cmdLogs(opts) {
410
+ if (!existsSync(LOG_FILE)) {
411
+ console.log(dim(`no log yet at ${LOG_FILE}`));
412
+ return;
413
+ }
414
+ if (opts.follow && !IS_WIN) {
415
+ spawn("tail", ["-n", "40", "-f", LOG_FILE], { stdio: "inherit" });
416
+ return;
417
+ }
418
+ const lines = readFileSync(LOG_FILE, "utf8").split("\n");
419
+ console.log(lines.slice(-40).join("\n"));
420
+ }
421
+
422
+ // --- arg parsing --------------------------------------------------------------
423
+ function parseArgs(argv) {
424
+ const opts = {
425
+ port: Number(process.env.BRIDGE_PORT || 8099),
426
+ token: null,
427
+ lan: false,
428
+ foreground: false,
429
+ follow: false,
430
+ };
431
+ const rest = [];
432
+ for (let i = 0; i < argv.length; i++) {
433
+ const a = argv[i];
434
+ if (a === "--port") opts.port = Number(argv[++i]);
435
+ else if (a === "--token") opts.token = argv[++i];
436
+ else if (a === "--lan") opts.lan = true;
437
+ else if (a === "--foreground") opts.foreground = true;
438
+ else if (a === "--follow" || a === "-f") opts.follow = true;
439
+ else if (a === "--help" || a === "-h") rest.unshift("help");
440
+ else if (a === "--version" || a === "-V") rest.unshift("version");
441
+ else rest.push(a);
442
+ }
443
+ return { opts, cmd: rest[0] || "up" };
444
+ }
445
+
446
+ const HELP = `
447
+ ${bold("pounce")} — pair your phone with this machine ${dim(`(use-pounce v${PKG.version})`)}
448
+
449
+ ${bold("pounce")} start the bridge (background) + show the pairing QR + wait
450
+ ${bold("pounce qr")} same, but don't wait for the phone
451
+ ${bold("pounce status")} bridge / tunnel / phone status
452
+ ${bold("pounce stop")} stop the background bridge and its tunnel
453
+ ${bold("pounce logs")} [-f] show (or follow) the bridge log
454
+
455
+ --port <n> bridge port ${dim("(default 8099)")}
456
+ --token <t> pairing token ${dim("(default: random, kept in ~/.pounce)")}
457
+ --lan skip the iroh tunnel — QR works on this Wi-Fi only
458
+ --foreground run the bridge attached to this terminal
459
+
460
+ Off-LAN pairing rides an iroh p2p tunnel (no port-forwarding): scan the QR
461
+ from anywhere — including a machine you're SSH'd into — and the phone
462
+ connects. The bridge keeps running after you close the terminal.
463
+ `;
464
+
465
+ const { opts, cmd } = parseArgs(process.argv.slice(2));
466
+ if (!Number.isInteger(opts.port) || opts.port <= 0 || opts.port > 65535) {
467
+ console.error(red("invalid --port"));
468
+ process.exit(2);
469
+ }
470
+
471
+ try {
472
+ if (cmd === "up") await cmdUp(opts, { wait: true });
473
+ else if (cmd === "qr") await cmdUp(opts, { wait: false });
474
+ else if (cmd === "status") await cmdStatus(opts);
475
+ else if (cmd === "stop") await cmdStop(opts);
476
+ else if (cmd === "logs") cmdLogs(opts);
477
+ else if (cmd === "version") console.log(PKG.version);
478
+ else if (cmd === "help") console.log(HELP);
479
+ else {
480
+ console.error(red(`unknown command: ${cmd}`));
481
+ console.log(HELP);
482
+ process.exit(2);
483
+ }
484
+ } catch (e) {
485
+ console.error(`\n${red("✗")} ${e?.message || e}`);
486
+ process.exit(1);
487
+ }