handmux 0.27.2 → 0.29.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/bin/handmux.js +2 -2
- package/dist/bin/handmux.js +51 -21
- package/dist/connectors/bridgeClient.js +30 -14
- package/dist/connectors/claude/index.js +78 -23
- package/dist/hooks/handmux-write.cjs +11 -0
- package/dist/package.json +13 -13
- package/dist/public/assets/index-BjnnFTLC.css +32 -0
- package/dist/public/assets/index-D0tDAqO-.js +356 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/bridgeTransport.js +31 -4
- package/dist/src/agent-runtime/builtinRuntime.js +10 -2
- package/dist/src/agent-runtime/conversation.js +3 -3
- package/dist/src/agent-runtime/conversationStore.js +2 -2
- package/dist/src/agent-runtime/inbox.js +20 -2
- package/dist/src/agent-runtime/run.js +5 -1
- package/dist/src/agents/claudeConversation.js +69 -6
- package/dist/src/agents/claudeLocalCommand.js +54 -0
- package/dist/src/agents/claudeNativeTail.js +271 -0
- package/dist/src/agents/claudePaneInput.js +47 -0
- package/dist/src/agents/codexConversation.js +46 -0
- package/dist/src/agents/piInboxBridge.js +64 -5
- package/dist/src/agents/processIdentity.js +13 -6
- package/dist/src/apiAccountProviders.js +5 -4
- package/dist/src/browser/bootstrap.js +5 -0
- package/dist/src/browser/credentials.js +68 -0
- package/dist/src/browser/manager.js +40 -5
- package/dist/src/browser/publicProxy.js +15 -5
- package/dist/src/browser/routes.js +7 -0
- package/dist/src/browser/workerClient.js +174 -9
- package/dist/src/browser/workerServer.js +13 -1
- package/dist/src/claudeEvents.js +94 -38
- package/dist/src/cli/authCmd.js +202 -0
- package/dist/src/cli/authDefaults.js +18 -0
- package/dist/src/cli/claudeHooks.js +3 -3
- package/dist/src/cli/hookScaffold.js +5 -1
- package/dist/src/cli/i18n/en.js +66 -11
- package/dist/src/cli/i18n/zh.js +66 -11
- package/dist/src/cli/options.js +7 -13
- package/dist/src/cli/pushCmd.js +24 -11
- package/dist/src/cli/setupModel.js +28 -6
- package/dist/src/cli/setupWizard.js +47 -25
- package/dist/src/cli/shortcutEditor.js +20 -2
- package/dist/src/cli/supervisor.js +5 -7
- package/dist/src/codexAppServer.js +25 -15
- package/dist/src/codexTranscriptParse.js +40 -1
- package/dist/src/codexUsageSnapshot.js +1 -1
- package/dist/src/deviceAccess.js +134 -0
- package/dist/src/deviceAuth/control.js +154 -0
- package/dist/src/deviceAuth/http.js +321 -0
- package/dist/src/deviceAuth/service.js +626 -0
- package/dist/src/docs.js +3 -0
- package/dist/src/httpApi.js +8 -3
- package/dist/src/paneInput.js +16 -2
- package/dist/src/previewServer.js +26 -2
- package/dist/src/previews.js +37 -13
- package/dist/src/projectTask/backup.js +28 -0
- package/dist/src/projectTask/lock.js +1 -1
- package/dist/src/projectTask/migrations.js +30 -1
- package/dist/src/projectTask/runtime.js +7 -1
- package/dist/src/projectTask/schema.js +2 -2
- package/dist/src/push.js +44 -13
- package/dist/src/requestAuthority.js +8 -0
- package/dist/src/requestOrigin.js +62 -0
- package/dist/src/routes/agents.js +3 -1
- package/dist/src/routes/files.js +2 -0
- package/dist/src/routes/previews.js +9 -3
- package/dist/src/routes/push.js +55 -58
- package/dist/src/routes/system.js +4 -4
- package/dist/src/routes/terminal.js +9 -1
- package/dist/src/server.js +88 -12
- package/dist/src/terminalStream.js +90 -5
- package/dist/src/tmux/commands.js +2 -0
- package/dist/src/transcriptParse.js +4 -1
- package/dist/src/workspace/environment.js +14 -5
- package/dist/src/workspace/runtime.js +16 -1
- package/package.json +4 -4
- package/dist/public/assets/index-B4pKf3IW.js +0 -356
- package/dist/public/assets/index-Cdrh2CUp.css +0 -32
package/dist/src/cli/i18n/zh.js
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
// 中文字典。键与 en.js 一一对应;缺键会自动回退到英文。命令名、flag、隧道名等字面量保持英文(它们是要照抄输入的)。
|
|
2
2
|
export default {
|
|
3
|
+
'auth.section': '认证与安全',
|
|
4
|
+
'auth.trusted': '可信设备保护',
|
|
5
|
+
'auth.token': 'Token 登录',
|
|
6
|
+
'auth.manageHint': '保护开关只能通过 CLI 管理;设备和访问地址可在 Web 设置中查看和管理。',
|
|
7
|
+
'auth.noDevices': '还没有已授权设备。请在目标浏览器打开 Handmux,再在这台电脑运行 handmux auth device add。',
|
|
8
|
+
'auth.access': '设备授权:先在目标浏览器打开 Handmux,再在这台电脑运行 handmux auth device add,并按提示确认。',
|
|
9
|
+
'auth.warning': '⚠ 可信设备保护未开启:当前只凭 Token 即可访问,建议运行 handmux auth device on 开启。',
|
|
10
|
+
'auth.addressWarning': '⚠ 可信地址保护未开启:任何地址都可以尝试连接,建议运行 handmux auth address on 开启。',
|
|
11
|
+
'auth.deviceDisableConfirm': '⚠ 关闭可信设备保护会增加安全风险:之后只需 Token 即可访问。确认要关闭吗?',
|
|
12
|
+
'auth.addressDisableConfirm': '⚠ 关闭可信地址保护会增加安全风险:任何地址都可以尝试连接。确认要关闭吗?',
|
|
13
|
+
'auth.code': '请输入目标浏览器显示的 6 位验证码',
|
|
14
|
+
'auth.codeInvalid': '请输入正好 6 位数字。',
|
|
15
|
+
'auth.claimed': '已找到这次授权申请。请在 5 分钟内设置设备名称和有效期,确认后浏览器才能登录。',
|
|
16
|
+
'auth.origin': '访问地址',
|
|
17
|
+
'auth.name': '设备名称',
|
|
18
|
+
'auth.expire': '授权有效期',
|
|
19
|
+
'auth.custom': '自定义',
|
|
20
|
+
'auth.never': '不限期',
|
|
21
|
+
'auth.duration': '请输入有效期,例如 1h、7d、30d,或 never',
|
|
22
|
+
'auth.confirm': '确认授权这台浏览器吗?',
|
|
23
|
+
'auth.canceled': '已取消本次授权。',
|
|
24
|
+
'auth.safety': '只确认你正在添加的浏览器;不要输入别人发来的验证码。',
|
|
25
|
+
'auth.invalidName': '设备名称需为 1–80 个字符,且不能包含控制字符。',
|
|
26
|
+
'auth.invalidDuration': '请输入有效期,例如 1h、7d、30d 或 never。',
|
|
27
|
+
'auth.error.unavailable': '无法连接 Handmux 授权服务,请确认 Handmux 正在运行后重试。',
|
|
28
|
+
'auth.error.invalidCommand': '授权操作无效,请重试。',
|
|
29
|
+
'auth.error.codeInvalid': '验证码无效、已过期或已使用,请在浏览器重新申请。',
|
|
30
|
+
'auth.error.rateLimit': '验证码尝试次数过多,请稍后再试。',
|
|
31
|
+
'auth.error.pairingGone': '这次授权申请已不存在或已过期,请在浏览器重新申请验证码。',
|
|
32
|
+
'auth.error.deviceNotFound': '找不到这个设备,请先运行 handmux auth device list 查看设备 ID。',
|
|
33
|
+
'auth.error.deviceInactive': '设备已过期或解除绑定,请重新授权。',
|
|
34
|
+
'auth.error.conflict': '设备信息已被其他操作修改,请重新查看设备后再试。',
|
|
35
|
+
'auth.error.invalidEdit': '请至少提供 --name 或 --expire。',
|
|
36
|
+
'auth.error.invalidVersion': '设备信息已更新,请重新查看设备后再试。',
|
|
37
|
+
'auth.error.pairingCapacity': '待处理的授权申请太多,请稍后再试。',
|
|
38
|
+
'auth.error.tokenRequired': '请先提供 Token,再进行授权操作。',
|
|
39
|
+
'auth.error.deviceRequired': '请先授权这台浏览器,再开启可信设备保护。',
|
|
40
|
+
'auth.error.expiryCliOnly': '设备有效期只能通过服务器上的 handmux CLI 修改。',
|
|
41
|
+
'auth.error.invalidOrigin': '访问地址格式不正确,请提供完整的 http(s) 地址。',
|
|
42
|
+
'auth.error.originLimit': '可信访问地址已达到上限,请先删除一个再添加。',
|
|
43
|
+
'auth.error.sessionInvalid': '当前设备授权已失效,请重新配对。',
|
|
44
|
+
'auth.error.controlFailed': '授权操作未完成,请检查 Handmux 状态后重试。',
|
|
3
45
|
// 通用
|
|
4
46
|
'err.generic': '✗ {msg}',
|
|
5
47
|
'err.configNotFound': '✗ --config {path}:找不到该文件',
|
|
@@ -107,7 +149,7 @@ export default {
|
|
|
107
149
|
'access.pending': '(等待中…)',
|
|
108
150
|
'access.lan': ' 📶 局域网 {url}',
|
|
109
151
|
'access.local': ' 💻 本机 {url}',
|
|
110
|
-
'access.token': ' 🔑
|
|
152
|
+
'access.token': ' 🔑 Token 登录 {token}',
|
|
111
153
|
'access.reachable': ' ✓ 可访问',
|
|
112
154
|
'access.unreachable': ' ⚠ 隧道已起,但 {url} 没有响应 —— 检查服务端的反向代理 / DNS',
|
|
113
155
|
'access.hint': ' handmux status | stop',
|
|
@@ -178,6 +220,7 @@ export default {
|
|
|
178
220
|
'setup.askSshHost': 'ssh 主机(user@host[:port])',
|
|
179
221
|
'setup.askRemotePort': 'ssh 主机上的远程端口',
|
|
180
222
|
'setup.askPublicUrl': '公网地址,http(s):// 按情况填(留空 = http://host:remotePort)',
|
|
223
|
+
'setup.askDirectPublicUrl': '自定义访问地址(填你自己的反向隧道或代理地址;留空 = 仅本机 / 局域网直连)',
|
|
181
224
|
'setup.natappGuide': '去哪拿 authtoken:到 https://natapp.cn 免费注册 → 新建一条隧道 → 复制它的 authtoken(免费额度够上手)。',
|
|
182
225
|
'setup.cpolarGuide': '去哪拿 authtoken:到 https://cpolar.com 免费注册 → 打开后台 → 验证/Verify → 复制你的 authtoken。',
|
|
183
226
|
'setup.askAuthtoken': 'authtoken',
|
|
@@ -193,19 +236,19 @@ export default {
|
|
|
193
236
|
'setup.secConnection': '连接',
|
|
194
237
|
'setup.secName': '名称',
|
|
195
238
|
'setup.secPort': '端口',
|
|
196
|
-
'setup.secToken': '
|
|
239
|
+
'setup.secToken': 'Token 登录',
|
|
197
240
|
'setup.secBrowser': '网页预览器',
|
|
198
241
|
'setup.browserOff': '未配置 · 仅手机直连',
|
|
199
242
|
'setup.askBrowserDomain': '网页预览器代理域名(留空 = 仅手机直连)',
|
|
200
243
|
'setup.browserAbout': '填写代理域名(如 preview.example.com),并将其通配子域以 HTTPS 路由到 Handmux;留空则仅使用手机直连。',
|
|
201
|
-
'setup.tokenAuto': '
|
|
202
|
-
'setup.tokenCustom': '
|
|
203
|
-
'setup.tokenRandom': '
|
|
204
|
-
'setup.tokenReset': '
|
|
205
|
-
'setup.askToken': '
|
|
206
|
-
'setup.tokenGenerated': '
|
|
207
|
-
'setup.valToken': '
|
|
208
|
-
'setup.valTokenSpace': '不能有空格 ——
|
|
244
|
+
'setup.tokenAuto': '已保存(Token 不会自动更换)',
|
|
245
|
+
'setup.tokenCustom': '修改Token…',
|
|
246
|
+
'setup.tokenRandom': '生成并保存新的随机 Token',
|
|
247
|
+
'setup.tokenReset': 'Token 不会自动更换',
|
|
248
|
+
'setup.askToken': 'Token —— 与可信设备同时用于登录',
|
|
249
|
+
'setup.tokenGenerated': '新的Token:{token}',
|
|
250
|
+
'setup.valToken': '请输入Token',
|
|
251
|
+
'setup.valTokenSpace': '不能有空格 —— 请在登录页单独输入 Token',
|
|
209
252
|
'setup.secLanguage': '命令行语言 / CLI language',
|
|
210
253
|
'setup.secPush': '推送',
|
|
211
254
|
'setup.secVoice': '语音',
|
|
@@ -235,6 +278,7 @@ export default {
|
|
|
235
278
|
'setup.valRequired': '{label} 不能为空',
|
|
236
279
|
'setup.valHost': '请输入有效域名(如 myapp.example.com)',
|
|
237
280
|
'setup.valPreviewDomain': '只填域名,如 preview.example.com;不要填 http://、https://、端口或 *.',
|
|
281
|
+
'setup.valPublicUrl': '请输入完整的 http(s) 地址,例如 https://handmux.example.com(不要带路径或凭据)',
|
|
238
282
|
'setup.valContact': '请填 mailto:you@example.com 或 https://你的站点(要真实域名 —— 苹果会拒收假域名)',
|
|
239
283
|
'setup.sumTemp': '临时',
|
|
240
284
|
'setup.sumFixed': '固定',
|
|
@@ -247,6 +291,7 @@ export default {
|
|
|
247
291
|
'setup.connSshHost': 'SSH 主机',
|
|
248
292
|
'setup.connRemotePort': '远程端口',
|
|
249
293
|
'setup.connPublicUrl': '公网地址',
|
|
294
|
+
'setup.connDirectAuto': '(留空 = 本机 / 局域网直连)',
|
|
250
295
|
'setup.connJump': '跳板机',
|
|
251
296
|
'setup.connDomain': '域名',
|
|
252
297
|
'setup.connRegion': '区域',
|
|
@@ -356,6 +401,16 @@ export default {
|
|
|
356
401
|
handmux stop | restart | status
|
|
357
402
|
handmux logs [--follow] [--lines N]
|
|
358
403
|
handmux push <标题> <正文> 从脚本推一条通知到手机(--session 会话 · --device 设备key · --tag · --url)
|
|
404
|
+
handmux auth device status 查看设备保护状态和设备列表
|
|
405
|
+
handmux auth device on|off 开启或关闭设备保护
|
|
406
|
+
handmux auth device add 交互式授权浏览器(自动化使用 --code、--name 和 --expire)
|
|
407
|
+
handmux auth device list 列出完整设备 ID、名称、有效期和访问时间
|
|
408
|
+
handmux auth device edit <id> 修改设备名称或有效期
|
|
409
|
+
handmux auth device revoke <id> 立即撤销指定设备
|
|
410
|
+
handmux auth address status 查看可信地址保护状态和地址列表
|
|
411
|
+
handmux auth address on|off 开启或关闭可信地址保护
|
|
412
|
+
handmux auth address add <origin> 添加访问地址
|
|
413
|
+
handmux auth address remove <origin> 删除访问地址
|
|
359
414
|
handmux codex [参数...] 启动与对话视图同步的 Codex TUI
|
|
360
415
|
handmux pi [参数...] 原样传递参数并启动 Pi
|
|
361
416
|
handmux agent [list] 查看支持的 Agent 接入状态
|
|
@@ -383,7 +438,7 @@ start flag(括号内为对应环境变量):
|
|
|
383
438
|
--tunnel none|cloudflare|cloudflare-named|ssh|natapp|cpolar 暴露方式(默认:none)
|
|
384
439
|
--port N 服务端口(HANDMUX_PORT,默认:19999)
|
|
385
440
|
--host H 绑定地址(HANDMUX_HOST,默认:0.0.0.0)
|
|
386
|
-
--token S 鉴权令牌(HANDMUX_TOKEN
|
|
441
|
+
--token S 鉴权令牌(HANDMUX_TOKEN,默认:复用已有或首次生成)
|
|
387
442
|
--name "My Box" 浏览器标签 + 主屏图标里的应用名(HANDMUX_APP_NAME)
|
|
388
443
|
--public-url URL 对外公布的公网地址(HANDMUX_PUBLIC_URL;任意隧道均可,包括自建的 none;
|
|
389
444
|
ssh 默认 http://host:remotePort;natapp/cpolar 填你的固定/保留域名 ——
|
package/dist/src/cli/options.js
CHANGED
|
@@ -64,7 +64,7 @@ export function sshPublicFallback(sshHost, remotePort) {
|
|
|
64
64
|
const host = sshHost.replace(/^[^@]*@/, '').replace(/:\d+$/, '');
|
|
65
65
|
return `http://${host}:${remotePort}`;
|
|
66
66
|
}
|
|
67
|
-
export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen = defaultGen) {
|
|
67
|
+
export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen = defaultGen, authDefaults = {}) {
|
|
68
68
|
const pick = (key, ...fallbacks) => {
|
|
69
69
|
for (const v of [flags[key], fileCfg[key], ...fallbacks])
|
|
70
70
|
if (v !== undefined && v !== null)
|
|
@@ -82,7 +82,7 @@ export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen =
|
|
|
82
82
|
port,
|
|
83
83
|
name: optionalString(pick('name', env.HANDMUX_APP_NAME), 'name'),
|
|
84
84
|
host: optionalString(pick('host', env.HANDMUX_HOST, '0.0.0.0'), 'host') ?? '0.0.0.0',
|
|
85
|
-
token: optionalString(pick('token', env.HANDMUX_TOKEN), 'token') ?? gen(),
|
|
85
|
+
token: optionalString(pick('token', env.HANDMUX_TOKEN, authDefaults.token), 'token') ?? gen(),
|
|
86
86
|
foreground: !!pick('foreground', false),
|
|
87
87
|
qr: pick('qr', true) !== false,
|
|
88
88
|
// Unified config — what used to live in .env. The supervisor injects these into the server child's
|
|
@@ -194,15 +194,9 @@ function resolvePublicUrl(flags, fileCfg, env, tunnel) {
|
|
|
194
194
|
return env.HANDMUX_PUBLIC_URL;
|
|
195
195
|
return null;
|
|
196
196
|
}
|
|
197
|
-
//
|
|
198
|
-
// so it's quick to thumb in on a phone. A user-supplied token (flag/config/env) is used verbatim — any
|
|
199
|
-
// length, never regenerated. 8 chars over a 32-char alphabet ≈ 40 bits, fine for a single secret URL.
|
|
200
|
-
const TOKEN_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyz';
|
|
197
|
+
// 192 random bits for new credentials; configured legacy tokens remain unchanged.
|
|
201
198
|
export function defaultGen() {
|
|
202
|
-
|
|
203
|
-
for (let i = 0; i < 8; i++)
|
|
204
|
-
s += TOKEN_ALPHABET[crypto.randomInt(TOKEN_ALPHABET.length)];
|
|
205
|
-
return s;
|
|
199
|
+
return crypto.randomBytes(24).toString('base64url');
|
|
206
200
|
}
|
|
207
201
|
function trace(flags, fileCfg, env, cfgPath, key, envKey, def) {
|
|
208
202
|
if (flags[key] != null)
|
|
@@ -216,7 +210,7 @@ function trace(flags, fileCfg, env, cfgPath, key, envKey, def) {
|
|
|
216
210
|
// Build the rows for `handmux config`: the value each key WOULD resolve to plus where it came from.
|
|
217
211
|
// Lenient (never throws — unlike resolveConfig — so a half-finished config still prints) and secret-safe
|
|
218
212
|
// (token masked; push/voice shown only as on/off). Tunnel-specific rows appear only for the live tunnel.
|
|
219
|
-
export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = process.env) {
|
|
213
|
+
export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = process.env, authDefaults = {}) {
|
|
220
214
|
const rows = [];
|
|
221
215
|
const mask = (value) => (String(value).length <= 8 ? '••••' : `••••${String(value).slice(-4)}`);
|
|
222
216
|
const add = (key, traced, display) => {
|
|
@@ -230,8 +224,8 @@ export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = pr
|
|
|
230
224
|
add('name', name, name.value == null ? '(default)' : String(name.value));
|
|
231
225
|
const lang = trace(flags, fileCfg, env, cfgPath, 'lang', 'HANDMUX_LANG', null);
|
|
232
226
|
add('lang', lang, lang.value == null ? '(auto — shell locale)' : String(lang.value));
|
|
233
|
-
const token = trace(flags, fileCfg, env, cfgPath, 'token', 'HANDMUX_TOKEN', null);
|
|
234
|
-
add('token', token, token.value == null ? '(generated
|
|
227
|
+
const token = trace(flags, fileCfg, env, cfgPath, 'token', 'HANDMUX_TOKEN', authDefaults.token ?? null);
|
|
228
|
+
add('token', token, token.value == null ? '(generated on first start)' : mask(token.value));
|
|
235
229
|
// publicUrl honours the same cross-tunnel guard as resolveConfig (file value only when tunnel matches).
|
|
236
230
|
const t = tunnel.value;
|
|
237
231
|
let pub = trace(flags, fileCfg, env, cfgPath, 'publicUrl', 'HANDMUX_PUBLIC_URL', null);
|
package/dist/src/cli/pushCmd.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
// `handmux push <title> <body> [--session X]... [--device K]... [--tag T] [--url U]` — fire one
|
|
2
3
|
// notification to the phone through the already-running server (loopback + the server's own token).
|
|
3
4
|
// Scope is mutually exclusive: --device (by key) or --session, else all. Pure parse + injectable
|
|
4
5
|
// runner so it unit-tests without spawning or real fetch.
|
|
5
6
|
import { readState } from './state.js';
|
|
6
7
|
import { sanitizeNotificationUrl } from '../urlPolicy.js';
|
|
8
|
+
import { authSocketPath, connectAuthControl } from '../deviceAuth/control.js';
|
|
7
9
|
const collect = (acc, value) => acc.concat(String(value ?? '').split(',').map((item) => item.trim()).filter(Boolean));
|
|
8
10
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
11
|
const finiteNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
@@ -59,22 +61,33 @@ export async function runPush({ argv, home, fetchImpl = globalThis.fetch, log =
|
|
|
59
61
|
return 1;
|
|
60
62
|
}
|
|
61
63
|
const st = readState(home);
|
|
62
|
-
if (!st || typeof st.localUrl !== 'string' || !st.localUrl
|
|
63
|
-
|| typeof st.token !== 'string' || !st.token) {
|
|
64
|
+
if (!st || typeof st.localUrl !== 'string' || !st.localUrl || typeof st.token !== 'string' || !st.token) {
|
|
64
65
|
err('handmux is not running — run `handmux start` first.');
|
|
65
66
|
return 1;
|
|
66
67
|
}
|
|
67
68
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
69
|
+
let raw;
|
|
70
|
+
if (fs.existsSync(authSocketPath(home))) {
|
|
71
|
+
const control = await connectAuthControl(home);
|
|
72
|
+
try {
|
|
73
|
+
raw = await control.request({ op: 'push', body: parsed });
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
control.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const res = await fetchImpl(`${st.localUrl}/api/push/send-local`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { Authorization: `Bearer ${st.token}`, 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify(parsed),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
err(`push failed: ${res.status ?? 'unknown'}`);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
raw = await res.json();
|
|
76
90
|
}
|
|
77
|
-
const raw = await res.json();
|
|
78
91
|
const out = isRecord(raw) ? raw : {};
|
|
79
92
|
if (out.configured === false) {
|
|
80
93
|
err('push is not configured (no VAPID keys) — run `handmux setup`.');
|
|
@@ -75,8 +75,8 @@ export function findTunnelId(listJsonOut, name) {
|
|
|
75
75
|
// The config keys the wizard owns: everything it can set. mergeConfig wipes these from the existing config
|
|
76
76
|
// before re-applying the answers, so switching a tunnel (or clearing an optional field) cleanly drops the
|
|
77
77
|
// old value instead of leaving a stale field behind. Anything NOT here (staticDir, uploadExts…) is
|
|
78
|
-
// preserved untouched. `token` IS owned so the Token
|
|
79
|
-
// round-trips through answersFromConfig
|
|
78
|
+
// preserved untouched. `token` IS owned so the Token can be changed from
|
|
79
|
+
// its sub-page and round-trips through answersFromConfig on every setup run.
|
|
80
80
|
const WIZARD_KEYS = [
|
|
81
81
|
'lang', 'name', 'port', 'tunnel', 'token', 'previewDomain',
|
|
82
82
|
'sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl',
|
|
@@ -93,9 +93,15 @@ export function configFromAnswers(a) {
|
|
|
93
93
|
if (a.name)
|
|
94
94
|
cfg.name = a.name;
|
|
95
95
|
if (a.token)
|
|
96
|
-
cfg.token = a.token;
|
|
96
|
+
cfg.token = a.token;
|
|
97
97
|
if (a.previewDomain)
|
|
98
98
|
cfg.previewDomain = a.previewDomain;
|
|
99
|
+
// Direct mode can sit behind a user-managed reverse tunnel or proxy. Keep
|
|
100
|
+
// its public entry point in the same `publicUrl` field used by the built-in
|
|
101
|
+
// tunnel drivers so origin validation and the URL shown by `handmux start`
|
|
102
|
+
// use one source of truth.
|
|
103
|
+
if (a.tunnel === 'none' && a.publicUrl)
|
|
104
|
+
cfg.publicUrl = a.publicUrl;
|
|
99
105
|
if (a.tunnel === 'ssh') {
|
|
100
106
|
cfg.sshHost = a.sshHost;
|
|
101
107
|
cfg.remotePort = a.remotePort;
|
|
@@ -138,7 +144,7 @@ export function answersFromConfig(config = {}) {
|
|
|
138
144
|
const a = {
|
|
139
145
|
lang: optionalString(cfg.lang) || getLocale(),
|
|
140
146
|
name: optionalString(cfg.name) || '',
|
|
141
|
-
token: optionalString(cfg.token) || '',
|
|
147
|
+
token: optionalString(cfg.token) || '',
|
|
142
148
|
previewDomain: optionalString(cfg.previewDomain) || '',
|
|
143
149
|
tunnel: isTunnel(cfg.tunnel) ? cfg.tunnel : 'none',
|
|
144
150
|
port: Number(cfg.port) || 19999,
|
|
@@ -215,6 +221,21 @@ export function validatePreviewDomain(v) {
|
|
|
215
221
|
return undefined;
|
|
216
222
|
return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(s) ? undefined : t('setup.valPreviewDomain');
|
|
217
223
|
}
|
|
224
|
+
export function validatePublicUrl(v) {
|
|
225
|
+
const s = String(v || '').trim();
|
|
226
|
+
if (!s)
|
|
227
|
+
return undefined;
|
|
228
|
+
try {
|
|
229
|
+
const url = new URL(s);
|
|
230
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
|
|
231
|
+
|| url.pathname !== '/' || url.search || url.hash)
|
|
232
|
+
return t('setup.valPublicUrl');
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return t('setup.valPublicUrl');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
218
239
|
// VAPID subject: Apple (APNs) rejects a fake/.local domain with BadJwtToken, so require a real-looking
|
|
219
240
|
// mailto:you@host.tld or an https:// URL and reject the known-bad .local. Keeps push from silently
|
|
220
241
|
// failing on iOS. (Can't fully validate "real" client-side — this just catches the obvious footguns.)
|
|
@@ -226,8 +247,9 @@ export function validateContact(v) {
|
|
|
226
247
|
return t('setup.valContact');
|
|
227
248
|
return undefined;
|
|
228
249
|
}
|
|
229
|
-
//
|
|
230
|
-
//
|
|
250
|
+
// The Token is entered separately on the login page and is never put in
|
|
251
|
+
// an address or QR code. Keep the value non-empty and whitespace-free so it
|
|
252
|
+
// can be copied without ambiguity.
|
|
231
253
|
export function validateToken(v) {
|
|
232
254
|
const s = String(v || '').trim();
|
|
233
255
|
if (!s)
|
|
@@ -17,6 +17,7 @@ import { resolveNatapp, resolveCpolar } from './tunnelClients.js';
|
|
|
17
17
|
import { t, setLocale } from './i18n/index.js';
|
|
18
18
|
import { intro, outro, note, cancel, select, text, password, confirm, ask, CANCELLED } from './prompt.js';
|
|
19
19
|
import { PrivateStateStore } from '../privateStateStore.js';
|
|
20
|
+
import { installationAuthDefaults } from './authDefaults.js';
|
|
20
21
|
const errorMessage = (error) => {
|
|
21
22
|
if (error instanceof Error)
|
|
22
23
|
return error.message;
|
|
@@ -29,15 +30,15 @@ const isTunnel = (value) => (typeof value === 'string' && TUNNELS.includes(value
|
|
|
29
30
|
// The pure answer↔config model + validators + cloudflared text helpers live in setupModel.js. Imported for
|
|
30
31
|
// the interactive shell below; re-exported so their historical import path (this module) stays valid for
|
|
31
32
|
// callers and tests.
|
|
32
|
-
import { cfConfigYaml, parseTunnelCreate, findTunnelId, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, TUNNEL_KEYS, } from './setupModel.js';
|
|
33
|
-
export { cfConfigYaml, parseTunnelCreate, findTunnelId, configFromAnswers, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, } from './setupModel.js';
|
|
33
|
+
import { cfConfigYaml, parseTunnelCreate, findTunnelId, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validatePublicUrl, validateNonEmpty, validateContact, validateToken, TUNNEL_KEYS, } from './setupModel.js';
|
|
34
|
+
export { cfConfigYaml, parseTunnelCreate, findTunnelId, configFromAnswers, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validatePublicUrl, validateNonEmpty, validateContact, validateToken, } from './setupModel.js';
|
|
34
35
|
function readExisting(file) {
|
|
35
|
-
|
|
36
|
-
return new PrivateStateStore(file).readStrict() || {};
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
36
|
+
if (!fs.existsSync(file))
|
|
39
37
|
return {};
|
|
40
|
-
|
|
38
|
+
const value = new PrivateStateStore(file).readStrict();
|
|
39
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
40
|
+
throw new Error('expected a JSON object');
|
|
41
|
+
return value;
|
|
41
42
|
}
|
|
42
43
|
// The hub. Pre-fills from the existing config so a re-run edits/switches rather than starts over; a brand-
|
|
43
44
|
// new config first walks Connection, then everyone lands on the hub (edit any section, then Save/Start/Exit).
|
|
@@ -49,9 +50,20 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
49
50
|
log.error(t('setup.needTty'));
|
|
50
51
|
return null;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
let existing;
|
|
54
|
+
try {
|
|
55
|
+
existing = readExisting(target);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
log.error(t('err.badConfig', { path: target, msg: errorMessage(error) }));
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const defaults = installationAuthDefaults(home, target);
|
|
62
|
+
const { isNew } = defaults;
|
|
54
63
|
let a = answersFromConfig(existing);
|
|
64
|
+
// Token is a durable factor. If no token exists yet, mint it once and persist it in config.json.
|
|
65
|
+
if (!a.token)
|
|
66
|
+
a.token = process.env.HANDMUX_TOKEN ?? defaults.token ?? genToken();
|
|
55
67
|
setLocale(a.lang);
|
|
56
68
|
intro('handmux setup');
|
|
57
69
|
// First run lands the cursor on "Save & start" — the essential step (connection) is done in onboarding and
|
|
@@ -67,6 +79,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
67
79
|
a.lang = await editLanguage(a);
|
|
68
80
|
note(t('setup.welcome'));
|
|
69
81
|
a = await editConnection(a, { home, log });
|
|
82
|
+
await editAuth(a);
|
|
70
83
|
}
|
|
71
84
|
catch (e) {
|
|
72
85
|
if (e !== CANCELLED)
|
|
@@ -81,7 +94,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
81
94
|
{ value: 'connection', label: t('setup.secConnection'), hint: summarizeConnection(a) },
|
|
82
95
|
{ value: 'name', label: t('setup.secName'), hint: a.name || t('setup.default') },
|
|
83
96
|
{ value: 'port', label: t('setup.secPort'), hint: String(a.port) },
|
|
84
|
-
{ value: '
|
|
97
|
+
{ value: 'auth', label: t('auth.section'), hint: a.token ? maskSecret(a.token) : t('setup.tokenAuto') },
|
|
85
98
|
{ value: 'browser', label: t('setup.secBrowser'), hint: a.previewDomain || t('setup.browserOff') },
|
|
86
99
|
{ value: 'push', label: t('setup.secPush'), hint: a.vapid ? (a.vapid.subject || t('setup.on')) : t('setup.off') },
|
|
87
100
|
{
|
|
@@ -120,8 +133,8 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
120
133
|
a.name = await editName(a);
|
|
121
134
|
else if (choice === 'port')
|
|
122
135
|
a.port = await editPort(a);
|
|
123
|
-
else if (choice === '
|
|
124
|
-
|
|
136
|
+
else if (choice === 'auth')
|
|
137
|
+
await editAuth(a);
|
|
125
138
|
else if (choice === 'browser')
|
|
126
139
|
a.previewDomain = await editBrowserDomain(a);
|
|
127
140
|
else if (choice === 'language')
|
|
@@ -158,6 +171,10 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
158
171
|
// clack's footer only shows ↑/↓ + Enter, so append the Esc-backs-out hint to each section's entry prompt —
|
|
159
172
|
// otherwise a user inside a section can't tell there's a way back to the hub.
|
|
160
173
|
const withBack = (msg) => `${msg} ${t('setup.escBack')}`;
|
|
174
|
+
async function editAuth(a) {
|
|
175
|
+
note(t('auth.manageHint'));
|
|
176
|
+
a.token = await editToken(a);
|
|
177
|
+
}
|
|
161
178
|
async function editLanguage(a) {
|
|
162
179
|
const lang = await ask(select({
|
|
163
180
|
message: withBack(t('setup.langQ')),
|
|
@@ -186,22 +203,19 @@ async function editBrowserDomain(a) {
|
|
|
186
203
|
}));
|
|
187
204
|
return String(value || '').trim().toLowerCase();
|
|
188
205
|
}
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
// pre-fills the current value (so you can read it off); the hub hint masks it. Esc returns to the main hub,
|
|
193
|
-
// keeping the choice. Returns the token string ('' = auto).
|
|
206
|
+
// Token is a durable factor. A mini-hub lets the user keep the
|
|
207
|
+
// current value, replace it with a custom value, or generate a new one. The
|
|
208
|
+
// hub hint masks it and Esc returns without changing it.
|
|
194
209
|
async function editToken(a) {
|
|
195
|
-
let token = a.token ||
|
|
210
|
+
let token = a.token || genToken();
|
|
196
211
|
for (;;) {
|
|
197
212
|
let pick;
|
|
198
213
|
try {
|
|
199
214
|
pick = await ask(select({
|
|
200
215
|
message: withBack(t('setup.secToken')),
|
|
201
216
|
options: [
|
|
202
|
-
{ value: 'custom', label: t('setup.tokenCustom'), hint:
|
|
217
|
+
{ value: 'custom', label: t('setup.tokenCustom'), hint: maskSecret(token) },
|
|
203
218
|
{ value: 'random', label: t('setup.tokenRandom') },
|
|
204
|
-
{ value: 'auto', label: t('setup.tokenReset'), hint: t('setup.tokenAuto') },
|
|
205
219
|
],
|
|
206
220
|
initialValue: 'custom',
|
|
207
221
|
}));
|
|
@@ -218,8 +232,6 @@ async function editToken(a) {
|
|
|
218
232
|
token = genToken();
|
|
219
233
|
note(t('setup.tokenGenerated', { token }));
|
|
220
234
|
}
|
|
221
|
-
else if (pick === 'auto')
|
|
222
|
-
token = '';
|
|
223
235
|
}
|
|
224
236
|
catch (e) {
|
|
225
237
|
if (e !== CANCELLED)
|
|
@@ -240,12 +252,18 @@ function tunnelOptions() {
|
|
|
240
252
|
{ value: 'cpolar', label: 'cpolar', hint: t('setup.hintCpolar') },
|
|
241
253
|
];
|
|
242
254
|
}
|
|
243
|
-
// Which tunnels have config fields to edit a level deeper
|
|
244
|
-
|
|
255
|
+
// Which tunnels have config fields to edit a level deeper. Direct mode has an
|
|
256
|
+
// optional public URL for a user-managed reverse tunnel; leaving it blank is
|
|
257
|
+
// still the ordinary LAN/local direct connection.
|
|
258
|
+
const hasConnFields = (tunnel) => ['none', 'cloudflare-named', 'ssh', 'natapp', 'cpolar'].includes(tunnel);
|
|
245
259
|
// The editable field rows for the CURRENT tunnel — the type/mode is chosen a level up (the picker), so this
|
|
246
260
|
// lists ONLY that tunnel's config, values shown and secrets masked. Empty for none / cloudflare-quick.
|
|
247
261
|
function connectionFieldRows(a) {
|
|
248
262
|
const none = t('setup.connNone');
|
|
263
|
+
if (a.tunnel === 'none')
|
|
264
|
+
return [
|
|
265
|
+
{ value: 'publicUrl', label: t('setup.connPublicUrl'), hint: a.publicUrl || t('setup.connDirectAuto') },
|
|
266
|
+
];
|
|
249
267
|
if (a.tunnel === 'cloudflare-named')
|
|
250
268
|
return [
|
|
251
269
|
{ value: 'cfHostname', label: t('setup.connHostname'), hint: a.cfHostname || none },
|
|
@@ -345,7 +363,11 @@ async function editConnField(a, field) {
|
|
|
345
363
|
n.remotePort = Number(await ask(text({ message: t('setup.askRemotePort'), initialValue: String(a.remotePort || a.port), validate: validatePort })));
|
|
346
364
|
break;
|
|
347
365
|
case 'publicUrl':
|
|
348
|
-
setOpt('publicUrl', await ask(text({
|
|
366
|
+
setOpt('publicUrl', await ask(text({
|
|
367
|
+
message: t(a.tunnel === 'none' ? 'setup.askDirectPublicUrl' : 'setup.askPublicUrl'),
|
|
368
|
+
initialValue: a.publicUrl || '',
|
|
369
|
+
validate: validatePublicUrl,
|
|
370
|
+
})));
|
|
349
371
|
break;
|
|
350
372
|
case 'sshJump':
|
|
351
373
|
setOpt('sshJump', await ask(text({ message: t('setup.askSshJump'), initialValue: a.sshJump || '' })));
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import { normalizeShortcuts, shortcutIdentity } from '../shortcutConfig.js';
|
|
2
3
|
import { t } from './i18n/index.js';
|
|
3
4
|
import * as prompts from './prompt.js';
|
|
4
5
|
import { acquireLifecycleLock, isAlive, readState } from './state.js';
|
|
5
6
|
import { PrivateStateStore } from '../privateStateStore.js';
|
|
7
|
+
import { authSocketPath, connectAuthControl } from '../deviceAuth/control.js';
|
|
6
8
|
const MODIFIERS = {
|
|
7
9
|
none: { prefixes: [], labels: [] },
|
|
8
10
|
ctrl: { prefixes: ['C-'], labels: ['Ctrl'] },
|
|
@@ -281,8 +283,24 @@ export async function runShortcutEditor({ target, log = console, isTTY = process
|
|
|
281
283
|
throw error;
|
|
282
284
|
}
|
|
283
285
|
}
|
|
284
|
-
export async function applyShortcutsLive({ state, shortcuts, fetchImpl = globalThis.fetch, timeoutMs = 8000, }) {
|
|
286
|
+
export async function applyShortcutsLive({ state, shortcuts, home, fetchImpl = globalThis.fetch, timeoutMs = 8000, }) {
|
|
285
287
|
const stateRecord = recordOf(state);
|
|
288
|
+
if (home && fs.existsSync(authSocketPath(home))) {
|
|
289
|
+
if (!home)
|
|
290
|
+
throw new Error('handmux home is required for the private control socket');
|
|
291
|
+
const control = await connectAuthControl(home);
|
|
292
|
+
const timer = setTimeout(() => control.close(), timeoutMs);
|
|
293
|
+
try {
|
|
294
|
+
const acknowledgment = await control.request({ op: 'shortcuts', body: { shortcuts } });
|
|
295
|
+
if (recordOf(acknowledgment)?.ok !== true)
|
|
296
|
+
throw new Error('invalid server response');
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
control.close();
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
286
304
|
if (typeof stateRecord?.localUrl !== 'string' || !stateRecord.localUrl
|
|
287
305
|
|| typeof stateRecord.token !== 'string' || !stateRecord.token) {
|
|
288
306
|
throw new Error('running server state is incomplete');
|
|
@@ -328,7 +346,7 @@ export async function commitShortcuts({ home, target, shortcuts, acquireLock = a
|
|
|
328
346
|
if (!state || !isAliveImpl(state.supervisorPid))
|
|
329
347
|
return { cfg, running: false, applied: false };
|
|
330
348
|
try {
|
|
331
|
-
await applyShortcutsLive({ state, shortcuts: cfg.shortcuts, fetchImpl, timeoutMs });
|
|
349
|
+
await applyShortcutsLive({ state, shortcuts: cfg.shortcuts, home, fetchImpl, timeoutMs });
|
|
332
350
|
return { cfg, running: true, applied: true };
|
|
333
351
|
}
|
|
334
352
|
catch (error) {
|
|
@@ -34,11 +34,11 @@ export function lanUrl(port, ifaces = os.networkInterfaces()) {
|
|
|
34
34
|
}
|
|
35
35
|
return null;
|
|
36
36
|
}
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return
|
|
37
|
+
// Kept as a source-compatible helper for integrations that imported the old
|
|
38
|
+
// name. Credentials are deliberately never put in a URL; QR and CLI output
|
|
39
|
+
// carry only the address and the browser asks for the Token separately.
|
|
40
|
+
export function publicUrlWithToken(base, _token) {
|
|
41
|
+
return bareUrl(base);
|
|
42
42
|
}
|
|
43
43
|
// Bare address with no token in it — printed/QR-encoded so a link can be shared or screenshotted without
|
|
44
44
|
// leaking the secret; the token is shown separately for the user to paste in.
|
|
@@ -80,8 +80,6 @@ export function supervise(cfg, { home, log = console, spawnChild = defaultSpawnC
|
|
|
80
80
|
error: null,
|
|
81
81
|
};
|
|
82
82
|
const persist = () => {
|
|
83
|
-
// Keep the legacy top-level fields during migration, but derive them from the explicit component
|
|
84
|
-
// machines so `ready` can never outlive the Server process that earned it.
|
|
85
83
|
state.serverPid = components.server.pid;
|
|
86
84
|
state.tunnelPid = components.tunnel.pid;
|
|
87
85
|
state.ready = components.server.phase === 'ready';
|