handmux 0.5.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +303 -0
  3. package/README.zh-CN.md +285 -0
  4. package/bin/handmux.js +417 -0
  5. package/hooks/handmux-notify.sh +20 -0
  6. package/hooks/handmux-write.cjs +92 -0
  7. package/package.json +52 -0
  8. package/public/assets/index-BN-IwtP6.css +32 -0
  9. package/public/assets/index-BUQ0R83h.js +157 -0
  10. package/public/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  11. package/public/fonts/TWUnifont.woff2 +0 -0
  12. package/public/icons/apple-touch-icon.png +0 -0
  13. package/public/icons/badge-96.png +0 -0
  14. package/public/icons/icon-192.png +0 -0
  15. package/public/icons/icon-512.png +0 -0
  16. package/public/icons/logo.svg +32 -0
  17. package/public/index.html +105 -0
  18. package/public/manifest.webmanifest +37 -0
  19. package/public/offline.html +50 -0
  20. package/public/sw.js +117 -0
  21. package/src/.gitkeep +0 -0
  22. package/src/appName.js +23 -0
  23. package/src/asr/iflyConfig.js +10 -0
  24. package/src/asr/iflySign.js +16 -0
  25. package/src/auth.js +30 -0
  26. package/src/claudeEvents.js +212 -0
  27. package/src/cli/cfNamed.js +5 -0
  28. package/src/cli/claudeHooks.js +116 -0
  29. package/src/cli/cloudflareUrl.js +9 -0
  30. package/src/cli/cloudflared.js +53 -0
  31. package/src/cli/drivers.js +59 -0
  32. package/src/cli/options.js +169 -0
  33. package/src/cli/probe.js +16 -0
  34. package/src/cli/qr.js +34 -0
  35. package/src/cli/service.js +98 -0
  36. package/src/cli/setupWizard.js +248 -0
  37. package/src/cli/sshTunnel.js +12 -0
  38. package/src/cli/state.js +42 -0
  39. package/src/cli/supervisor.js +172 -0
  40. package/src/cli/tmuxConf.js +90 -0
  41. package/src/cli/tmuxVersion.js +49 -0
  42. package/src/cli/tunlite.js +22 -0
  43. package/src/config.js +6 -0
  44. package/src/docPath.js +46 -0
  45. package/src/docs.js +222 -0
  46. package/src/git.js +185 -0
  47. package/src/httpApi.js +546 -0
  48. package/src/previewServer.js +182 -0
  49. package/src/previews.js +118 -0
  50. package/src/push.js +121 -0
  51. package/src/server.js +97 -0
  52. package/src/staticCache.js +8 -0
  53. package/src/tmux/commands.js +223 -0
  54. package/src/trimCapture.js +28 -0
  55. package/src/uploadTypes.js +28 -0
  56. package/tmux/README.md +77 -0
  57. package/tmux/claude-tab-seed.py +67 -0
  58. package/tmux/claude-tab-seen.sh +14 -0
