handmux 0.23.0 → 0.25.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 (327) hide show
  1. package/README.md +31 -8
  2. package/README.zh-CN.md +31 -8
  3. package/bin/handmux.js +14 -688
  4. package/bin/node-version.js +20 -0
  5. package/dist/bin/handmux.js +1015 -0
  6. package/dist/connectors/bridgeClient.js +385 -0
  7. package/dist/connectors/claude/index.js +476 -0
  8. package/dist/connectors/pi/bridgeClient.js +8 -0
  9. package/dist/connectors/pi/index.js +851 -0
  10. package/dist/hooks/handmux-notify.sh +64 -0
  11. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  12. package/dist/hooks/handmux-write.cjs +264 -0
  13. package/dist/package.json +127 -0
  14. package/dist/public/assets/index-CTrqTqtT.css +32 -0
  15. package/dist/public/assets/index-DmjeMSQA.js +346 -0
  16. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  17. package/{public → dist/public}/index.html +5 -4
  18. package/dist/src/agent-runtime/adapter.js +204 -0
  19. package/dist/src/agent-runtime/bridge.js +1006 -0
  20. package/dist/src/agent-runtime/bridgeStore.js +25 -0
  21. package/dist/src/agent-runtime/bridgeTransport.js +619 -0
  22. package/dist/src/agent-runtime/bridgeTypes.js +13 -0
  23. package/dist/src/agent-runtime/builtinRuntime.js +193 -0
  24. package/dist/src/agent-runtime/conversation.js +2179 -0
  25. package/dist/src/agent-runtime/conversationActivation.js +75 -0
  26. package/dist/src/agent-runtime/conversationActivity.js +69 -0
  27. package/dist/src/agent-runtime/conversationControls.js +206 -0
  28. package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
  29. package/dist/src/agent-runtime/conversationStore.js +352 -0
  30. package/dist/src/agent-runtime/conversationTypes.js +1 -0
  31. package/dist/src/agent-runtime/conversationValidation.js +339 -0
  32. package/dist/src/agent-runtime/inbox.js +973 -0
  33. package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
  34. package/dist/src/agent-runtime/inboxStore.js +25 -0
  35. package/dist/src/agent-runtime/inboxTypes.js +1 -0
  36. package/dist/src/agent-runtime/interaction.js +829 -0
  37. package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
  38. package/dist/src/agent-runtime/interactionStore.js +18 -0
  39. package/dist/src/agent-runtime/interactionTypes.js +1 -0
  40. package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
  41. package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
  42. package/dist/src/agent-runtime/resourceTypes.js +1 -0
  43. package/dist/src/agent-runtime/resources.js +245 -0
  44. package/dist/src/agent-runtime/run.js +328 -0
  45. package/dist/src/agent-runtime/runtime.js +793 -0
  46. package/dist/src/agent-runtime/sessionControl.js +164 -0
  47. package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
  48. package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
  49. package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
  50. package/dist/src/agents/claude.js +203 -0
  51. package/dist/src/agents/claudeConversation.js +362 -0
  52. package/dist/src/agents/claudeInteraction.js +134 -0
  53. package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
  54. package/dist/src/agents/codex.js +262 -0
  55. package/dist/src/agents/codexConversation.js +1103 -0
  56. package/dist/src/agents/codexConversationActivation.js +121 -0
  57. package/dist/src/agents/codexConversationControls.js +256 -0
  58. package/dist/src/agents/codexInteraction.js +306 -0
  59. package/dist/src/agents/codexSessionControl.js +118 -0
  60. package/dist/src/agents/codexSubscriptionUsage.js +313 -0
  61. package/dist/src/agents/codexTerminalControl.js +23 -0
  62. package/dist/src/agents/conversationProjectionSafety.js +416 -0
  63. package/dist/src/agents/index.js +39 -0
  64. package/dist/src/agents/nativeInbox.js +264 -0
  65. package/dist/src/agents/nativeInboxSources.js +115 -0
  66. package/dist/src/agents/pi.js +28 -0
  67. package/dist/src/agents/piConversation.js +523 -0
  68. package/dist/src/agents/piConversationHistory.js +502 -0
  69. package/dist/src/agents/piInboxBridge.js +368 -0
  70. package/dist/src/agents/piSessionControl.js +32 -0
  71. package/dist/src/agents/processIdentity.js +94 -0
  72. package/dist/src/agents/scanUtils.js +262 -0
  73. package/dist/src/apiAccountProviders.js +194 -0
  74. package/dist/src/apiAccounts.js +592 -0
  75. package/dist/src/apiErrors.js +86 -0
  76. package/{src → dist/src}/appName.js +12 -13
  77. package/dist/src/asr/iflyConfig.js +7 -0
  78. package/dist/src/asr/iflySign.js +14 -0
  79. package/dist/src/auth.js +27 -0
  80. package/dist/src/browser/bootstrap.js +72 -0
  81. package/dist/src/browser/cookieProfiles.js +413 -0
  82. package/dist/src/browser/coordinator.js +51 -0
  83. package/dist/src/browser/hammerheadCompat.js +104 -0
  84. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  85. package/dist/src/browser/manager.js +560 -0
  86. package/dist/src/browser/originLabel.js +15 -0
  87. package/dist/src/browser/profilePersistence.js +235 -0
  88. package/dist/src/browser/publicProxy.js +176 -0
  89. package/dist/src/browser/routes.js +159 -0
  90. package/dist/src/browser/sessionStore.js +72 -0
  91. package/dist/src/browser/siteVersion.js +121 -0
  92. package/dist/src/browser/targetPolicy.js +163 -0
  93. package/{src → dist/src}/browser/worker.js +14 -18
  94. package/dist/src/browser/workerClient.js +286 -0
  95. package/dist/src/browser/workerServer.js +91 -0
  96. package/dist/src/captureBackground.js +95 -0
  97. package/dist/src/claudeEvents.js +525 -0
  98. package/dist/src/cli/agentIntegration.js +131 -0
  99. package/dist/src/cli/agentLauncher.js +26 -0
  100. package/dist/src/cli/claudeHooks.js +290 -0
  101. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  102. package/dist/src/cli/cloudflared.js +106 -0
  103. package/dist/src/cli/codexManaged.js +80 -0
  104. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  105. package/dist/src/cli/drivers.js +98 -0
  106. package/dist/src/cli/hookScaffold.js +39 -0
  107. package/dist/src/cli/i18n/en.js +392 -0
  108. package/{src → dist/src}/cli/i18n/index.js +27 -25
  109. package/dist/src/cli/i18n/zh.js +391 -0
  110. package/dist/src/cli/legacyCodexHooks.js +52 -0
  111. package/{src → dist/src}/cli/natappUrl.js +2 -3
  112. package/dist/src/cli/options.js +245 -0
  113. package/dist/src/cli/piExtension.js +155 -0
  114. package/dist/src/cli/probe.js +16 -0
  115. package/{src → dist/src}/cli/prompt.js +4 -6
  116. package/dist/src/cli/pushCmd.js +102 -0
  117. package/{src → dist/src}/cli/qr.js +19 -19
  118. package/dist/src/cli/service.js +154 -0
  119. package/dist/src/cli/setupModel.js +194 -0
  120. package/dist/src/cli/setupWizard.js +647 -0
  121. package/dist/src/cli/shortcutEditor.js +351 -0
  122. package/dist/src/cli/sshTunnel.js +18 -0
  123. package/dist/src/cli/state.js +117 -0
  124. package/dist/src/cli/statusLine.js +115 -0
  125. package/dist/src/cli/supervisor.js +278 -0
  126. package/dist/src/cli/supervisorHealth.js +21 -0
  127. package/dist/src/cli/supervisorLaunch.js +148 -0
  128. package/dist/src/cli/supervisorProcesses.js +62 -0
  129. package/dist/src/cli/supervisorState.js +62 -0
  130. package/dist/src/cli/tmuxVersion.js +53 -0
  131. package/{src → dist/src}/cli/tunlite.js +7 -10
  132. package/dist/src/cli/tunnelClients.js +72 -0
  133. package/{src → dist/src}/cli/updateCheck.js +98 -69
  134. package/{src → dist/src}/cli/urlHost.js +8 -4
  135. package/dist/src/cli/workspaceCmd.js +347 -0
  136. package/dist/src/codexAppServer.js +3436 -0
  137. package/dist/src/codexConversationProjection.js +199 -0
  138. package/dist/src/codexDiff.js +118 -0
  139. package/dist/src/codexMessageIdentity.js +18 -0
  140. package/dist/src/codexPlan.js +30 -0
  141. package/dist/src/codexQueueProtocol.js +137 -0
  142. package/dist/src/codexStreamProtocol.js +207 -0
  143. package/dist/src/codexToolProtocol.js +68 -0
  144. package/dist/src/codexTranscriptParse.js +835 -0
  145. package/dist/src/codexUsageSnapshot.js +228 -0
  146. package/dist/src/config.js +8 -0
  147. package/{src → dist/src}/docPath.js +22 -24
  148. package/dist/src/docs.js +337 -0
  149. package/dist/src/git.js +284 -0
  150. package/dist/src/healthProtocol.js +97 -0
  151. package/dist/src/httpApi.js +96 -0
  152. package/dist/src/jsonStore.js +15 -0
  153. package/{src → dist/src}/keyNames.js +3 -3
  154. package/dist/src/notifications.js +111 -0
  155. package/dist/src/orphans.js +167 -0
  156. package/dist/src/paneInput.js +42 -0
  157. package/dist/src/pendingPrompt.js +194 -0
  158. package/dist/src/previewServer.js +202 -0
  159. package/dist/src/previews.js +179 -0
  160. package/dist/src/privateStateStore.js +124 -0
  161. package/dist/src/projectTask/backup.js +224 -0
  162. package/dist/src/projectTask/lock.js +108 -0
  163. package/dist/src/projectTask/migrations.js +90 -0
  164. package/dist/src/projectTask/routes.js +155 -0
  165. package/dist/src/projectTask/runtime.js +182 -0
  166. package/dist/src/projectTask/schema.js +56 -0
  167. package/dist/src/projectTask/store.js +457 -0
  168. package/dist/src/push.js +224 -0
  169. package/dist/src/routes/agents.js +631 -0
  170. package/dist/src/routes/apiAccounts.js +82 -0
  171. package/dist/src/routes/files.js +230 -0
  172. package/dist/src/routes/git.js +89 -0
  173. package/dist/src/routes/health.js +16 -0
  174. package/dist/src/routes/notifications.js +16 -0
  175. package/dist/src/routes/previews.js +43 -0
  176. package/dist/src/routes/push.js +145 -0
  177. package/dist/src/routes/sessions.js +244 -0
  178. package/dist/src/routes/system.js +220 -0
  179. package/dist/src/routes/terminal.js +244 -0
  180. package/dist/src/routes/workspace.js +267 -0
  181. package/dist/src/server.js +315 -0
  182. package/dist/src/shortcutConfig.js +83 -0
  183. package/{src → dist/src}/staticCache.js +5 -3
  184. package/{src → dist/src}/staticCompression.js +5 -6
  185. package/dist/src/terminalStream.js +441 -0
  186. package/{src → dist/src}/tmux/commands.js +185 -172
  187. package/dist/src/tmux/format.js +47 -0
  188. package/dist/src/transcriptParse.js +261 -0
  189. package/dist/src/transcriptReader.js +209 -0
  190. package/{src → dist/src}/trimCapture.js +11 -11
  191. package/dist/src/uploadTypes.js +31 -0
  192. package/dist/src/urlPolicy.js +18 -0
  193. package/dist/src/usagePaths.js +15 -0
  194. package/dist/src/workspace/agentRunner.js +301 -0
  195. package/dist/src/workspace/atomicJson.js +31 -0
  196. package/dist/src/workspace/capture.js +139 -0
  197. package/dist/src/workspace/checkpointer.js +228 -0
  198. package/dist/src/workspace/environment.js +71 -0
  199. package/dist/src/workspace/lock.js +137 -0
  200. package/dist/src/workspace/mapping.js +128 -0
  201. package/dist/src/workspace/operations.js +271 -0
  202. package/dist/src/workspace/paths.js +15 -0
  203. package/dist/src/workspace/planner.js +292 -0
  204. package/dist/src/workspace/restore.js +363 -0
  205. package/dist/src/workspace/runtime.js +318 -0
  206. package/dist/src/workspace/schema.js +156 -0
  207. package/dist/src/workspace/store.js +464 -0
  208. package/dist/src/workspace/tmuxAdapter.js +704 -0
  209. package/dist/src/workspaceProtocol.js +158 -0
  210. package/package.json +30 -17
  211. package/hooks/handmux-notify.sh +0 -22
  212. package/hooks/handmux-write.cjs +0 -78
  213. package/public/assets/index-C3N7OkyP.css +0 -32
  214. package/public/assets/index-D1DWcC5u.js +0 -337
  215. package/src/.gitkeep +0 -0
  216. package/src/agents/claude.js +0 -159
  217. package/src/agents/codex.js +0 -168
  218. package/src/agents/index.js +0 -21
  219. package/src/agents/processIdentity.js +0 -72
  220. package/src/agents/scanUtils.js +0 -225
  221. package/src/asr/iflyConfig.js +0 -10
  222. package/src/asr/iflySign.js +0 -16
  223. package/src/auth.js +0 -30
  224. package/src/browser/bootstrap.js +0 -68
  225. package/src/browser/cookieProfiles.js +0 -403
  226. package/src/browser/coordinator.js +0 -47
  227. package/src/browser/hammerheadCompat.js +0 -86
  228. package/src/browser/hammerheadRedirectCompat.js +0 -38
  229. package/src/browser/manager.js +0 -556
  230. package/src/browser/originLabel.js +0 -17
  231. package/src/browser/profilePersistence.js +0 -241
  232. package/src/browser/publicProxy.js +0 -175
  233. package/src/browser/routes.js +0 -154
  234. package/src/browser/sessionStore.js +0 -78
  235. package/src/browser/siteVersion.js +0 -124
  236. package/src/browser/targetPolicy.js +0 -137
  237. package/src/browser/workerClient.js +0 -253
  238. package/src/browser/workerServer.js +0 -98
  239. package/src/captureBackground.js +0 -93
  240. package/src/claudeEvents.js +0 -399
  241. package/src/cli/claudeHooks.js +0 -206
  242. package/src/cli/cloudflared.js +0 -99
  243. package/src/cli/codexManaged.js +0 -77
  244. package/src/cli/drivers.js +0 -97
  245. package/src/cli/hookScaffold.js +0 -33
  246. package/src/cli/i18n/en.js +0 -385
  247. package/src/cli/i18n/zh.js +0 -384
  248. package/src/cli/legacyCodexHooks.js +0 -49
  249. package/src/cli/options.js +0 -211
  250. package/src/cli/probe.js +0 -16
  251. package/src/cli/pushCmd.js +0 -60
  252. package/src/cli/service.js +0 -126
  253. package/src/cli/setupModel.js +0 -154
  254. package/src/cli/setupWizard.js +0 -506
  255. package/src/cli/shortcutEditor.js +0 -288
  256. package/src/cli/sshTunnel.js +0 -12
  257. package/src/cli/state.js +0 -90
  258. package/src/cli/statusLine.js +0 -90
  259. package/src/cli/supervisor.js +0 -183
  260. package/src/cli/supervisorProcesses.js +0 -57
  261. package/src/cli/tmuxVersion.js +0 -49
  262. package/src/cli/tunnelClients.js +0 -62
  263. package/src/cli/workspaceCmd.js +0 -300
  264. package/src/codexAppServer.js +0 -1620
  265. package/src/codexDiff.js +0 -108
  266. package/src/codexTranscriptParse.js +0 -413
  267. package/src/codexUsageSnapshot.cjs +0 -129
  268. package/src/config.js +0 -9
  269. package/src/docs.js +0 -228
  270. package/src/git.js +0 -208
  271. package/src/httpApi.js +0 -72
  272. package/src/jsonStore.js +0 -24
  273. package/src/notifications.js +0 -68
  274. package/src/orphans.js +0 -152
  275. package/src/pendingPrompt.js +0 -163
  276. package/src/previewServer.js +0 -168
  277. package/src/previews.js +0 -116
  278. package/src/push.js +0 -183
  279. package/src/routes/codex.js +0 -473
  280. package/src/routes/files.js +0 -172
  281. package/src/routes/git.js +0 -57
  282. package/src/routes/notifications.js +0 -19
  283. package/src/routes/previews.js +0 -35
  284. package/src/routes/push.js +0 -119
  285. package/src/routes/sessions.js +0 -168
  286. package/src/routes/system.js +0 -132
  287. package/src/routes/terminal.js +0 -210
  288. package/src/routes/transcript.js +0 -171
  289. package/src/routes/workspace.js +0 -267
  290. package/src/server.js +0 -186
  291. package/src/shortcutConfig.js +0 -75
  292. package/src/terminalStream.js +0 -391
  293. package/src/tmux/format.js +0 -45
  294. package/src/transcriptParse.js +0 -180
  295. package/src/transcriptReader.js +0 -101
  296. package/src/uploadTypes.js +0 -30
  297. package/src/urlPolicy.js +0 -14
  298. package/src/usage.js +0 -260
  299. package/src/workspace/agentRunner.js +0 -188
  300. package/src/workspace/atomicJson.js +0 -32
  301. package/src/workspace/capture.js +0 -139
  302. package/src/workspace/checkpointer.js +0 -187
  303. package/src/workspace/environment.js +0 -75
  304. package/src/workspace/lock.js +0 -128
  305. package/src/workspace/mapping.js +0 -124
  306. package/src/workspace/operations.js +0 -252
  307. package/src/workspace/paths.js +0 -16
  308. package/src/workspace/planner.js +0 -247
  309. package/src/workspace/restore.js +0 -279
  310. package/src/workspace/runtime.js +0 -314
  311. package/src/workspace/schema.js +0 -136
  312. package/src/workspace/store.js +0 -382
  313. package/src/workspace/tmuxAdapter.js +0 -554
  314. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  315. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  316. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  317. /package/{public → dist/public}/icons/badge-96.png +0 -0
  318. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  319. /package/{public → dist/public}/icons/icon-192.png +0 -0
  320. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  321. /package/{public → dist/public}/icons/icon-512.png +0 -0
  322. /package/{public → dist/public}/icons/logo.svg +0 -0
  323. /package/{public → dist/public}/manifest.webmanifest +0 -0
  324. /package/{public → dist/public}/offline.html +0 -0
  325. /package/{public → dist/public}/sw.js +0 -0
  326. /package/{src → dist/src}/browser/protocol.js +0 -0
  327. /package/{src → dist/src}/cli/cfNamed.js +0 -0
