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 +38 -0
- package/bin/pounce.mjs +487 -0
- package/dist/launcher.mjs +5246 -0
- package/package.json +52 -0
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
|
+
}
|