package/src/cli/qr.js ADDED
@@ -0,0 +1,34 @@
1
+ // Terminal QR using vertical half-blocks: each character stacks TWO module rows (top + bottom) in one cell,
2
+ // one module wide. A terminal cell is ~2× taller than wide, so a 1-wide × 2-tall module pair renders as a
3
+ // SQUARE module — the whole code comes out square and scans cleanly.
4
+ //
5
+ // (The earlier 2×2 "quadrant" packing was half the width, but putting 2 modules across a 2:1 cell made each
6
+ // module twice as tall as wide, stretching the whole code vertically. Square + scannable beats narrow.)
7
+ //
8
+ // Polarity: a LIGHT module (quiet zone / non-ink) renders bright, a DARK (ink) module renders empty — so on
9
+ // a dark terminal it reads as a black code on a white field, the quiet zone being the white border.
10
+ //
11
+ // matrix[r][c] === true means a DARK module. renderCompactQr is pure (no I/O) so it's unit-tested; the CLI
12
+ // builds the matrix from qrcode-terminal's QR model and hands it here.
13
+
14
+ export function renderCompactQr(matrix, { quiet = 2 } = {}) {
15
+ const n = matrix.length;
16
+ const side = n + quiet * 2;
17
+ // A module is LIGHT when it's outside the code (quiet zone, incl. a dangling final half-row) or the matrix
18
+ // cell is not dark.
19
+ const light = (r, c) => {
20
+ const mr = r - quiet, mc = c - quiet;
21
+ if (mr < 0 || mr >= n || mc < 0 || mc >= n) return true;
22
+ return !matrix[mr][mc];
23
+ };
24
+ let out = '';
25
+ for (let r = 0; r < side; r += 2) {
26
+ for (let c = 0; c < side; c++) {
27
+ const top = light(r, c);
28
+ const bottom = light(r + 1, c); // overflow row (odd side) is out-of-range → LIGHT, i.e. quiet border
29
+ out += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
30
+ }
31
+ out += '\n';
32
+ }
33
+ return out;
34
+ }
@@ -0,0 +1,98 @@
1
+ // Boot/login autostart. The OS keeps the handmux SUPERVISOR (`__supervise`) alive; the supervisor
2
+ // keeps server + tunnel alive — same model as a foreground run, just parented by launchd/systemd.
3
+ // The intended config is baked into the service file as a base64 payload (self-contained: no reliance
4
+ // on config.json). Text generators are pure (unit-tested); the launchctl/systemctl calls inject `exec`.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { pocketHome, logPath } from './state.js';
9
+
10
+ export const LABEL = 'com.handmux.agent';
11
+ export const UNIT = 'handmux.service';
12
+
13
+ export function plistPath(home) { return path.join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`); }
14
+ export function unitPath(home) { return path.join(home, '.config', 'systemd', 'user', UNIT); }
15
+
16
+ const xmlEscape = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
17
+
18
+ // launchd LaunchAgent. args = full argv for the process (node, script, __supervise, --payload, b64).
19
+ export function plistFor({ args, log, label = LABEL }) {
20
+ const items = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
21
+ return `<?xml version="1.0" encoding="UTF-8"?>
22
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
23
+ <plist version="1.0">
24
+ <dict>
25
+ <key>Label</key><string>${label}</string>
26
+ <key>ProgramArguments</key>
27
+ <array>
28
+ ${items}
29
+ </array>
30
+ <key>RunAtLoad</key><true/>
31
+ <key>KeepAlive</key><true/>
32
+ <key>StandardOutPath</key><string>${xmlEscape(log)}</string>
33
+ <key>StandardErrorPath</key><string>${xmlEscape(log)}</string>
34
+ </dict>
35
+ </plist>
36
+ `;
37
+ }
38
+
39
+ // systemd --user unit. ExecStart needs a single command line; args are space-joined (our args have no
40
+ // spaces except an absolute path with none in practice — quote the script path defensively).
41
+ export function unitFor({ args }) {
42
+ const cmd = args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ');
43
+ return `[Unit]
44
+ Description=handmux — drive your tmux from your phone
45
+ After=network-online.target
46
+
47
+ [Service]
48
+ ExecStart=${cmd}
49
+ Restart=always
50
+ RestartSec=2
51
+
52
+ [Install]
53
+ WantedBy=default.target
54
+ `;
55
+ }
56
+
57
+ export function installService(args, { home, platform = process.platform, exec = spawnSync, log = console } = {}) {
58
+ if (platform === 'darwin') {
59
+ const p = plistPath(home);
60
+ fs.mkdirSync(path.dirname(p), { recursive: true });
61
+ fs.writeFileSync(p, plistFor({ args, log: logPath(home) }));
62
+ exec('launchctl', ['unload', p], { stdio: 'ignore' }); // best-effort: clear any prior load
63
+ const r = exec('launchctl', ['load', '-w', p], { encoding: 'utf8' });
64
+ if (r.status !== 0) throw new Error(`launchctl load failed: ${r.stderr || r.status}`);
65
+ log.log?.(`installed launchd agent: ${p}`);
66
+ return p;
67
+ }
68
+ if (platform === 'linux') {
69
+ const p = unitPath(home);
70
+ fs.mkdirSync(path.dirname(p), { recursive: true });
71
+ fs.writeFileSync(p, unitFor({ args }));
72
+ exec('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
73
+ const r = exec('systemctl', ['--user', 'enable', '--now', UNIT], { encoding: 'utf8' });
74
+ if (r.status !== 0) throw new Error(`systemctl enable failed: ${r.stderr || r.status}`);
75
+ log.log?.(`installed systemd --user unit: ${p}`);
76
+ log.log?.('(for autostart before login: loginctl enable-linger "$USER")');
77
+ return p;
78
+ }
79
+ throw new Error(`autostart not supported on ${platform} yet`);
80
+ }
81
+
82
+ export function uninstallService({ home, platform = process.platform, exec = spawnSync, log = console } = {}) {
83
+ if (platform === 'darwin') {
84
+ const p = plistPath(home);
85
+ exec('launchctl', ['unload', '-w', p], { stdio: 'ignore' });
86
+ try { fs.unlinkSync(p); } catch { /* already gone */ }
87
+ log.log?.(`removed launchd agent: ${p}`);
88
+ return;
89
+ }
90
+ if (platform === 'linux') {
91
+ exec('systemctl', ['--user', 'disable', '--now', UNIT], { stdio: 'ignore' });
92
+ try { fs.unlinkSync(unitPath(home)); } catch { /* already gone */ }
93
+ exec('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
94
+ log.log?.(`removed systemd --user unit: ${unitPath(home)}`);
95
+ return;
96
+ }
97
+ throw new Error(`autostart not supported on ${platform} yet`);
98
+ }
@@ -0,0 +1,248 @@
1
+ // `handmux setup` wizard. Pure mappers (config shape, cloudflared config.yml, parsing tunnel create
2
+ // output) are split out and unit-tested; the readline/spawn shell (runSetup, added in the next task) is
3
+ // thin glue on top.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { createInterface } from 'node:readline';
10
+ import webpush from 'web-push';
11
+ import { configPath, pocketHome } from './state.js';
12
+ import { resolveCloudflared } from './cloudflared.js';
13
+ import { resolveTunlite, checkSshAuth } from './tunlite.js';
14
+
15
+ // ~/.cloudflared/config.yml for a named tunnel: route the hostname to the local handmux port.
16
+ export function cfConfigYaml({ tunnelName, credentialsFile, hostname, port }) {
17
+ return [
18
+ `tunnel: ${tunnelName}`,
19
+ `credentials-file: ${credentialsFile}`,
20
+ 'ingress:',
21
+ ` - hostname: ${hostname}`,
22
+ ` service: http://localhost:${port}`,
23
+ ' - service: http_status:404',
24
+ '',
25
+ ].join('\n');
26
+ }
27
+
28
+ // Extract the tunnel UUID + credentials path from `cloudflared tunnel create <name>` stdout.
29
+ export function parseTunnelCreate(out) {
30
+ const s = String(out || '');
31
+ const id = (s.match(/Created tunnel \S+ with id ([0-9a-fA-F-]+)/) || [])[1] || null;
32
+ const credentialsFile = (s.match(/credentials written to (\S+\.json)/i) || [])[1]?.replace(/\.$/, '') || null;
33
+ return { id, credentialsFile };
34
+ }
35
+
36
+ // Find an existing named tunnel's UUID in `cloudflared tunnel list --output json`. A named tunnel is a
37
+ // PERSISTENT object in the Cloudflare account, so a second `setup` would hit `cloudflared tunnel create`'s
38
+ // "tunnel with name X already exists" error — which used to force a rename and pile up junk tunnels in the
39
+ // account. Looking it up first lets provisioning REUSE it idempotently. Tolerates non-JSON / errors → null.
40
+ export function findTunnelId(listJsonOut, name) {
41
+ let arr;
42
+ try { arr = JSON.parse(String(listJsonOut || '')); } catch { return null; }
43
+ if (!Array.isArray(arr)) return null;
44
+ const hit = arr.find((t) => t && t.name === name);
45
+ return hit?.id || null;
46
+ }
47
+
48
+ // The config keys the wizard owns: everything it asks about. mergeConfig wipes these from the existing
49
+ // config before re-applying the answers, so a blank answer (or an unselected tunnel) cleanly clears the
50
+ // old value instead of leaving a stale field behind. Anything NOT here (token, staticDir, previewDomain…)
51
+ // is preserved untouched.
52
+ const WIZARD_KEYS = [
53
+ 'name', 'port', 'tunnel',
54
+ 'sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl',
55
+ 'vapid', 'xfyun',
56
+ ];
57
+
58
+ // Wizard answers → the config fragment the user actually set (omit empty optional fields).
59
+ export function configFromAnswers(a) {
60
+ const cfg = { tunnel: a.tunnel, port: a.port };
61
+ if (a.name) cfg.name = a.name;
62
+ if (a.tunnel === 'ssh') {
63
+ cfg.sshHost = a.sshHost;
64
+ cfg.remotePort = a.remotePort;
65
+ if (a.publicUrl) cfg.publicUrl = a.publicUrl;
66
+ if (a.sshJump) cfg.sshJump = a.sshJump;
67
+ }
68
+ if (a.tunnel === 'cloudflare-named') {
69
+ cfg.cfHostname = a.cfHostname;
70
+ cfg.cfTunnelName = a.cfTunnelName;
71
+ }
72
+ if (a.vapid) cfg.vapid = a.vapid;
73
+ if (a.xfyun) cfg.xfyun = a.xfyun;
74
+ return cfg;
75
+ }
76
+
77
+ // Fold this run's answers into an existing config: preserve every non-wizard field, replace the wizard's
78
+ // own fields wholesale. This is why re-running `setup` to switch tunnels (or edit the name) never drops
79
+ // your token / push keys / static dir, yet also never leaves the previous tunnel's stale keys around.
80
+ export function mergeConfig(existing = {}, answers) {
81
+ const out = { ...existing };
82
+ for (const k of WIZARD_KEYS) delete out[k];
83
+ return { ...out, ...configFromAnswers(answers) };
84
+ }
85
+
86
+ const ask = (rl, q, dflt) => new Promise((res) =>
87
+ rl.question(dflt ? `${q} [${dflt}] ` : `${q} `, (a) => res((a.trim() || dflt || ''))));
88
+
89
+ // [y/N] / [Y/n] prompt. `dfltYes` sets which way a bare Enter goes.
90
+ const askYesNo = async (rl, q, dfltYes) => {
91
+ const a = (await ask(rl, `${q} ${dfltYes ? '[Y/n]' : '[y/N]'}`, '')).trim().toLowerCase();
92
+ if (a === '') return dfltYes;
93
+ return a === 'y' || a === 'yes';
94
+ };
95
+
96
+ function readExisting(file) {
97
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
98
+ }
99
+
100
+ // Interactive wizard — the one place to configure handmux. Pre-fills from the existing config so a re-run
101
+ // edits/switches rather than starts over, asks name → tunnel → push → voice, and merges the answers back
102
+ // (preserving fields it didn't ask about). Returns the resolved config (or null on abort). `home` and the
103
+ // write `target` are injectable for tests / `--config`.
104
+ export async function runSetup({ home = homedir(), target = configPath(home), log = console } = {}) {
105
+ if (!process.stdin.isTTY) { log.error('handmux setup needs an interactive terminal'); return null; }
106
+ const cur = readExisting(target);
107
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
108
+ try {
109
+ const name = await ask(rl, 'app name (shown in the browser tab / home-screen icon; blank = default)', cur.name || '');
110
+
111
+ log.log('How should your phone reach this machine?');
112
+ log.log(' 1) none — same Wi-Fi / LAN only');
113
+ log.log(' 2) cloudflare — instant, random temporary https URL');
114
+ log.log(' 3) cloudflare-named — your domain, stable HTTPS (most hands-off)');
115
+ log.log(' 4) ssh (tunlite) — your own server / edge');
116
+ const curPick = { none: '1', cloudflare: '2', 'cloudflare-named': '3', ssh: '4' }[cur.tunnel] || '3';
117
+ const pick = await ask(rl, 'choose 1-4', curPick);
118
+ const tunnel = { 1: 'none', 2: 'cloudflare', 3: 'cloudflare-named', 4: 'ssh' }[pick];
119
+ if (!tunnel) { log.error('invalid choice'); return null; }
120
+ const port = Number(await ask(rl, 'server port', String(cur.port || 19999)));
121
+
122
+ const answers = { name, tunnel, port };
123
+ if (tunnel === 'cloudflare-named') {
124
+ answers.cfHostname = await ask(rl, 'public hostname (e.g. handmux.example.com)', cur.cfHostname || '');
125
+ answers.cfTunnelName = await ask(rl, 'tunnel name', cur.cfTunnelName || 'handmux');
126
+ await provisionCloudflareNamed({ home, hostname: answers.cfHostname, tunnelName: answers.cfTunnelName, port, log });
127
+ } else if (tunnel === 'ssh') {
128
+ answers.sshHost = await ask(rl, 'ssh host (user@host[:port])', cur.sshHost || '');
129
+ answers.remotePort = Number(await ask(rl, 'remote port on the ssh host', String(cur.remotePort || port)));
130
+ answers.publicUrl = await ask(rl, 'public url (blank = http://host:remotePort)', cur.publicUrl || '');
131
+ await provisionSsh({ sshHost: answers.sshHost, log });
132
+ }
133
+
134
+ answers.vapid = await askPush(rl, cur.vapid, log);
135
+ answers.xfyun = await askVoice(rl, cur.xfyun, log);
136
+
137
+ const cfg = mergeConfig(cur, answers);
138
+ fs.mkdirSync(path.dirname(target), { recursive: true });
139
+ fs.writeFileSync(target, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
140
+ log.log(`✓ wrote ${target}`);
141
+ if (tunnel === 'ssh') printSshServerHelp(answers, log);
142
+ if (tunnel === 'cloudflare-named' || tunnel === 'ssh') printPreviewHelp(tunnel, log);
143
+ return cfg;
144
+ } finally { rl.close(); }
145
+ }
146
+
147
+ // Push notifications need a VAPID keypair. If one already exists we offer to keep it (regenerating would
148
+ // invalidate every existing phone subscription); otherwise we generate one on the spot — the only painful
149
+ // part of push setup, done for the user. Returns the vapid object, or undefined to leave push off.
150
+ async function askPush(rl, existing, log) {
151
+ if (existing) {
152
+ if (await askYesNo(rl, 'keep push notifications configured?', true)) return existing;
153
+ return undefined;
154
+ }
155
+ if (!await askYesNo(rl, 'set up push notifications now? (generates VAPID keys)', false)) return undefined;
156
+ const { publicKey, privateKey } = webpush.generateVAPIDKeys();
157
+ const subject = await ask(rl, 'contact (mailto: or https URL, for the push service)', 'mailto:admin@example.com');
158
+ log.log('✓ generated VAPID keypair');
159
+ return { public: publicKey, private: privateKey, subject };
160
+ }
161
+
162
+ // Voice input (iFlytek/xfyun) — three credentials from their console; no generation possible, just paste.
163
+ async function askVoice(rl, existing, log) {
164
+ if (existing) {
165
+ if (await askYesNo(rl, 'keep voice input configured?', true)) return existing;
166
+ return undefined;
167
+ }
168
+ if (!await askYesNo(rl, 'set up voice input now? (needs iFlytek/xfyun keys)', false)) return undefined;
169
+ const appId = await ask(rl, 'xfyun appId');
170
+ const apiKey = await ask(rl, 'xfyun apiKey');
171
+ const apiSecret = await ask(rl, 'xfyun apiSecret');
172
+ if (!appId || !apiKey || !apiSecret) { log.log(' (skipped — missing fields)'); return undefined; }
173
+ return { appId, apiKey, apiSecret };
174
+ }
175
+
176
+ // login (browser) → create → route dns → write config.yml. The only human step is the browser login.
177
+ async function provisionCloudflareNamed({ home, hostname, tunnelName, port, log }) {
178
+ const bin = await resolveCloudflared(home);
179
+ const cfDir = path.join(home, '.cloudflared');
180
+ if (!fs.existsSync(path.join(cfDir, 'cert.pem'))) {
181
+ log.log('→ logging in to Cloudflare (a browser will open) …');
182
+ spawnSync(bin, ['tunnel', 'login'], { stdio: 'inherit' });
183
+ }
184
+ // Idempotent: reuse the tunnel if it already exists (re-running setup, or after a stop), else create it.
185
+ // Without this, `tunnel create` errors "already exists" and you'd have to keep renaming — leaving orphan
186
+ // tunnels piling up in the Cloudflare account.
187
+ const listed = spawnSync(bin, ['tunnel', 'list', '--output', 'json'], { encoding: 'utf8' });
188
+ let id = findTunnelId(listed.stdout, tunnelName);
189
+ let credentialsFile = null;
190
+ if (id) {
191
+ log.log(`✓ reusing existing tunnel ${tunnelName} (${id})`);
192
+ credentialsFile = path.join(cfDir, `${id}.json`);
193
+ if (!fs.existsSync(credentialsFile)) {
194
+ log.error(`⚠ credentials file ${credentialsFile} not found on this machine — the tunnel was likely`);
195
+ log.error(` created elsewhere. Run \`${bin} tunnel delete ${tunnelName}\` and re-run setup to recreate it here.`);
196
+ }
197
+ } else {
198
+ log.log(`→ creating tunnel ${tunnelName} …`);
199
+ const created = spawnSync(bin, ['tunnel', 'create', tunnelName], { encoding: 'utf8' });
200
+ process.stdout.write(created.stdout || ''); process.stderr.write(created.stderr || '');
201
+ const parsed = parseTunnelCreate(`${created.stdout || ''}\n${created.stderr || ''}`);
202
+ id = parsed.id;
203
+ credentialsFile = parsed.credentialsFile;
204
+ }
205
+ log.log(`→ routing ${hostname} → tunnel …`);
206
+ // --overwrite-dns: re-running setup (or pointing a hostname already routed) must not error on the DNS step.
207
+ const routed = spawnSync(bin, ['tunnel', 'route', 'dns', '--overwrite-dns', tunnelName, hostname], { encoding: 'utf8' });
208
+ if (routed.status !== 0) {
209
+ process.stderr.write(routed.stderr || '');
210
+ log.error(`⚠ route dns failed — is ${hostname.split('.').slice(-2).join('.')}'s DNS hosted on Cloudflare?`);
211
+ log.error(' Add the domain on Cloudflare (free) and point its nameservers there, then re-run setup.');
212
+ }
213
+ fs.mkdirSync(cfDir, { recursive: true });
214
+ fs.writeFileSync(path.join(cfDir, 'config.yml'),
215
+ cfConfigYaml({ tunnelName, credentialsFile: credentialsFile || path.join(cfDir, `${id || tunnelName}.json`), hostname, port }));
216
+ log.log(`✓ wrote ${path.join(cfDir, 'config.yml')}`);
217
+ }
218
+
219
+ // drive tunlite passwordless setup inline (one password) if not already set up.
220
+ async function provisionSsh({ sshHost, log }) {
221
+ const bin = resolveTunlite();
222
+ if (checkSshAuth(sshHost, { bin }) === 0) { log.log('✓ passwordless SSH already set up'); return; }
223
+ log.log(`→ setting up passwordless SSH to ${sshHost} (you'll enter the password once) …`);
224
+ spawnSync(bin, ['setup-key', sshHost], { stdio: 'inherit' });
225
+ }
226
+
227
+ function printSshServerHelp(a, log) {
228
+ log.log('');
229
+ log.log('Server side (one-time): point a reverse proxy at the forwarded loopback port.');
230
+ log.log(` nginx: proxy_pass http://127.0.0.1:${a.remotePort}; (add client_max_body_size 60m; proxy_read_timeout 90s;)`);
231
+ log.log(` caddy: ${a.publicUrl || '<your-domain>'} { reverse_proxy 127.0.0.1:${a.remotePort} }`);
232
+ log.log('');
233
+ }
234
+
235
+ // FYI on dynamic port preview: it's optional and NOT wired by this wizard (separate wildcard domain). Print
236
+ // the requirement + a TLS note that fits the chosen tunnel — Cloudflare's free cert only covers one level
237
+ // (so deeper needs ACM), whereas on the ssh/own-edge path the user serves their own wildcard cert. Shown
238
+ // only for wildcard-capable tunnels (a quick tunnel can't do wildcards at all).
239
+ function printPreviewHelp(tunnel, log) {
240
+ log.log('Optional — dynamic port preview (open a dev server by port on your phone):');
241
+ log.log(' set "previewDomain": "..." in the config, and route the wildcard preview domain to the gateway.');
242
+ if (tunnel === 'cloudflare-named') {
243
+ log.log(" TLS: Cloudflare's free cert covers ONE level (*.example.com); deeper (*.preview.example.com) needs Advanced Certificate Manager.");
244
+ } else {
245
+ log.log(" TLS: your edge serves the wildcard cert (e.g. a Let's Encrypt *.preview.your.domain).");
246
+ }
247
+ log.log('');
248
+ }
@@ -0,0 +1,12 @@
1
+ // Pure: detect tunlite `run --json` reaching the `connected` state from an NDJSON log chunk. tunlite
2
+ // self-reconnects, so we only flip to "live" on a real connected event (mirrors cloudflare URL-scrape:
3
+ // don't surface the public URL until the tunnel is actually up). Side-effect-free → unit-tested against
4
+ // real captured NDJSON lines.
5
+ export function isTunnelConnected(text) {
6
+ for (const line of String(text || '').split('\n')) {
7
+ const s = line.trim();
8
+ if (!s) continue;
9
+ try { if (JSON.parse(s).state === 'connected') return true; } catch { /* partial / non-json line */ }
10
+ }
11
+ return false;
12
+ }
@@ -0,0 +1,42 @@
1
+ // Runtime state lives in ~/.handmux/ — a single state.json (pids + the live public URL) plus a log
2
+ // file the detached supervisor writes to. `home` is injectable so this unit-tests in a temp dir.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { homedir } from 'node:os';
6
+
7
+ export function pocketHome(home = homedir()) { return path.join(home, '.handmux'); }
8
+ export function statePath(home) { return path.join(pocketHome(home), 'state.json'); }
9
+ export function logPath(home) { return path.join(pocketHome(home), 'handmux.log'); }
10
+ export function configPath(home) { return path.join(pocketHome(home), 'config.json'); }
11
+
12
+ // The hook-maintained Claude state file, on a stable per-user path (survives a global reinstall, unlike
13
+ // the package-internal server/data default). The CLI sets this as CLAUDE_STATE_FILE for the server child
14
+ // and writes the same path into the hook's env, so both ends read/write one file.
15
+ export function claudeStatePath(home) { return path.join(pocketHome(home), 'claude-state.json'); }
16
+
17
+ // Push subscriptions and the dynamic-preview registry are mutable runtime data too, so they belong on the
18
+ // same stable per-user path — NOT the package-internal server/data default, which a `npm i -g handmux`
19
+ // reinstall replaces (silently dropping every saved subscription). The CLI injects these as PUSH_STORE /
20
+ // PREVIEW_STORE for the server child.
21
+ export function pushStorePath(home) { return path.join(pocketHome(home), 'push-subs.json'); }
22
+ export function previewStorePath(home) { return path.join(pocketHome(home), 'previews.json'); }
23
+
24
+ export function readState(home) {
25
+ try { return JSON.parse(fs.readFileSync(statePath(home), 'utf8')); } catch { return null; }
26
+ }
27
+
28
+ export function writeState(state, home) {
29
+ fs.mkdirSync(pocketHome(home), { recursive: true });
30
+ fs.writeFileSync(statePath(home), JSON.stringify(state, null, 2));
31
+ }
32
+
33
+ export function clearState(home) {
34
+ try { fs.unlinkSync(statePath(home)); } catch { /* already gone */ }
35
+ }
36
+
37
+ // pid liveness without sending a real signal: kill(pid, 0) throws ESRCH if dead, EPERM if alive but
38
+ // not ours (still counts as running).
39
+ export function isAlive(pid) {
40
+ if (!pid) return false;
41
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
42
+ }
@@ -0,0 +1,172 @@
1
+ // The single long-running supervisor. It owns the node server (and, for a process-backed tunnel like
2
+ // cloudflare, the tunnel) as CHILD processes, restarts them with a small backoff on exit, and records
3
+ // the live public URL into state.json. There is only ever one daemon here — cloudflared (and, later,
4
+ // `tunlite run`) are just its children, exactly like the server is.
5
+ import { spawn } from 'node:child_process';
6
+ import net from 'node:net';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { getDriver } from './drivers.js';
11
+ import { writeState, clearState, claudeStatePath, pushStorePath, previewStorePath } from './state.js';
12
+
13
+ const here = path.dirname(fileURLToPath(import.meta.url));
14
+ const SERVER = path.resolve(here, '../server.js');
15
+
16
+ // First non-internal IPv4 — the address a phone on the same wifi uses when there's no tunnel.
17
+ export function lanUrl(port, ifaces = os.networkInterfaces()) {
18
+ for (const list of Object.values(ifaces)) {
19
+ for (const ni of list || []) {
20
+ if (ni.family === 'IPv4' && !ni.internal) return `http://${ni.address}:${port}`;
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+
26
+ // The token rides in the query string so the first navigation (or a QR scan) authenticates in one shot.
27
+ export function publicUrlWithToken(base, token) {
28
+ if (!base) return base;
29
+ return `${base.replace(/\/$/, '')}/?token=${encodeURIComponent(token)}`;
30
+ }
31
+
32
+ // Bare address with no token in it — printed/QR-encoded so a link can be shared or screenshotted without
33
+ // leaking the secret; the token is shown separately for the user to paste in.
34
+ export function bareUrl(base) {
35
+ if (!base) return base;
36
+ return `${base.replace(/\/$/, '')}/`;
37
+ }
38
+
39
+ export function supervise(cfg, { home, log = console } = {}) {
40
+ const driver = getDriver(cfg.tunnel);
41
+ const children = {};
42
+ let stopping = false;
43
+ let urlBuf = '';
44
+ let backoff = 500;
45
+
46
+ const state = {
47
+ supervisorPid: process.pid,
48
+ startedAt: Date.now(),
49
+ tunnel: cfg.tunnel,
50
+ port: cfg.port,
51
+ host: cfg.host,
52
+ token: cfg.token,
53
+ previewDomain: cfg.previewDomain || null,
54
+ localUrl: `http://localhost:${cfg.port}`,
55
+ lanUrl: lanUrl(cfg.port),
56
+ publicUrl: null,
57
+ ready: false,
58
+ error: null,
59
+ };
60
+ const persist = () => writeState(state, home);
61
+ persist();
62
+
63
+ // The server socket comes up a beat after spawn; don't report "ready" (and don't let the CLI print an
64
+ // access URL) until a TCP connect to it actually succeeds, or callers race an unbound port.
65
+ const waitListening = () => {
66
+ if (stopping || state.ready) return;
67
+ const s = net.connect({ port: cfg.port, host: '127.0.0.1' });
68
+ const retry = () => { s.destroy(); if (!stopping) setTimeout(waitListening, 200); };
69
+ s.setTimeout(500, retry);
70
+ s.once('error', retry);
71
+ s.once('connect', () => { s.destroy(); state.ready = true; persist(); });
72
+ };
73
+
74
+ const startServer = () => {
75
+ // The server reads only process.env (no .env files) — the CLI resolved the one config file and we
76
+ // hand the server everything it needs here. This is the single injection point: config.json fields →
77
+ // the env names the server already reads (HANDMUX_* / VAPID_* / XFYUN_*).
78
+ const env = {
79
+ ...process.env,
80
+ NODE_ENV: 'handmux',
81
+ HANDMUX_PORT: String(cfg.port),
82
+ HANDMUX_HOST: cfg.host,
83
+ HANDMUX_TOKEN: cfg.token,
84
+ CLAUDE_STATE_FILE: claudeStatePath(home),
85
+ PUSH_STORE: pushStorePath(home),
86
+ PREVIEW_STORE: previewStorePath(home),
87
+ };
88
+ if (cfg.previewDomain) env.HANDMUX_PREVIEW_DOMAIN = cfg.previewDomain;
89
+ if (cfg.name) env.HANDMUX_APP_NAME = cfg.name;
90
+ if (cfg.staticDir) env.HANDMUX_STATIC_DIR = cfg.staticDir;
91
+ if (cfg.uploadExts) env.HANDMUX_UPLOAD_EXTS = cfg.uploadExts;
92
+ if (cfg.previewTtl) env.HANDMUX_PREVIEW_TTL = String(cfg.previewTtl);
93
+ if (cfg.vapid) {
94
+ if (cfg.vapid.public) env.VAPID_PUBLIC = cfg.vapid.public;
95
+ if (cfg.vapid.private) env.VAPID_PRIVATE = cfg.vapid.private;
96
+ if (cfg.vapid.subject) env.VAPID_SUBJECT = cfg.vapid.subject;
97
+ }
98
+ if (cfg.xfyun) {
99
+ if (cfg.xfyun.appId) env.XFYUN_APPID = cfg.xfyun.appId;
100
+ if (cfg.xfyun.apiKey) env.XFYUN_APIKEY = cfg.xfyun.apiKey;
101
+ if (cfg.xfyun.apiSecret) env.XFYUN_APISECRET = cfg.xfyun.apiSecret;
102
+ }
103
+ const c = spawn(process.execPath, [SERVER], { env, stdio: ['ignore', 'inherit', 'inherit'] });
104
+ children.server = c;
105
+ state.serverPid = c.pid; persist();
106
+ c.on('exit', () => { if (!stopping) backoffRestart('server', startServer); });
107
+ };
108
+
109
+ const startTunnel = () => {
110
+ if (!driver.needsProcess) { // 'none' — reachable directly on LAN/localhost (or a tunnel you run yourself)
111
+ state.publicUrl = cfg.publicUrl || state.lanUrl || state.localUrl; persist(); return;
112
+ }
113
+ const spec = driver.proc(cfg);
114
+ const c = spawn(spec.cmd, spec.args, { stdio: ['ignore', 'pipe', 'pipe'] });
115
+ children.tunnel = c;
116
+ state.tunnelPid = c.pid; persist();
117
+ const onData = (b) => {
118
+ const s = b.toString();
119
+ process.stdout.write(s);
120
+ if (state.publicUrl) return;
121
+ urlBuf = (urlBuf + s).slice(-4000);
122
+ const url = driver.matchUrl(urlBuf, cfg);
123
+ if (url) { state.publicUrl = url; state.error = null; backoff = 500; persist(); }
124
+ };
125
+ c.stdout.on('data', onData);
126
+ c.stderr.on('data', onData);
127
+ c.on('error', (e) => {
128
+ state.error = e.code === 'ENOENT'
129
+ ? (driver.notFoundHint || `${spec.cmd} not found`)
130
+ : String(e);
131
+ persist();
132
+ });
133
+ c.on('exit', () => { if (!stopping) { state.publicUrl = null; persist(); backoffRestart('tunnel', startTunnel); } });
134
+ };
135
+
136
+ const backoffRestart = (what, fn) => {
137
+ const d = Math.min(backoff, 15000);
138
+ backoff = Math.min(backoff * 2, 15000);
139
+ log.warn?.(`[handmux] ${what} exited; restarting in ${d}ms`);
140
+ setTimeout(() => { if (!stopping) fn(); }, d);
141
+ };
142
+
143
+ const shutdown = () => {
144
+ stopping = true;
145
+ const kids = Object.values(children).filter(Boolean);
146
+ for (const c of kids) { try { c.kill('SIGTERM'); } catch { /* already dead */ } }
147
+ clearState(home);
148
+ // Don't exit on a fixed timer — that orphans any child still shutting down (a SIGKILL'd or crashed
149
+ // supervisor was how a stray cloudflared kept running after `stop`). Poll until the children are gone,
150
+ // then SIGKILL any straggler so we NEVER leak a tunnel process. With --grace-period 0s, cloudflared
151
+ // exits near-instantly; the 3s ceiling is just a backstop.
152
+ const alive = () => kids.filter((c) => { try { process.kill(c.pid, 0); return true; } catch { return false; } });
153
+ let waited = 0;
154
+ const tick = () => {
155
+ const left = alive();
156
+ if (left.length === 0 || waited >= 3000) {
157
+ for (const c of left) { try { c.kill('SIGKILL'); } catch { /* already dead */ } }
158
+ process.exit(0);
159
+ }
160
+ waited += 150;
161
+ setTimeout(tick, 150);
162
+ };
163
+ tick();
164
+ };
165
+ process.on('SIGTERM', shutdown);
166
+ process.on('SIGINT', shutdown);
167
+
168
+ startServer();
169
+ startTunnel();
170
+ waitListening();
171
+ return { state };
172
+ }