@@ -1,506 +0,0 @@
1
- // `handmux setup` — a menu HUB (not a linear wizard): every setting is a row showing its current value;
2
- // you arrow to a section to edit just that, then return to the hub; a Save/Start/Exit action row ends it.
3
- // First run auto-dives into Connection once (the one choice a newcomer must make), then lands on the hub.
4
- // The interactive shell is thin glue over @clack/prompts (via ./prompt.js); the pure mappers below
5
- // (configFromAnswers / mergeConfig / validators / summarizeConnection / answersFromConfig) are unit-tested.
6
-
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import { homedir } from 'node:os';
10
- import { spawnSync } from 'node:child_process';
11
- import webpush from 'web-push';
12
- import { configPath, pocketHome } from './state.js';
13
- import { defaultGen as genToken } from './options.js';
14
- import { resolveCloudflared } from './cloudflared.js';
15
- import { resolveTunlite, checkSshAuth } from './tunlite.js';
16
- import { resolveNatapp, resolveCpolar } from './tunnelClients.js';
17
- import { t, setLocale } from './i18n/index.js';
18
- import { intro, outro, note, cancel, select, text, password, confirm, ask, CANCELLED } from './prompt.js';
19
-
20
- // The pure answer↔config model + validators + cloudflared text helpers live in setupModel.js. Imported for
21
- // the interactive shell below; re-exported so their historical import path (this module) stays valid for
22
- // callers and tests.
23
- import {
24
- cfConfigYaml, parseTunnelCreate, findTunnelId,
25
- mergeConfig, answersFromConfig, summarizeConnection,
26
- validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken,
27
- TUNNEL_KEYS,
28
- } from './setupModel.js';
29
- export {
30
- cfConfigYaml, parseTunnelCreate, findTunnelId,
31
- configFromAnswers, mergeConfig, answersFromConfig, summarizeConnection,
32
- validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken,
33
- } from './setupModel.js';
34
-
35
- function readExisting(file) {
36
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
37
- }
38
-
39
- // The hub. Pre-fills from the existing config so a re-run edits/switches rather than starts over; a brand-
40
- // new config first walks Connection, then everyone lands on the hub (edit any section, then Save/Start/Exit).
41
- // Returns { cfg, start } (start = the user chose the save-and-run action), or null on cancel/exit. When an
42
- // instance is already `running`, that action reads "Save & restart" (the caller stop+starts to apply the new
43
- // config). `home` and the write `target` are injectable for tests / `--config`.
44
- export async function runSetup({ home = homedir(), target = configPath(home), log = console, running = false } = {}) {
45
- if (!process.stdin.isTTY) { log.error(t('setup.needTty')); return null; }
46
- const existing = readExisting(target);
47
- const isNew = !existing || Object.keys(existing).length === 0;
48
- let a = answersFromConfig(existing);
49
- setLocale(a.lang);
50
-
51
- intro('handmux setup');
52
- // First run lands the cursor on "Save & start" — the essential step (connection) is done in onboarding and
53
- // everything else is optional, so a newcomer sees they can just finish. A re-run starts on the first row so
54
- // you jump straight to what you came to change.
55
- let cursor = isNew ? 'start' : 'connection';
56
- try {
57
- if (isNew) {
58
- // First-run onboarding: pick language, orient the newcomer, then ask the ONE thing they must decide —
59
- // how the phone reaches this machine — going straight to that picker (not the field mini-hub). Esc here
60
- // drops to the hub with safe defaults, so "Esc goes back" holds everywhere.
61
- try {
62
- a.lang = await editLanguage(a);
63
- note(t('setup.welcome'));
64
- a = await editConnection(a, { home, log });
65
- } catch (e) { if (e !== CANCELLED) throw e; }
66
- }
67
- for (;;) {
68
- // Esc / Ctrl-C AT THE HUB (or during first-run onboarding) exits setup — caught by the outer try.
69
- const choice = await ask(select({
70
- message: t('setup.hubTitle'),
71
- options: [
72
- { value: 'connection', label: t('setup.secConnection'), hint: summarizeConnection(a) },
73
- { value: 'name', label: t('setup.secName'), hint: a.name || t('setup.default') },
74
- { value: 'port', label: t('setup.secPort'), hint: String(a.port) },
75
- { value: 'token', label: t('setup.secToken'), hint: a.token ? maskSecret(a.token) : t('setup.tokenAuto') },
76
- { value: 'browser', label: t('setup.secBrowser'), hint: a.previewDomain || t('setup.browserOff') },
77
- { value: 'push', label: t('setup.secPush'), hint: a.vapid ? (a.vapid.subject || t('setup.on')) : t('setup.off') },
78
- { value: 'voice', label: t('setup.secVoice'), hint: a.xfyun ? (a.xfyun.appId || t('setup.on')) : t('setup.off') },
79
- // A CLI-tool preference (language of handmux's own terminal output), not an app setting — so it
80
- // sits at the bottom of the settings, just above the actions.
81
- { value: 'language', label: t('setup.secLanguage'), hint: a.lang === 'zh' ? '中文' : 'English' },
82
- { value: 'start', label: running ? t('setup.actRestart') : t('setup.actStart') },
83
- { value: 'save', label: t('setup.actSave') },
84
- { value: 'exit', label: t('setup.actExit') },
85
- ],
86
- initialValue: cursor,
87
- }));
88
- if (choice === 'exit') { cancel(t('setup.exited')); return null; }
89
- if (choice === 'save' || choice === 'start') {
90
- const cfg = mergeConfig(existing, a);
91
- fs.mkdirSync(path.dirname(target), { recursive: true });
92
- fs.writeFileSync(target, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
93
- outro(t('setup.wrote', { path: target }));
94
- if (a.tunnel === 'ssh') printSshServerHelp(a, log);
95
- return { cfg, start: choice === 'start' };
96
- }
97
- cursor = choice; // remember the row so returning from an edit re-highlights it
98
- // Esc / Ctrl-C INSIDE a section backs out to the hub, leaving that section unchanged.
99
- try {
100
- if (choice === 'connection') a = await editConnection(a, { home, log });
101
- else if (choice === 'name') a.name = await editName(a);
102
- else if (choice === 'port') a.port = await editPort(a);
103
- else if (choice === 'token') a.token = await editToken(a);
104
- else if (choice === 'browser') a.previewDomain = await editBrowserDomain(a);
105
- else if (choice === 'language') a.lang = await editLanguage(a);
106
- else if (choice === 'push') a.vapid = await editPush(a);
107
- else if (choice === 'voice') a.xfyun = await editVoice(a);
108
- } catch (e) {
109
- if (e !== CANCELLED) throw e;
110
- }
111
- }
112
- } catch (e) {
113
- if (e === CANCELLED) { cancel(t('setup.exited')); return null; }
114
- throw e;
115
- }
116
- }
117
-
118
- // clack's footer only shows ↑/↓ + Enter, so append the Esc-backs-out hint to each section's entry prompt —
119
- // otherwise a user inside a section can't tell there's a way back to the hub.
120
- const withBack = (msg) => `${msg} ${t('setup.escBack')}`;
121
-
122
- async function editLanguage(a) {
123
- const lang = await ask(select({
124
- message: withBack(t('setup.langQ')),
125
- options: [{ value: 'en', label: 'English' }, { value: 'zh', label: '中文' }],
126
- initialValue: a.lang === 'zh' ? 'zh' : 'en',
127
- }));
128
- setLocale(lang); // apply immediately so the rest of the hub speaks the chosen language
129
- return lang;
130
- }
131
-
132
- async function editName(a) {
133
- // initialValue puts the current name IN the editable field (defaultValue only fills a blank submit, unseen).
134
- const v = await ask(text({ message: withBack(t('setup.askName')), placeholder: t('setup.default'), initialValue: a.name || '' }));
135
- return (v || '').trim();
136
- }
137
-
138
- async function editPort(a) {
139
- const v = await ask(text({ message: withBack(t('setup.askPort')), initialValue: String(a.port), validate: validatePort }));
140
- return Number(v);
141
- }
142
-
143
- async function editBrowserDomain(a) {
144
- note(t('setup.browserAbout'));
145
- const value = await ask(text({
146
- message: withBack(t('setup.askBrowserDomain')),
147
- placeholder: 'preview.example.com',
148
- initialValue: a.previewDomain || '',
149
- validate: validatePreviewDomain,
150
- }));
151
- return String(value || '').trim().toLowerCase();
152
- }
153
-
154
- // The access token — the one secret in the phone's URL. Unset = the server mints a fresh one each start
155
- // (printed + QR'd), so the link changes every restart; pinning one keeps the same URL. A mini-hub (like
156
- // push/voice): type your own, generate + pin a strong random one, or reset back to auto. Editing custom
157
- // pre-fills the current value (so you can read it off); the hub hint masks it. Esc returns to the main hub,
158
- // keeping the choice. Returns the token string ('' = auto).
159
- async function editToken(a) {
160
- let token = a.token || '';
161
- for (;;) {
162
- let pick;
163
- try {
164
- pick = await ask(select({
165
- message: withBack(t('setup.secToken')),
166
- options: [
167
- { value: 'custom', label: t('setup.tokenCustom'), hint: token ? maskSecret(token) : t('setup.tokenAuto') },
168
- { value: 'random', label: t('setup.tokenRandom') },
169
- { value: 'auto', label: t('setup.tokenReset'), hint: t('setup.tokenAuto') },
170
- ],
171
- initialValue: 'custom',
172
- }));
173
- } catch (e) { if (e === CANCELLED) return token; throw e; } // back to the main hub, keeping the choice
174
- try {
175
- if (pick === 'custom') token = (await ask(text({ message: t('setup.askToken'), initialValue: token, validate: validateToken }))).trim();
176
- else if (pick === 'random') { token = genToken(); note(t('setup.tokenGenerated', { token })); }
177
- else if (pick === 'auto') token = '';
178
- } catch (e) { if (e !== CANCELLED) throw e; } // Esc in a sub-edit → back to the mini-hub
179
- }
180
- }
181
-
182
- const bareHost = (u) => String(u || '').replace(/^https?:\/\//, '');
183
- // cloudflare's two drivers are ONE family in the UI: 'cloudflare' (quick, no login) and 'cloudflare-named'
184
- // (your domain, needs a login) are picked as a mode INSIDE cloudflare, not as two separate tunnels.
185
- const cfFamily = (tunnel) => (tunnel === 'cloudflare-named' ? 'cloudflare' : tunnel);
186
-
187
- function tunnelOptions() {
188
- return [
189
- { value: 'none', label: t('setup.tunnelNone'), hint: t('setup.hintNone') },
190
- { value: 'cloudflare', label: 'cloudflare', hint: t('setup.hintCf') },
191
- { value: 'ssh', label: 'ssh (tunlite)', hint: t('setup.hintSsh') },
192
- { value: 'natapp', label: 'natapp', hint: t('setup.hintNatapp') },
193
- { value: 'cpolar', label: 'cpolar', hint: t('setup.hintCpolar') },
194
- ];
195
- }
196
-
197
- // Which tunnels have config fields to edit a level deeper (none/cloudflare-quick have nothing to configure).
198
- const hasConnFields = (tunnel) => ['cloudflare-named', 'ssh', 'natapp', 'cpolar'].includes(tunnel);
199
-
200
- // The editable field rows for the CURRENT tunnel — the type/mode is chosen a level up (the picker), so this
201
- // lists ONLY that tunnel's config, values shown and secrets masked. Empty for none / cloudflare-quick.
202
- function connectionFieldRows(a) {
203
- const none = t('setup.connNone');
204
- if (a.tunnel === 'cloudflare-named') return [
205
- { value: 'cfHostname', label: t('setup.connHostname'), hint: a.cfHostname || none },
206
- { value: 'cfTunnelName', label: t('setup.connTunnelName'), hint: a.cfTunnelName || 'handmux' },
207
- ];
208
- if (a.tunnel === 'ssh') return [
209
- { value: 'sshHost', label: t('setup.connSshHost'), hint: a.sshHost || none },
210
- { value: 'remotePort', label: t('setup.connRemotePort'), hint: String(a.remotePort || a.port) },
211
- { value: 'publicUrl', label: t('setup.connPublicUrl'), hint: a.publicUrl || t('setup.connAuto') },
212
- { value: 'sshJump', label: t('setup.connJump'), hint: a.sshJump || none },
213
- ];
214
- if (a.tunnel === 'natapp' || a.tunnel === 'cpolar') {
215
- const rows = [
216
- { value: 'authtoken', label: 'authtoken', hint: a.authtoken ? maskSecret(a.authtoken) : none },
217
- { value: 'domain', label: t('setup.connDomain'), hint: a.publicUrl ? bareHost(a.publicUrl) : t('setup.domainTemp') },
218
- ];
219
- if (a.tunnel === 'cpolar') rows.push({ value: 'cpolarRegion', label: t('setup.connRegion'), hint: a.cpolarRegion || t('setup.default') });
220
- return rows;
221
- }
222
- return [];
223
- }
224
-
225
- // cloudflare mode: temporary quick tunnel (no login) vs a named tunnel on your own domain (needs login).
226
- async function editCfMode(a) {
227
- const named = await ask(select({
228
- message: t('setup.cfModeQ'),
229
- options: [
230
- { value: false, label: t('setup.cfTemp'), hint: t('setup.cfTempHint') },
231
- { value: true, label: t('setup.cfNamed'), hint: t('setup.cfNamedHint') },
232
- ],
233
- initialValue: a.tunnel === 'cloudflare-named',
234
- }));
235
- const n = { ...a };
236
- if (named) {
237
- n.tunnel = 'cloudflare-named';
238
- n.cfHostname = await ask(text({ message: t('setup.askHostname'), initialValue: a.cfHostname || '', validate: validateHost }));
239
- n.cfTunnelName = (await ask(text({ message: t('setup.askTunnelName'), initialValue: a.cfTunnelName || 'handmux' }))) || 'handmux';
240
- } else {
241
- n.tunnel = 'cloudflare';
242
- delete n.cfHostname; delete n.cfTunnelName;
243
- }
244
- return n;
245
- }
246
-
247
- // Level 1 — choose the tunnel type (and, for cloudflare, temporary vs named). On a real change, clear the old
248
- // fields and collect the new type's REQUIRED field(s) so a half-set tunnel never reaches the field hub.
249
- // Returns the answers (unchanged if you re-pick the same type — you still drop into its field hub).
250
- async function pickTunnel(a) {
251
- // Default the cursor to the current tunnel; a brand-new user starts on `none` — the zero-config core that
252
- // just works on the same Wi-Fi. Tunnels are opt-in help for the user's own "reach it from outside" problem.
253
- const start = cfFamily(a.tunnel);
254
- const picked = await ask(select({ message: withBack(t('setup.tunnelQ')), options: tunnelOptions(), initialValue: start }));
255
- const base = { ...a };
256
- for (const k of TUNNEL_KEYS) delete base[k];
257
- // Staying in the cloudflare family keeps its fields as defaults; arriving from another tunnel starts clean.
258
- if (picked === 'cloudflare') return editCfMode(cfFamily(a.tunnel) === 'cloudflare' ? a : base);
259
- if (picked === start) return a; // unchanged type → keep fields, go straight to the field hub
260
- base.tunnel = picked;
261
- if (picked === 'ssh') {
262
- base.sshHost = await ask(text({ message: t('setup.askSshHost'), defaultValue: '', validate: validateNonEmpty('ssh host') }));
263
- } else if (picked === 'natapp' || picked === 'cpolar') {
264
- note(t(picked === 'natapp' ? 'setup.natappGuide' : 'setup.cpolarGuide'));
265
- base.authtoken = await ask(password({ message: t('setup.askAuthtoken'), validate: validateNonEmpty('authtoken') }));
266
- }
267
- return base;
268
- }
269
-
270
- // Edit one Connection field in place (from its mini-hub row).
271
- async function editConnField(a, field) {
272
- const n = { ...a };
273
- const setOpt = (k, v) => { if (v) n[k] = v; else delete n[k]; };
274
- switch (field) {
275
- // initialValue pre-fills the field with the current value so you edit in place (and can just Enter to keep
276
- // it); an empty submit clears the optional ones (setOpt). Secrets use password() — never pre-filled.
277
- case 'cfHostname': n.cfHostname = await ask(text({ message: t('setup.askHostname'), initialValue: a.cfHostname || '', validate: validateHost })); break;
278
- case 'cfTunnelName': n.cfTunnelName = (await ask(text({ message: t('setup.askTunnelName'), initialValue: a.cfTunnelName || 'handmux' }))) || 'handmux'; break;
279
- case 'sshHost': n.sshHost = await ask(text({ message: t('setup.askSshHost'), initialValue: a.sshHost || '', validate: validateNonEmpty('ssh host') })); break;
280
- case 'remotePort': n.remotePort = Number(await ask(text({ message: t('setup.askRemotePort'), initialValue: String(a.remotePort || a.port), validate: validatePort }))); break;
281
- case 'publicUrl': setOpt('publicUrl', await ask(text({ message: t('setup.askPublicUrl'), initialValue: a.publicUrl || '' }))); break;
282
- case 'sshJump': setOpt('sshJump', await ask(text({ message: t('setup.askSshJump'), initialValue: a.sshJump || '' }))); break;
283
- case 'authtoken': n.authtoken = await ask(password({ message: t('setup.askAuthtoken'), validate: validateNonEmpty('authtoken') })); break;
284
- case 'cpolarRegion': setOpt('cpolarRegion', await ask(text({ message: t('setup.askCpolarRegion'), initialValue: a.cpolarRegion || '' }))); break;
285
- case 'domain': {
286
- const fixed = await ask(select({
287
- message: t('setup.domainQ'),
288
- options: [{ value: false, label: t('setup.domainTemp'), hint: t('setup.domainTempHint') }, { value: true, label: t('setup.domainFixed') }],
289
- initialValue: !!a.publicUrl,
290
- }));
291
- if (fixed) n.publicUrl = await ask(text({ message: t(a.tunnel === 'natapp' ? 'setup.askNatappDomain' : 'setup.askCpolarDomain'), initialValue: a.publicUrl || '', validate: validateHost }));
292
- else delete n.publicUrl;
293
- break;
294
- }
295
- }
296
- return n;
297
- }
298
-
299
- // Run the tunnel's provisioning (browser login / key setup / client download) — idempotent, so it's safe to
300
- // run once on the way OUT of the Connection mini-hub whenever something changed.
301
- async function provisionConnection(a, { home, log }) {
302
- if (a.tunnel === 'cloudflare-named') await provisionCloudflareNamed({ home, hostname: a.cfHostname, tunnelName: a.cfTunnelName || 'handmux', port: a.port, log });
303
- else if (a.tunnel === 'ssh') await provisionSsh({ sshHost: a.sshHost, log });
304
- else if (a.tunnel === 'natapp' || a.tunnel === 'cpolar') await provisionNgrokClient({ tunnel: a.tunnel, home, authtoken: a.authtoken, log });
305
- }
306
-
307
- // Level 2 — the field hub for the chosen tunnel: each config field is a row showing its value (secrets
308
- // masked); edit any in place. Esc leaves the field hub. Returns the updated answers.
309
- async function editTunnelFields(a) {
310
- let next = { ...a };
311
- let cur;
312
- for (;;) {
313
- let pick;
314
- try {
315
- pick = await ask(select({ message: withBack(summarizeConnection(next)), options: connectionFieldRows(next), initialValue: cur }));
316
- } catch (e) {
317
- if (e !== CANCELLED) throw e;
318
- return next;
319
- }
320
- cur = pick;
321
- try { next = await editConnField(next, pick); }
322
- catch (e) { if (e !== CANCELLED) throw e; } // Esc in a sub-edit → back to the field hub
323
- }
324
- }
325
-
326
- // Connection is TWO levels: FIRST pick the tunnel type (and, for cloudflare, temporary vs named) — level 1;
327
- // THEN that tunnel's config fields appear inside it — level 2. Provisioning (browser login / key setup /
328
- // client download) runs once on the way out, only if something changed. Esc at either level → main hub.
329
- async function editConnection(a, ctx) {
330
- const before = JSON.stringify(a);
331
- let next;
332
- try {
333
- next = await pickTunnel(a); // level 1: type / mode / required fields
334
- } catch (e) { if (e !== CANCELLED) throw e; return a; }
335
- if (hasConnFields(next.tunnel)) {
336
- try { next = await editTunnelFields(next); } // level 2: this tunnel's fields
337
- catch (e) { if (e !== CANCELLED) throw e; }
338
- }
339
- if (JSON.stringify(next) !== before) await provisionConnection(next, ctx);
340
- return next;
341
- }
342
-
343
- // Push notifications need a VAPID keypair. If one already exists we offer to keep it (regenerating would
344
- // invalidate every existing phone subscription); otherwise we generate one on the spot. Returns the vapid
345
- // object, or undefined to leave push off.
346
- // Localise clack's Yes/No toggle for every confirm().
347
- const yesno = () => ({ active: t('setup.yes'), inactive: t('setup.no') });
348
- // A masked preview of an already-set secret for a sub-menu hint (last 4 shown, like `handmux config`).
349
- const maskSecret = (s) => (String(s || '').length <= 8 ? '••••' : `••••${String(s).slice(-4)}`);
350
-
351
- // Composite sections (push/voice) are their OWN mini-hub: when already configured, show the current values
352
- // and let you edit/regenerate/turn-off in place — no "keep it? [y/n]" gate, matching how every other row
353
- // shows its value and edits directly. Esc in the mini-hub returns to the main hub (keeping edits); Esc in a
354
- // sub-edit returns to the mini-hub. Push needs a VAPID keypair, so turning it ON is a genuine one-shot setup.
355
- async function editPush(a) {
356
- let vapid = a.vapid;
357
- if (!vapid) {
358
- let on;
359
- try { on = await ask(confirm({ message: withBack(t('setup.pushSetup')), initialValue: false, ...yesno() })); }
360
- catch (e) { if (e === CANCELLED) return undefined; throw e; }
361
- if (!on) return undefined;
362
- note(t('setup.pushAbout'));
363
- const subject = await ask(text({ message: t('setup.pushContact'), initialValue: 'mailto:admin@example.com', validate: validateContact }));
364
- const { publicKey, privateKey } = webpush.generateVAPIDKeys();
365
- note(t('setup.pushGenerated'));
366
- vapid = { public: publicKey, private: privateKey, subject };
367
- }
368
- for (;;) {
369
- let pick;
370
- try {
371
- pick = await ask(select({
372
- message: withBack(t('setup.secPush')),
373
- options: [
374
- { value: 'contact', label: t('setup.pushContactLabel'), hint: vapid.subject || '' },
375
- { value: 'regen', label: t('setup.pushRegen'), hint: t('setup.pushRegenHint') },
376
- { value: 'off', label: t('setup.pushOff') },
377
- ],
378
- initialValue: 'contact',
379
- }));
380
- } catch (e) { if (e === CANCELLED) return vapid; throw e; } // back to the main hub, keeping edits
381
- if (pick === 'off') return undefined;
382
- try {
383
- if (pick === 'contact') vapid = { ...vapid, subject: await ask(text({ message: t('setup.pushContact'), initialValue: vapid.subject || '', validate: validateContact })) };
384
- else if (pick === 'regen') {
385
- const ok = await ask(confirm({ message: t('setup.pushRegenConfirm'), initialValue: false, ...yesno() }));
386
- if (ok) {
387
- const k = webpush.generateVAPIDKeys();
388
- vapid = { ...vapid, public: k.publicKey, private: k.privateKey };
389
- note(t('setup.pushRegenerated'));
390
- }
391
- }
392
- } catch (e) { if (e !== CANCELLED) throw e; } // Esc in a sub-edit → back to the mini-hub
393
- }
394
- }
395
-
396
- // Voice input (iFlytek/xfyun) — three credentials; appId is shown/edited in the clear, the two secrets show
397
- // only a masked preview and are replaced (never revealed) when edited.
398
- async function editVoice(a) {
399
- let x = a.xfyun;
400
- if (!x) {
401
- let on;
402
- try { on = await ask(confirm({ message: withBack(t('setup.voiceSetup')), initialValue: false, ...yesno() })); }
403
- catch (e) { if (e === CANCELLED) return undefined; throw e; }
404
- if (!on) return undefined;
405
- const appId = await ask(text({ message: t('setup.voiceAppId'), validate: validateNonEmpty('appId') }));
406
- const apiKey = await ask(password({ message: t('setup.voiceApiKey'), validate: validateNonEmpty('apiKey') }));
407
- const apiSecret = await ask(password({ message: t('setup.voiceApiSecret'), validate: validateNonEmpty('apiSecret') }));
408
- x = { appId, apiKey, apiSecret };
409
- }
410
- for (;;) {
411
- let pick;
412
- try {
413
- pick = await ask(select({
414
- message: withBack(t('setup.secVoice')),
415
- options: [
416
- { value: 'appId', label: 'appId', hint: x.appId || '' },
417
- { value: 'apiKey', label: 'apiKey', hint: maskSecret(x.apiKey) },
418
- { value: 'apiSecret', label: 'apiSecret', hint: maskSecret(x.apiSecret) },
419
- { value: 'off', label: t('setup.voiceOff') },
420
- ],
421
- initialValue: 'appId',
422
- }));
423
- } catch (e) { if (e === CANCELLED) return x; throw e; }
424
- if (pick === 'off') return undefined;
425
- try {
426
- if (pick === 'appId') x = { ...x, appId: await ask(text({ message: t('setup.voiceAppId'), initialValue: x.appId || '', validate: validateNonEmpty('appId') })) };
427
- else if (pick === 'apiKey') x = { ...x, apiKey: await ask(password({ message: t('setup.voiceApiKey'), validate: validateNonEmpty('apiKey') })) };
428
- else if (pick === 'apiSecret') x = { ...x, apiSecret: await ask(password({ message: t('setup.voiceApiSecret'), validate: validateNonEmpty('apiSecret') })) };
429
- } catch (e) { if (e !== CANCELLED) throw e; }
430
- }
431
- }
432
-
433
- // login (browser) → create → route dns → write config.yml. The only human step is the browser login.
434
- async function provisionCloudflareNamed({ home, hostname, tunnelName, port, log }) {
435
- const bin = await resolveCloudflared(home);
436
- const cfDir = path.join(home, '.cloudflared');
437
- if (!fs.existsSync(path.join(cfDir, 'cert.pem'))) {
438
- log.log(t('setup.cfLogin'));
439
- spawnSync(bin, ['tunnel', 'login'], { stdio: 'inherit' });
440
- }
441
- // Idempotent: reuse the tunnel if it already exists (re-running setup, or after a stop), else create it.
442
- // Without this, `tunnel create` errors "already exists" and you'd have to keep renaming — leaving orphan
443
- // tunnels piling up in the Cloudflare account.
444
- const listed = spawnSync(bin, ['tunnel', 'list', '--output', 'json'], { encoding: 'utf8' });
445
- let id = findTunnelId(listed.stdout, tunnelName);
446
- let credentialsFile = null;
447
- if (id) {
448
- log.log(t('setup.cfReuse', { name: tunnelName, id }));
449
- credentialsFile = path.join(cfDir, `${id}.json`);
450
- if (!fs.existsSync(credentialsFile)) {
451
- log.error(t('setup.cfCredMissing1', { file: credentialsFile }));
452
- log.error(t('setup.cfCredMissing2', { bin, name: tunnelName }));
453
- }
454
- } else {
455
- log.log(t('setup.cfCreate', { name: tunnelName }));
456
- const created = spawnSync(bin, ['tunnel', 'create', tunnelName], { encoding: 'utf8' });
457
- process.stdout.write(created.stdout || ''); process.stderr.write(created.stderr || '');
458
- const parsed = parseTunnelCreate(`${created.stdout || ''}\n${created.stderr || ''}`);
459
- id = parsed.id;
460
- credentialsFile = parsed.credentialsFile;
461
- }
462
- log.log(t('setup.cfRoute', { host: hostname }));
463
- // --overwrite-dns: re-running setup (or pointing a hostname already routed) must not error on the DNS step.
464
- const routed = spawnSync(bin, ['tunnel', 'route', 'dns', '--overwrite-dns', tunnelName, hostname], { encoding: 'utf8' });
465
- if (routed.status !== 0) {
466
- process.stderr.write(routed.stderr || '');
467
- log.error(t('setup.cfRouteFail', { domain: hostname.split('.').slice(-2).join('.') }));
468
- log.error(t('setup.cfRouteFail2'));
469
- }
470
- fs.mkdirSync(cfDir, { recursive: true });
471
- fs.writeFileSync(path.join(cfDir, 'config.yml'),
472
- cfConfigYaml({ tunnelName, credentialsFile: credentialsFile || path.join(cfDir, `${id || tunnelName}.json`), hostname, port }));
473
- log.log(t('setup.wrote', { path: path.join(cfDir, 'config.yml') }));
474
- }
475
-
476
- // drive tunlite passwordless setup inline (one password) if not already set up.
477
- async function provisionSsh({ sshHost, log }) {
478
- const bin = resolveTunlite();
479
- if (checkSshAuth(sshHost, { bin }) === 0) { log.log(t('setup.sshReady')); return; }
480
- log.log(t('setup.sshSetup', { host: sshHost }));
481
- spawnSync(bin, ['setup-key', sshHost], { stdio: 'inherit' });
482
- }
483
-
484
- // Get the client binary ready (cpolar auto-downloads; natapp must be pre-installed) and, for cpolar, seed
485
- // the authtoken into its config. NON-FATAL: if the binary isn't there yet we print the hint and still write
486
- // the config, so the user can install it later and just `handmux start` — the wizard never dead-ends here.
487
- async function provisionNgrokClient({ tunnel, home, authtoken, log }) {
488
- try {
489
- if (tunnel === 'cpolar') {
490
- const bin = await resolveCpolar(home);
491
- if (authtoken) spawnSync(bin, ['authtoken', authtoken], { stdio: 'ignore' });
492
- log.log(t('setup.cpolarReady'));
493
- } else {
494
- resolveNatapp(home);
495
- log.log(t('setup.natappReady'));
496
- }
497
- } catch (e) { log.log(t('err.generic', { msg: e.message })); }
498
- }
499
-
500
- function printSshServerHelp(a, log) {
501
- log.log('');
502
- log.log(t('setup.sshHelp1'));
503
- log.log(t('setup.sshHelpNginx', { port: a.remotePort }));
504
- log.log(t('setup.sshHelpCaddy', { url: a.publicUrl || '<your-domain>', port: a.remotePort }));
505
- log.log('');
506
- }