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.
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/README.zh-CN.md +285 -0
- package/bin/handmux.js +417 -0
- package/hooks/handmux-notify.sh +20 -0
- package/hooks/handmux-write.cjs +92 -0
- package/package.json +52 -0
- package/public/assets/index-BN-IwtP6.css +32 -0
- package/public/assets/index-BUQ0R83h.js +157 -0
- package/public/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- package/public/fonts/TWUnifont.woff2 +0 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/badge-96.png +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/logo.svg +32 -0
- package/public/index.html +105 -0
- package/public/manifest.webmanifest +37 -0
- package/public/offline.html +50 -0
- package/public/sw.js +117 -0
- package/src/.gitkeep +0 -0
- package/src/appName.js +23 -0
- package/src/asr/iflyConfig.js +10 -0
- package/src/asr/iflySign.js +16 -0
- package/src/auth.js +30 -0
- package/src/claudeEvents.js +212 -0
- package/src/cli/cfNamed.js +5 -0
- package/src/cli/claudeHooks.js +116 -0
- package/src/cli/cloudflareUrl.js +9 -0
- package/src/cli/cloudflared.js +53 -0
- package/src/cli/drivers.js +59 -0
- package/src/cli/options.js +169 -0
- package/src/cli/probe.js +16 -0
- package/src/cli/qr.js +34 -0
- package/src/cli/service.js +98 -0
- package/src/cli/setupWizard.js +248 -0
- package/src/cli/sshTunnel.js +12 -0
- package/src/cli/state.js +42 -0
- package/src/cli/supervisor.js +172 -0
- package/src/cli/tmuxConf.js +90 -0
- package/src/cli/tmuxVersion.js +49 -0
- package/src/cli/tunlite.js +22 -0
- package/src/config.js +6 -0
- package/src/docPath.js +46 -0
- package/src/docs.js +222 -0
- package/src/git.js +185 -0
- package/src/httpApi.js +546 -0
- package/src/previewServer.js +182 -0
- package/src/previews.js +118 -0
- package/src/push.js +121 -0
- package/src/server.js +97 -0
- package/src/staticCache.js +8 -0
- package/src/tmux/commands.js +223 -0
- package/src/trimCapture.js +28 -0
- package/src/uploadTypes.js +28 -0
- package/tmux/README.md +77 -0
- package/tmux/claude-tab-seed.py +67 -0
- package/tmux/claude-tab-seen.sh +14 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
// The hook-maintained state file: ONE JSON object keyed by tmux pane id, each value the pane's latest
|
|
7
|
+
// event { ts, src, host, payload }. The hook writes it (handmux-write.js); the server only reads it.
|
|
8
|
+
// Default lives under server/data (gitignored runtime data); override with CLAUDE_STATE_FILE.
|
|
9
|
+
export const DEFAULT_STATE_FILE = process.env.CLAUDE_STATE_FILE || path.resolve(here, '../data/claude-state.json');
|
|
10
|
+
|
|
11
|
+
// Build the 需要你 one-liner for a PermissionRequest, from the tool it's gating on (PermissionRequest
|
|
12
|
+
// carries tool_name + tool_input, unlike the later permission_prompt Notification which only has an
|
|
13
|
+
// English message).
|
|
14
|
+
function permMsg(body) {
|
|
15
|
+
const t = body.tool_name;
|
|
16
|
+
if (t === 'AskUserQuestion') {
|
|
17
|
+
const q = body.tool_input && body.tool_input.questions && body.tool_input.questions[0];
|
|
18
|
+
const text = (q && (q.question || q.header)) || '';
|
|
19
|
+
return text ? `需要你回答:${text}` : '需要你回答';
|
|
20
|
+
}
|
|
21
|
+
if (t === 'ExitPlanMode') return '需要你批准计划';
|
|
22
|
+
return t ? `需要你授权:${t}` : '需要你';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Build the 进行中 one-liner for a resume (PostToolUse after the user answered/approved), surfacing the
|
|
26
|
+
// choice they just made — AskUserQuestion stores it in tool_input.answers, keyed by question.
|
|
27
|
+
function resumeMsg(body) {
|
|
28
|
+
const a = (body.tool_input && body.tool_input.answers) || (body.tool_response && body.tool_response.answers);
|
|
29
|
+
if (a && typeof a === 'object') {
|
|
30
|
+
const picks = Object.values(a).flat().filter(Boolean).join('、');
|
|
31
|
+
if (picks) return `已答:${picks}`;
|
|
32
|
+
}
|
|
33
|
+
if (body.tool_name === 'ExitPlanMode') return '已批准计划';
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Map a hook event (src + raw Claude payload) to a notification "kind". Pure — no I/O, easy to test.
|
|
38
|
+
// stop → done (turn finished; carries last message)
|
|
39
|
+
// prompt → working (UserPromptSubmit; carries the prompt)
|
|
40
|
+
// end → end (SessionEnd; the pane's claude is gone)
|
|
41
|
+
// notify + idle_prompt → idle (waited ~60s; carries the notification message)
|
|
42
|
+
// notify + permission_prompt → permission (blocked on a permission/选择 gate; carries the message)
|
|
43
|
+
// resume → working (PostToolUse on AskUserQuestion/ExitPlanMode: the user just
|
|
44
|
+
// answered/approved → Claude is working again; un-sticks the
|
|
45
|
+
// pane from the `permission` state its prompt left behind, and
|
|
46
|
+
// carries the choice the user made, e.g. 已答:Red)
|
|
47
|
+
// permreq → permission (PermissionRequest: a real prompt just appeared — fires ~6s
|
|
48
|
+
// before the permission_prompt Notification and names the tool,
|
|
49
|
+
// so 需要你 shows faster and says what's being asked. Verified
|
|
50
|
+
// NOT to fire for auto-approved tools → no false 需要你 in auto)
|
|
51
|
+
// anything else → null (ignored: auth_success, elicitation_*, etc.)
|
|
52
|
+
export function classifyEvent(src, body = {}) {
|
|
53
|
+
if (src === 'stop') return { kind: 'done', msg: body.last_assistant_message || '' };
|
|
54
|
+
if (src === 'prompt') return { kind: 'working', msg: body.prompt || '' };
|
|
55
|
+
if (src === 'resume') return { kind: 'working', msg: resumeMsg(body) };
|
|
56
|
+
if (src === 'permreq') return { kind: 'permission', msg: permMsg(body) };
|
|
57
|
+
if (src === 'end') return { kind: 'end' };
|
|
58
|
+
if (src === 'notify') {
|
|
59
|
+
if (body.notification_type === 'idle_prompt') return { kind: 'idle', msg: body.message || '' };
|
|
60
|
+
if (body.notification_type === 'permission_prompt') return { kind: 'permission', msg: body.message || '' };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Which display VIEW a kind pushes as — and so what the device notification fires for. permission→需要你,
|
|
66
|
+
// done→已完成. 已完成 fires at the COMPLETION MOMENT (done) only. The trailing idle reminder (~60s "still
|
|
67
|
+
// waiting") is dropped UPSTREAM at the hook (handmux-write.cjs), so neither push nor the inbox ever sees
|
|
68
|
+
// it — that's the single source of truth. The idle NO-OP in the loop below is defense-in-depth for one
|
|
69
|
+
// that ever slips through. working / end / unclassifiable map to undefined → no push + re-arm the dedup.
|
|
70
|
+
const PUSH_VIEW = { permission: 'needs', done: 'done' };
|
|
71
|
+
const VIEW_LABEL = { needs: '需要你', done: '已完成' };
|
|
72
|
+
|
|
73
|
+
// A 进行中 (working) is a LATCHED state: set by UserPromptSubmit, normally closed by Stop. But an ESC
|
|
74
|
+
// interrupt / walk-away fires NO hook at all (verified across all 26 hook event types), so working never
|
|
75
|
+
// gets closed and the blue dot would stick forever. There's no event signal for the interrupt — so we
|
|
76
|
+
// expire working purely by age: latched longer than this with no done/needs ⇒ no-longer-working, drop it.
|
|
77
|
+
// Generous (2h) so a genuinely long-running task keeps its dot for its whole run; a real turn re-lights on
|
|
78
|
+
// its next event, and a new prompt resets the clock.
|
|
79
|
+
const WORKING_TTL_MS = 2 * 60 * 60 * 1000;
|
|
80
|
+
|
|
81
|
+
// Truncate a Claude message to a notification-friendly one-liner.
|
|
82
|
+
function summarize(msg) {
|
|
83
|
+
const oneLine = (msg || '').replace(/\s+/g, ' ').trim();
|
|
84
|
+
return oneLine.length > 120 ? `${oneLine.slice(0, 117)}…` : oneLine;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Read the hook's JSON state file. Tolerant of a missing / corrupt / half-written file (returns {}),
|
|
88
|
+
// never throws — the hook replaces it atomically, but a read can still land on a transient state.
|
|
89
|
+
function readStateFile(file) {
|
|
90
|
+
try {
|
|
91
|
+
const obj = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
92
|
+
return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
|
|
93
|
+
} catch { return {}; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Per-process event reader. Deps injected for testability:
|
|
97
|
+
// commands.listLivePanes() → [{ id, cmd, session, window, windowName }] (one tmux call: liveness+location)
|
|
98
|
+
// push.sendToSession(session, payload, {ttl, urgency, topic})
|
|
99
|
+
// file: the hook-maintained JSON state file (DEFAULT_STATE_FILE).
|
|
100
|
+
// The hook is the sole writer; the server reads the file fresh on every getStates and on every file
|
|
101
|
+
// change (the watcher, for push). No persisted state of our own — the file IS the persistence.
|
|
102
|
+
export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE, now = () => Date.now() } = {}) {
|
|
103
|
+
const lastPushed = {}; // pane → 'needs' | 'done' | null (in-process push-transition dedup, by display view)
|
|
104
|
+
const dotClearedFor = {}; // pane → ts of the stuck 进行中 we already cleared @claude_dot for (clear once)
|
|
105
|
+
// The dedup above is in-process ONLY: a restart (e.g. ./deploy.sh) wipes it while the hook's state
|
|
106
|
+
// file on disk keeps every pane's latest 需要你/已完成. Without priming, the first read after boot
|
|
107
|
+
// would see an empty dedup and re-push every resting pane — a flood of "historical" notifications on
|
|
108
|
+
// every redeploy. prime() (run from start(), before any request is served) adopts the file's current
|
|
109
|
+
// resting states as already-notified, so only transitions that happen AFTER boot push.
|
|
110
|
+
function prime() {
|
|
111
|
+
const recorded = readStateFile(file);
|
|
112
|
+
for (const [pane, r] of Object.entries(recorded)) {
|
|
113
|
+
const c = r && typeof r.src === 'string' ? classifyEvent(r.src, r.payload || {}) : null;
|
|
114
|
+
const view = c ? PUSH_VIEW[c.kind] : undefined;
|
|
115
|
+
if (view) lastPushed[pane] = view; // resting 需要你/已完成 → treat as seen, don't replay
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Notify the device that a pane entered 需要你 / 已完成. Title carries the state label + session so the
|
|
120
|
+
// user can tell the two apart at a glance (the inbox shows the same label as a chip); body is the
|
|
121
|
+
// pane's one-line message, falling back to the bare label. 需要你 is high-urgency (wake the phone).
|
|
122
|
+
async function sendPush(pane, view, c, lp) {
|
|
123
|
+
const label = VIEW_LABEL[view];
|
|
124
|
+
const body = summarize(c.msg) || label;
|
|
125
|
+
const payload = { title: `${label} · ${lp.session}`, body, tag: `pane-${pane}`, data: { session: lp.session, window: lp.window, pane } };
|
|
126
|
+
// 可靠优先 TTL: a phone in Doze / briefly offline still gets the LATEST per pane when it wakes (the
|
|
127
|
+
// pane-topic collapses older ones, so no pileup). 需要你 holds for hours (you're being waited on);
|
|
128
|
+
// 已完成 ~30min (a stale "done" is less useful). A force-stopped app still can't be reached at all —
|
|
129
|
+
// that's an OS limit, not a TTL one.
|
|
130
|
+
const opts = { topic: `pane-${pane}`, ttl: view === 'needs' ? 14400 : 1800, urgency: view === 'needs' ? 'high' : 'normal' };
|
|
131
|
+
try { await push.sendToSession(lp.session, payload, opts); } catch { /* best effort */ }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Read the state file, reconcile every recorded pane against live tmux, and in ONE pass: (1) fire push
|
|
135
|
+
// for 需要你/已完成 transitions (deduped, global — independent of any caller's session filter), and
|
|
136
|
+
// (2) build the pane→state roster the inbox shows. A pane is dropped from the roster when its latest
|
|
137
|
+
// event is `end`/unclassifiable, or when tmux says it's gone or no longer running claude (hard kill /
|
|
138
|
+
// crash / Ctrl-C-out with no SessionEnd). `allowedSessions` (session NAMES, or null) scopes only the
|
|
139
|
+
// OUTPUT — push and reconciliation run over every pane. On a tmux failure we degrade: no location, no
|
|
140
|
+
// reconciliation, no push (we'd have no session name to route to), roster returned best-effort.
|
|
141
|
+
async function getStates(allowedSessions = null) {
|
|
142
|
+
const allow = allowedSessions == null ? null : new Set(allowedSessions);
|
|
143
|
+
const recorded = readStateFile(file);
|
|
144
|
+
let live = null;
|
|
145
|
+
try { live = new Map((await commands.listLivePanes()).map((p) => [p.id, p])); } catch { /* tmux down */ }
|
|
146
|
+
|
|
147
|
+
const out = {};
|
|
148
|
+
for (const [pane, rec] of Object.entries(recorded)) {
|
|
149
|
+
const c = rec && typeof rec.src === 'string' ? classifyEvent(rec.src, rec.payload || {}) : null;
|
|
150
|
+
const lp = live ? live.get(pane) : null;
|
|
151
|
+
const gone = live ? (!lp || lp.cmd !== 'claude') : false;
|
|
152
|
+
|
|
153
|
+
// (1) push side-effect — runs for every pane regardless of the output filter. Push fires on entry
|
|
154
|
+
// into a 需要你 (permission) / 已完成 (done) view, deduped so a stay-put doesn't re-push. The idle
|
|
155
|
+
// reminder that trails a done is a NO-OP: 已完成 pings at the completion moment (done) only, so the
|
|
156
|
+
// 60s "still waiting" idle neither pushes nor disturbs the dedup (the pane is still in the same
|
|
157
|
+
// resting state). working / end / gone / unclassifiable re-arm the dedup for the next entry.
|
|
158
|
+
if (live) {
|
|
159
|
+
const kind = c ? c.kind : null;
|
|
160
|
+
const view = PUSH_VIEW[kind]; // 'needs' | 'done' | undefined
|
|
161
|
+
if (kind === 'idle') {
|
|
162
|
+
/* trailing idle reminder — no push, keep the dedup as the preceding done left it */
|
|
163
|
+
} else if (gone || !view) {
|
|
164
|
+
lastPushed[pane] = null; // 进行中 / 结束 / gone → re-arm for the next 需要你 / 已完成
|
|
165
|
+
} else if (lastPushed[pane] !== view && lp.session) {
|
|
166
|
+
lastPushed[pane] = view;
|
|
167
|
+
await sendPush(pane, view, c, lp);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// (2) roster — drop ended / dead / claude-exited panes; resolve location from the live tmux row.
|
|
172
|
+
if (!c || c.kind === 'end' || gone) continue;
|
|
173
|
+
// Expire a 进行中 latched past the TTL (an ESC-interrupt / walk-away that never got a Stop): drop it
|
|
174
|
+
// from the roster and clear the PC @claude_dot once, so the stuck blue dot goes away. See WORKING_TTL_MS.
|
|
175
|
+
if (c.kind === 'working' && now() - (rec.ts || 0) > WORKING_TTL_MS) {
|
|
176
|
+
if (live && typeof commands.runTmux === 'function' && dotClearedFor[pane] !== rec.ts) {
|
|
177
|
+
dotClearedFor[pane] = rec.ts;
|
|
178
|
+
try { await commands.runTmux(['set-option', '-w', '-t', pane, '@claude_dot', '']); } catch { /* best effort */ }
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const loc = lp ? { session: lp.session, window: lp.window, windowName: lp.windowName } : {};
|
|
183
|
+
if (allow && !allow.has(loc.session)) continue;
|
|
184
|
+
out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0 };
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Watch the state file's directory (the hook replaces the file via rename, which changes the inode, so
|
|
190
|
+
// watching the file itself would go deaf after the first write — watch the dir and filter by name).
|
|
191
|
+
// On a change, re-run getStates so 需要你/已完成 push fires even when NO client is polling — that's
|
|
192
|
+
// the whole point of push (notify you while you're away). Debounced so a burst of writes pumps once.
|
|
193
|
+
let watcher = null;
|
|
194
|
+
let deb = null;
|
|
195
|
+
function start() {
|
|
196
|
+
if (watcher) return;
|
|
197
|
+
prime(); // boot baseline: don't replay the file's resting 需要你/已完成 as fresh push (see prime())
|
|
198
|
+
const dir = path.dirname(file);
|
|
199
|
+
const base = path.basename(file);
|
|
200
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
|
201
|
+
try {
|
|
202
|
+
watcher = fs.watch(dir, (_evt, fname) => {
|
|
203
|
+
if (fname && fname !== base) return;
|
|
204
|
+
clearTimeout(deb);
|
|
205
|
+
deb = setTimeout(() => { getStates().catch(() => {}); }, 120);
|
|
206
|
+
});
|
|
207
|
+
} catch { /* fs.watch unsupported → push falls back to evaluation on each /states poll */ }
|
|
208
|
+
}
|
|
209
|
+
function stop() { if (watcher) { watcher.close(); watcher = null; } clearTimeout(deb); }
|
|
210
|
+
|
|
211
|
+
return { getStates, start, stop };
|
|
212
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Pure: detect a named cloudflared tunnel reaching a live edge connection. The hostname is known up front
|
|
2
|
+
// (https://<cf-hostname>), so unlike quick-tunnel we don't scrape a URL — we just gate on cloudflared
|
|
3
|
+
// logging a registered connection, so the QR isn't shown before the edge is reachable.
|
|
4
|
+
const RE = /Registered tunnel connection/;
|
|
5
|
+
export function cfNamedReady(text) { return RE.test(String(text || '')); }
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Install/uninstall the Claude Code lifecycle hooks that feed the handmux inbox. This ports the idempotent
|
|
2
|
+
// merge from scripts/install-hooks.sh into testable JS so the OSS CLI (and the phone, via the server) can
|
|
3
|
+
// turn the inbox on — opt-in, since writing ~/.claude/settings.json edits another tool's config.
|
|
4
|
+
//
|
|
5
|
+
// Iron rule: only ever touch ~/.handmux/ and — after explicit opt-in — ~/.claude/. If ~/.claude is absent
|
|
6
|
+
// (no Claude Code), skip and report 'no-claude'; never create it.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
|
|
11
|
+
// The six events the inbox reads. src is the arg passed to handmux-notify.sh; only PostToolUse is scoped to
|
|
12
|
+
// a matcher (the two "需要你" interaction tools) — every other Read/Bash/Edit must NOT wake the hook, or
|
|
13
|
+
// many concurrent Claude panes would each spawn the hook on every tool call. Keep this table in sync with
|
|
14
|
+
// the hook scripts in ../../hooks.
|
|
15
|
+
export const HOOK_EVENTS = [
|
|
16
|
+
{ event: 'Stop', src: 'stop' },
|
|
17
|
+
{ event: 'Notification', src: 'notify' },
|
|
18
|
+
{ event: 'UserPromptSubmit', src: 'prompt' },
|
|
19
|
+
{ event: 'SessionEnd', src: 'end' },
|
|
20
|
+
{ event: 'PostToolUse', src: 'resume', matcher: 'AskUserQuestion|ExitPlanMode' },
|
|
21
|
+
{ event: 'PermissionRequest', src: 'permreq' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const HOOK_MARK = 'handmux-notify.sh'; // identifies our hooks among the user's own
|
|
25
|
+
|
|
26
|
+
// True if `settings.hooks[event]` already has one of our hooks (command references the dest script).
|
|
27
|
+
function alreadyHas(hooks, event) {
|
|
28
|
+
return (hooks[event] || []).some((g) => (g.hooks || []).some(
|
|
29
|
+
(h) => typeof h.command === 'string' && h.command.includes(HOOK_MARK)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Pure: return a NEW settings object with our six hooks merged into settings.hooks, idempotently, leaving
|
|
33
|
+
// the user's own hooks and other keys untouched. `dest` is the absolute path to the copied notify script.
|
|
34
|
+
export function mergeHooks(settings, dest) {
|
|
35
|
+
const s = { ...(settings || {}) };
|
|
36
|
+
const hooks = (s.hooks && typeof s.hooks === 'object' && !Array.isArray(s.hooks)) ? { ...s.hooks } : {};
|
|
37
|
+
for (const e of HOOK_EVENTS) {
|
|
38
|
+
if (alreadyHas(hooks, e.event)) continue;
|
|
39
|
+
const groups = hooks[e.event] = [...(hooks[e.event] || [])];
|
|
40
|
+
groups.push({ matcher: e.matcher || '', hooks: [{ type: 'command', command: `${dest} ${e.src}`, async: true, timeout: 5 }] });
|
|
41
|
+
}
|
|
42
|
+
s.hooks = hooks;
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Pure: return a NEW settings object with all of OUR hooks removed (uninstall). An event group that ends up
|
|
47
|
+
// empty is dropped; the user's own hooks and other keys are untouched.
|
|
48
|
+
export function stripHooks(settings) {
|
|
49
|
+
const s = { ...(settings || {}) };
|
|
50
|
+
if (!s.hooks || typeof s.hooks !== 'object' || Array.isArray(s.hooks)) return s;
|
|
51
|
+
const hooks = {};
|
|
52
|
+
for (const [event, groups] of Object.entries(s.hooks)) {
|
|
53
|
+
const kept = (groups || [])
|
|
54
|
+
.map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => !(typeof h.command === 'string' && h.command.includes(HOOK_MARK))) }))
|
|
55
|
+
.filter((g) => (g.hooks || []).length > 0);
|
|
56
|
+
if (kept.length) hooks[event] = kept;
|
|
57
|
+
}
|
|
58
|
+
s.hooks = hooks;
|
|
59
|
+
return s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Path helpers (also used by the IO shell in the next task).
|
|
63
|
+
function claudeDir(home = homedir()) { return path.join(home, '.claude'); }
|
|
64
|
+
function settingsPath(home = homedir()) { return path.join(claudeDir(home), 'settings.json'); }
|
|
65
|
+
|
|
66
|
+
// 'no-claude' → ~/.claude absent (don't prompt to enable). 'installed' → our hooks present. 'absent' →
|
|
67
|
+
// Claude Code is here but our hooks aren't (offer to enable).
|
|
68
|
+
export function hooksStatus(home = homedir()) {
|
|
69
|
+
if (!fs.existsSync(claudeDir(home))) return 'no-claude';
|
|
70
|
+
let settings = {};
|
|
71
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { /* missing/corrupt → {} */ }
|
|
72
|
+
const hooks = (settings && settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)) ? settings.hooks : {};
|
|
73
|
+
return Object.keys(hooks).some((ev) => alreadyHas(hooks, ev)) ? 'installed' : 'absent';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs'];
|
|
77
|
+
|
|
78
|
+
// Atomic JSON write (tmp + rename) so a crash can't leave a half-written settings.json.
|
|
79
|
+
function writeJsonAtomic(file, obj) {
|
|
80
|
+
const tmp = `${file}.tmp`;
|
|
81
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
|
82
|
+
fs.renameSync(tmp, file);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Install (opt-in): copy the bundled hook scripts to ~/.claude/hooks/, write the env pointing at the state
|
|
86
|
+
// file, and merge our six hooks into settings.json. Returns { status }. NEVER creates ~/.claude — if it's
|
|
87
|
+
// absent the user doesn't run Claude Code, so we report 'no-claude' and do nothing.
|
|
88
|
+
// srcDir = the bundled hooks dir (server/hooks)
|
|
89
|
+
// stateFile = the unified ~/.handmux/claude-state.json path the hook writes and the server reads
|
|
90
|
+
export function installHooks(home = homedir(), { srcDir, stateFile } = {}) {
|
|
91
|
+
if (!fs.existsSync(claudeDir(home))) return { status: 'no-claude' };
|
|
92
|
+
const hooksDir = path.join(claudeDir(home), 'hooks');
|
|
93
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
94
|
+
for (const f of SCRIPTS) fs.copyFileSync(path.join(srcDir, f), path.join(hooksDir, f));
|
|
95
|
+
fs.chmodSync(path.join(hooksDir, 'handmux-notify.sh'), 0o755);
|
|
96
|
+
fs.writeFileSync(path.join(hooksDir, 'handmux-notify.env'), `HANDMUX_STATE=${stateFile}\n`, { mode: 0o600 });
|
|
97
|
+
|
|
98
|
+
const dest = path.join(hooksDir, 'handmux-notify.sh');
|
|
99
|
+
let settings = {};
|
|
100
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { /* missing/corrupt → {} */ }
|
|
101
|
+
writeJsonAtomic(settingsPath(home), mergeHooks(settings, dest));
|
|
102
|
+
return { status: 'installed' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Uninstall: strip our hooks from settings.json and remove the copied scripts/env. Best-effort on the file
|
|
106
|
+
// deletes (a missing file is fine). Leaves ~/.claude and the user's own hooks intact.
|
|
107
|
+
export function uninstallHooks(home = homedir()) {
|
|
108
|
+
let settings = {};
|
|
109
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { /* nothing to strip */ }
|
|
110
|
+
if (fs.existsSync(settingsPath(home))) writeJsonAtomic(settingsPath(home), stripHooks(settings));
|
|
111
|
+
const hooksDir = path.join(claudeDir(home), 'hooks');
|
|
112
|
+
for (const f of [...SCRIPTS, 'handmux-notify.env']) {
|
|
113
|
+
try { fs.unlinkSync(path.join(hooksDir, f)); } catch { /* already gone */ }
|
|
114
|
+
}
|
|
115
|
+
return { status: 'absent' };
|
|
116
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Pure: pull the first https://<sub>.trycloudflare.com out of a cloudflared log chunk. The quick-tunnel
|
|
2
|
+
// hostname is random per run, so scraping it from cloudflared's startup log is how the supervisor learns
|
|
3
|
+
// the public URL. Kept tiny + side-effect-free so it unit-tests against real captured log lines.
|
|
4
|
+
const RE = /https:\/\/[a-z0-9][a-z0-9-]*\.trycloudflare\.com/;
|
|
5
|
+
|
|
6
|
+
export function extractCloudflareUrl(text) {
|
|
7
|
+
const m = RE.exec(String(text || ''));
|
|
8
|
+
return m ? m[0] : null;
|
|
9
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Resolve a usable `cloudflared` binary so `--tunnel cloudflare` is truly one-click (no brew/manual
|
|
2
|
+
// install). Order: $PATH → ~/.handmux/bin/ → download the latest release for this OS/arch from GitHub.
|
|
3
|
+
// `which`/`fetchImpl` are injectable so the pure mapping (assetFor) unit-tests offline.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { pocketHome } from './state.js';
|
|
8
|
+
|
|
9
|
+
// Map Node's platform/arch to cloudflared's release asset. Linux/Windows ship a bare binary; macOS
|
|
10
|
+
// ships a .tgz that contains a `cloudflared` executable.
|
|
11
|
+
export function assetFor(platform = process.platform, arch = process.arch) {
|
|
12
|
+
const a = { x64: 'amd64', arm64: 'arm64', arm: 'arm', ia32: '386' }[arch] || arch;
|
|
13
|
+
if (platform === 'darwin') return { file: `cloudflared-darwin-${a}.tgz`, archive: 'tgz', bin: 'cloudflared' };
|
|
14
|
+
if (platform === 'win32') return { file: `cloudflared-windows-${a}.exe`, archive: null, bin: 'cloudflared.exe' };
|
|
15
|
+
return { file: `cloudflared-linux-${a}`, archive: null, bin: 'cloudflared' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function onPath(exec = 'cloudflared') {
|
|
19
|
+
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
20
|
+
const r = spawnSync(finder, [exec], { encoding: 'utf8' });
|
|
21
|
+
return r.status === 0 ? String(r.stdout).trim().split(/\r?\n/)[0] : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function resolveCloudflared(home, { which = onPath, fetchImpl, log = console } = {}) {
|
|
25
|
+
const found = which('cloudflared');
|
|
26
|
+
if (found) return found;
|
|
27
|
+
|
|
28
|
+
const dir = path.join(pocketHome(home), 'bin');
|
|
29
|
+
const asset = assetFor();
|
|
30
|
+
const dest = path.join(dir, asset.bin);
|
|
31
|
+
if (fs.existsSync(dest)) return dest;
|
|
32
|
+
|
|
33
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
34
|
+
if (!doFetch) throw new Error('no fetch available to download cloudflared (Node 18+ required)');
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset.file}`;
|
|
37
|
+
log.log?.(` downloading cloudflared (${asset.file}) …`);
|
|
38
|
+
const res = await doFetch(url, { redirect: 'follow' });
|
|
39
|
+
if (!res.ok) throw new Error(`cloudflared download failed: HTTP ${res.status} (${url})`);
|
|
40
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
41
|
+
|
|
42
|
+
if (asset.archive === 'tgz') {
|
|
43
|
+
const tmp = path.join(dir, asset.file);
|
|
44
|
+
fs.writeFileSync(tmp, buf);
|
|
45
|
+
const r = spawnSync('tar', ['xzf', tmp, '-C', dir], { encoding: 'utf8' });
|
|
46
|
+
fs.unlinkSync(tmp);
|
|
47
|
+
if (r.status !== 0) throw new Error(`failed to extract cloudflared: ${r.stderr || r.status}`);
|
|
48
|
+
} else {
|
|
49
|
+
fs.writeFileSync(dest, buf);
|
|
50
|
+
}
|
|
51
|
+
fs.chmodSync(dest, 0o755);
|
|
52
|
+
return dest;
|
|
53
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Tunnel driver registry. A driver only DESCRIBES how to expose the local server (what process to spawn,
|
|
2
|
+
// how to read the public URL out of its output); the supervisor owns process lifecycle (spawn, restart,
|
|
3
|
+
// kill). Declarative → unit-testable without spawning. matchUrl receives (chunk, cfg): cloudflare scrapes
|
|
4
|
+
// a random URL from logs; ssh / cloudflare-named already KNOW the URL (cfg.publicUrl) and only gate it on
|
|
5
|
+
// a readiness signal so the QR isn't shown before the tunnel is live.
|
|
6
|
+
import { extractCloudflareUrl } from './cloudflareUrl.js';
|
|
7
|
+
import { isTunnelConnected } from './sshTunnel.js';
|
|
8
|
+
import { cfNamedReady } from './cfNamed.js';
|
|
9
|
+
|
|
10
|
+
export const DRIVERS = {
|
|
11
|
+
none: {
|
|
12
|
+
name: 'none',
|
|
13
|
+
needsProcess: false,
|
|
14
|
+
proc: () => null,
|
|
15
|
+
matchUrl: () => null,
|
|
16
|
+
},
|
|
17
|
+
cloudflare: {
|
|
18
|
+
name: 'cloudflare',
|
|
19
|
+
needsProcess: true,
|
|
20
|
+
notFoundHint: 'cloudflared not found — install it (brew install cloudflared)',
|
|
21
|
+
proc: (cfg) => ({
|
|
22
|
+
cmd: cfg.cloudflaredBin || 'cloudflared',
|
|
23
|
+
// --grace-period 0s: drop the edge connection immediately on SIGTERM instead of draining for the 30s
|
|
24
|
+
// default, so `stop`/`restart` don't leave the tunnel lingering on Cloudflare's side (it would look
|
|
25
|
+
// "still running" remotely and overlap a restart). See supervisor.shutdown for the local-process side.
|
|
26
|
+
args: ['tunnel', '--grace-period', '0s', '--url', `http://localhost:${cfg.port}`],
|
|
27
|
+
}),
|
|
28
|
+
matchUrl: (chunk) => extractCloudflareUrl(chunk),
|
|
29
|
+
},
|
|
30
|
+
'cloudflare-named': {
|
|
31
|
+
name: 'cloudflare-named',
|
|
32
|
+
needsProcess: true,
|
|
33
|
+
notFoundHint: 'cloudflared not found — run `handmux setup` to provision the named tunnel',
|
|
34
|
+
proc: (cfg) => ({
|
|
35
|
+
cmd: cfg.cloudflaredBin || 'cloudflared',
|
|
36
|
+
// --grace-period 0s goes BEFORE the `run` subcommand (it's a `cloudflared tunnel` flag). Same reason as
|
|
37
|
+
// the quick tunnel: disconnect immediately on stop so the named tunnel doesn't linger on the edge.
|
|
38
|
+
args: ['tunnel', '--grace-period', '0s', 'run', cfg.cfTunnelName],
|
|
39
|
+
}),
|
|
40
|
+
matchUrl: (chunk, cfg) => (cfNamedReady(chunk) ? cfg.publicUrl : null),
|
|
41
|
+
},
|
|
42
|
+
ssh: {
|
|
43
|
+
name: 'ssh',
|
|
44
|
+
needsProcess: true,
|
|
45
|
+
notFoundHint: 'tunlite not found — install it (npm i -g tunlite / npx tunlite install)',
|
|
46
|
+
proc: (cfg) => ({
|
|
47
|
+
cmd: cfg.tunliteBin || 'tunlite',
|
|
48
|
+
args: ['run', '--to', cfg.sshHost, '-R', `${cfg.remotePort}:localhost:${cfg.port}`,
|
|
49
|
+
'--name', 'handmux', '--json', ...(cfg.sshJump ? ['--jump', cfg.sshJump] : [])],
|
|
50
|
+
}),
|
|
51
|
+
matchUrl: (chunk, cfg) => (isTunnelConnected(chunk) ? cfg.publicUrl : null),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function getDriver(name) {
|
|
56
|
+
const d = DRIVERS[name];
|
|
57
|
+
if (!d) throw new Error(`unknown tunnel: ${name}`);
|
|
58
|
+
return d;
|
|
59
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Pure CLI option handling: a tiny hand-rolled flag parser (no dependency) + config resolution.
|
|
2
|
+
// Resolution order is flags > config file > env > built-in default. The token is ALWAYS materialised
|
|
3
|
+
// (generated when unset) so a public tunnel can never come up token-less.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export const TUNNELS = ['none', 'cloudflare', 'cloudflare-named', 'ssh'];
|
|
7
|
+
|
|
8
|
+
const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
9
|
+
|
|
10
|
+
// argv = process.argv.slice(2). `command` is the first bare word; flags are `--key value`,
|
|
11
|
+
// `--key` (boolean true), `--no-key` (boolean false), and `-f` (alias for --foreground).
|
|
12
|
+
export function parseArgs(argv) {
|
|
13
|
+
const [command = 'help', ...rest] = argv;
|
|
14
|
+
const flags = {};
|
|
15
|
+
for (let i = 0; i < rest.length; i++) {
|
|
16
|
+
const a = rest[i];
|
|
17
|
+
if (a === '-f') { flags.foreground = true; continue; }
|
|
18
|
+
if (!a.startsWith('--')) continue;
|
|
19
|
+
const key = a.slice(2);
|
|
20
|
+
if (key.startsWith('no-')) { flags[camel(key.slice(3))] = false; continue; }
|
|
21
|
+
const next = rest[i + 1];
|
|
22
|
+
if (next === undefined || next.startsWith('--')) { flags[camel(key)] = true; }
|
|
23
|
+
else { flags[camel(key)] = next; i++; }
|
|
24
|
+
}
|
|
25
|
+
return { command, flags };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ssh 回退公网地址:SSH 不生成 URL,无 --public-url 时用 host:remotePort(去掉 user@ 与 :sshPort)。
|
|
29
|
+
export function sshPublicFallback(sshHost, remotePort) {
|
|
30
|
+
const host = String(sshHost).replace(/^[^@]*@/, '').replace(/:\d+$/, '');
|
|
31
|
+
return `http://${host}:${remotePort}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen = defaultGen) {
|
|
35
|
+
const pick = (k, ...fallbacks) => {
|
|
36
|
+
for (const v of [flags[k], fileCfg[k], ...fallbacks]) if (v !== undefined && v !== null) return v;
|
|
37
|
+
return undefined;
|
|
38
|
+
};
|
|
39
|
+
const tunnel = pick('tunnel', 'none');
|
|
40
|
+
if (!TUNNELS.includes(tunnel)) throw new Error(`unknown tunnel: ${tunnel} (use: ${TUNNELS.join(', ')})`);
|
|
41
|
+
|
|
42
|
+
const port = Number(pick('port', env.HANDMUX_PORT, 19999));
|
|
43
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`bad port: ${pick('port', env.HANDMUX_PORT, 19999)}`);
|
|
44
|
+
|
|
45
|
+
const cfg = {
|
|
46
|
+
tunnel,
|
|
47
|
+
port,
|
|
48
|
+
name: pick('name', env.HANDMUX_APP_NAME) || null,
|
|
49
|
+
host: pick('host', env.HANDMUX_HOST, '0.0.0.0'),
|
|
50
|
+
token: pick('token', env.HANDMUX_TOKEN) || gen(),
|
|
51
|
+
previewDomain: pick('previewDomain', env.HANDMUX_PREVIEW_DOMAIN) || null,
|
|
52
|
+
foreground: !!pick('foreground', false),
|
|
53
|
+
qr: pick('qr', true) !== false,
|
|
54
|
+
// Unified config — what used to live in .env. The supervisor injects these into the server child's
|
|
55
|
+
// environment (HANDMUX_STATIC_DIR / VAPID_* / XFYUN_* …), which is exactly where the server reads them.
|
|
56
|
+
staticDir: pick('staticDir', env.HANDMUX_STATIC_DIR) || null,
|
|
57
|
+
uploadExts: pick('uploadExts', env.HANDMUX_UPLOAD_EXTS) || null,
|
|
58
|
+
previewTtl: pick('previewTtl', env.HANDMUX_PREVIEW_TTL) || null,
|
|
59
|
+
vapid: fileCfg.vapid || null, // { public, private, subject } — push notifications
|
|
60
|
+
xfyun: fileCfg.xfyun || null, // { appId, apiKey, apiSecret } — voice input
|
|
61
|
+
// An explicit public URL is honoured for ANY tunnel mode — including 'none', so someone who runs their
|
|
62
|
+
// own tunnel/reverse-proxy can still have handmux advertise (print + QR) their real domain. The
|
|
63
|
+
// tunnel-specific blocks below only fill a *fallback* when it wasn't given.
|
|
64
|
+
//
|
|
65
|
+
// Guard: a publicUrl in the FILE was set for the file's tunnel, so it only carries over when the
|
|
66
|
+
// resolved tunnel still matches (or the file pins no tunnel). Otherwise a `--tunnel B` override would
|
|
67
|
+
// advertise tunnel A's URL. A flag/env publicUrl is explicit for THIS run and always wins.
|
|
68
|
+
publicUrl: resolvePublicUrl(flags, fileCfg, env, tunnel),
|
|
69
|
+
// tunnel-specific (null unless the relevant tunnel is selected)
|
|
70
|
+
sshHost: null, remotePort: null, sshJump: null,
|
|
71
|
+
cfHostname: null, cfTunnelName: null,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (tunnel === 'ssh') {
|
|
75
|
+
cfg.sshHost = pick('sshHost', env.HANDMUX_SSH_HOST) || null;
|
|
76
|
+
if (!cfg.sshHost) throw new Error('ssh tunnel needs --ssh-host user@host (or HANDMUX_SSH_HOST)');
|
|
77
|
+
const rp = Number(pick('remotePort', env.HANDMUX_REMOTE_PORT, port));
|
|
78
|
+
if (!Number.isInteger(rp) || rp < 1 || rp > 65535) throw new Error(`bad remote-port: ${pick('remotePort', env.HANDMUX_REMOTE_PORT, port)}`);
|
|
79
|
+
cfg.remotePort = rp;
|
|
80
|
+
cfg.sshJump = pick('sshJump', env.HANDMUX_SSH_JUMP) || null;
|
|
81
|
+
cfg.publicUrl = cfg.publicUrl || sshPublicFallback(cfg.sshHost, rp);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (tunnel === 'cloudflare-named') {
|
|
85
|
+
cfg.cfHostname = pick('cfHostname', env.HANDMUX_CF_HOSTNAME) || null;
|
|
86
|
+
if (!cfg.cfHostname) throw new Error('cloudflare-named needs --cf-hostname handmux.example.com (or HANDMUX_CF_HOSTNAME)');
|
|
87
|
+
cfg.cfTunnelName = pick('cfTunnelName', env.HANDMUX_CF_TUNNEL_NAME) || 'handmux';
|
|
88
|
+
cfg.publicUrl = cfg.publicUrl || `https://${cfg.cfHostname}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return cfg;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// publicUrl resolution with the cross-tunnel guard (see resolveConfig). flag > file(if tunnel matches) > env.
|
|
95
|
+
function resolvePublicUrl(flags, fileCfg, env, tunnel) {
|
|
96
|
+
if (flags.publicUrl != null) return flags.publicUrl;
|
|
97
|
+
const fileTunnel = fileCfg.tunnel;
|
|
98
|
+
const fileApplies = fileTunnel == null || fileTunnel === tunnel;
|
|
99
|
+
if (fileApplies && fileCfg.publicUrl != null) return fileCfg.publicUrl;
|
|
100
|
+
if (env.HANDMUX_PUBLIC_URL != null) return env.HANDMUX_PUBLIC_URL;
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Default token: 8 chars from a typing-friendly alphabet (lowercase + digits, look-alikes 0/o/1/l dropped)
|
|
105
|
+
// so it's quick to thumb in on a phone. A user-supplied token (flag/config/env) is used verbatim — any
|
|
106
|
+
// length, never regenerated. 8 chars over a 32-char alphabet ≈ 40 bits, fine for a single secret URL.
|
|
107
|
+
const TOKEN_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyz';
|
|
108
|
+
function defaultGen() {
|
|
109
|
+
let s = '';
|
|
110
|
+
for (let i = 0; i < 8; i++) s += TOKEN_ALPHABET[crypto.randomInt(TOKEN_ALPHABET.length)];
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Trace one key's value back to its source through the same flag > file > env > default precedence
|
|
115
|
+
// resolveConfig uses. `null`/`undefined` at a layer is "unset" (skip), matching pick(). Returns the
|
|
116
|
+
// winning value and a human origin label (the file path for a file hit, so the user sees exactly where).
|
|
117
|
+
function trace(flags, fileCfg, env, cfgPath, key, envKey, def) {
|
|
118
|
+
if (flags[key] != null) return { value: flags[key], origin: 'flag' };
|
|
119
|
+
if (fileCfg[key] != null) return { value: fileCfg[key], origin: cfgPath || 'file' };
|
|
120
|
+
if (envKey && env[envKey] != null) return { value: env[envKey], origin: 'env' };
|
|
121
|
+
return { value: def, origin: 'default' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Build the rows for `handmux config`: the value each key WOULD resolve to plus where it came from.
|
|
125
|
+
// Lenient (never throws — unlike resolveConfig — so a half-finished config still prints) and secret-safe
|
|
126
|
+
// (token masked; push/voice shown only as on/off). Tunnel-specific rows appear only for the live tunnel.
|
|
127
|
+
export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = process.env) {
|
|
128
|
+
const rows = [];
|
|
129
|
+
const mask = (s) => (String(s).length <= 8 ? '••••' : `••••${String(s).slice(-4)}`);
|
|
130
|
+
const add = (key, t, display) => rows.push({ key, origin: t.origin, display: display ?? String(t.value) });
|
|
131
|
+
|
|
132
|
+
const tunnel = trace(flags, fileCfg, env, cfgPath, 'tunnel', null, 'none');
|
|
133
|
+
add('tunnel', tunnel);
|
|
134
|
+
add('port', trace(flags, fileCfg, env, cfgPath, 'port', 'HANDMUX_PORT', 19999));
|
|
135
|
+
add('host', trace(flags, fileCfg, env, cfgPath, 'host', 'HANDMUX_HOST', '0.0.0.0'));
|
|
136
|
+
|
|
137
|
+
const name = trace(flags, fileCfg, env, cfgPath, 'name', 'HANDMUX_APP_NAME', null);
|
|
138
|
+
add('name', name, name.value == null ? '(default)' : String(name.value));
|
|
139
|
+
|
|
140
|
+
const token = trace(flags, fileCfg, env, cfgPath, 'token', 'HANDMUX_TOKEN', null);
|
|
141
|
+
add('token', token, token.value == null ? '(generated each start)' : mask(token.value));
|
|
142
|
+
|
|
143
|
+
// publicUrl honours the same cross-tunnel guard as resolveConfig (file value only when tunnel matches).
|
|
144
|
+
const t = tunnel.value;
|
|
145
|
+
let pub = trace(flags, fileCfg, env, cfgPath, 'publicUrl', 'HANDMUX_PUBLIC_URL', null);
|
|
146
|
+
if (pub.origin !== 'flag' && pub.origin !== 'env' && fileCfg.tunnel != null && fileCfg.tunnel !== t) {
|
|
147
|
+
pub = { value: null, origin: 'default' };
|
|
148
|
+
}
|
|
149
|
+
add('publicUrl', pub, pub.value == null ? '(none — derived from tunnel if any)' : String(pub.value));
|
|
150
|
+
|
|
151
|
+
const preview = trace(flags, fileCfg, env, cfgPath, 'previewDomain', 'HANDMUX_PREVIEW_DOMAIN', null);
|
|
152
|
+
add('previewDomain', preview, preview.value == null ? '(off)' : String(preview.value));
|
|
153
|
+
|
|
154
|
+
if (t === 'ssh') {
|
|
155
|
+
add('sshHost', trace(flags, fileCfg, env, cfgPath, 'sshHost', 'HANDMUX_SSH_HOST', null));
|
|
156
|
+
add('remotePort', trace(flags, fileCfg, env, cfgPath, 'remotePort', 'HANDMUX_REMOTE_PORT', '(= port)'));
|
|
157
|
+
const jump = trace(flags, fileCfg, env, cfgPath, 'sshJump', 'HANDMUX_SSH_JUMP', null);
|
|
158
|
+
add('sshJump', jump, jump.value == null ? '(none)' : String(jump.value));
|
|
159
|
+
}
|
|
160
|
+
if (t === 'cloudflare-named') {
|
|
161
|
+
add('cfHostname', trace(flags, fileCfg, env, cfgPath, 'cfHostname', 'HANDMUX_CF_HOSTNAME', null));
|
|
162
|
+
add('cfTunnelName', trace(flags, fileCfg, env, cfgPath, 'cfTunnelName', 'HANDMUX_CF_TUNNEL_NAME', 'handmux'));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Integrations live only in the file (secrets); show presence, never the keys.
|
|
166
|
+
rows.push({ key: 'push (vapid)', origin: fileCfg.vapid ? (cfgPath || 'file') : 'default', display: fileCfg.vapid ? 'on' : 'off' });
|
|
167
|
+
rows.push({ key: 'voice (xfyun)', origin: fileCfg.xfyun ? (cfgPath || 'file') : 'default', display: fileCfg.xfyun ? 'on' : 'off' });
|
|
168
|
+
return rows;
|
|
169
|
+
}
|
package/src/cli/probe.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Read-only reachability check: after the tunnel reports live, GET the public URL from THIS machine to
|
|
2
|
+
// confirm the whole chain (edge/reverse-proxy → tunnel → server) actually answers. Touches no remote
|
|
3
|
+
// config — it just fills the "tunnel connected but page won't load" blind spot.
|
|
4
|
+
export async function probe(url, { fetchImpl = globalThis.fetch, timeoutMs = 6000 } = {}) {
|
|
5
|
+
if (!url) return false;
|
|
6
|
+
const ac = new AbortController();
|
|
7
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
8
|
+
try {
|
|
9
|
+
await fetchImpl(url, { method: 'GET', redirect: 'manual', signal: ac.signal });
|
|
10
|
+
return true; // any HTTP response (even 401/404) means the chain is reachable
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
} finally {
|
|
14
|
+
clearTimeout(t);
|
|
15
|
+
}
|
|
16
|
+
}
|