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
@@ -0,0 +1,154 @@
1
+ // Boot/login autostart. The OS keeps the handmux SUPERVISOR (`__supervise`) alive; the supervisor
2
+ // keeps server + tunnel alive — same model as a foreground run, just parented by launchd/systemd.
3
+ // The service definition receives only a private supervisor-config path. Secrets stay out of argv, plist,
4
+ // and unit text. Text generators are pure (unit-tested); the launchctl/systemctl calls inject `exec`.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { pocketHome, logPath } from './state.js';
9
+ import { ensurePrivateDirectorySync } from '../privateStateStore.js';
10
+ export const LABEL = 'com.handmux.agent';
11
+ export const UNIT = 'handmux.service';
12
+ const defaultExec = (command, args, options) => {
13
+ const result = options.encoding
14
+ ? spawnSync(command, [...args], { encoding: options.encoding })
15
+ : spawnSync(command, [...args], { stdio: options.stdio });
16
+ return { status: result.status, stderr: result.stderr };
17
+ };
18
+ const failureDetail = (result) => String(result.stderr || result.status);
19
+ export function plistPath(home) { return path.join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`); }
20
+ export function unitPath(home) { return path.join(home, '.config', 'systemd', 'user', UNIT); }
21
+ export function isServiceInstalled(home, platform = process.platform) {
22
+ if (platform === 'darwin')
23
+ return fs.existsSync(plistPath(home));
24
+ if (platform === 'linux')
25
+ return fs.existsSync(unitPath(home));
26
+ return false;
27
+ }
28
+ const xmlEscape = (value) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
29
+ // launchd does not inherit the interactive shell PATH. Persist the caller's PATH so the supervised Server
30
+ // resolves the same npm/Homebrew/nvm/volta Agent executables that `handmux start` could resolve.
31
+ // args = full argv for the process (node, script, __supervise, --payload-file, path).
32
+ export function plistFor({ args, log, label = LABEL, pathEnv }) {
33
+ const items = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
34
+ const environment = pathEnv ? `
35
+ <key>EnvironmentVariables</key>
36
+ <dict>
37
+ <key>PATH</key><string>${xmlEscape(pathEnv)}</string>
38
+ </dict>` : '';
39
+ return `<?xml version="1.0" encoding="UTF-8"?>
40
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
41
+ <plist version="1.0">
42
+ <dict>
43
+ <key>Label</key><string>${label}</string>
44
+ <key>ProgramArguments</key>
45
+ <array>
46
+ ${items}
47
+ </array>
48
+ ${environment}
49
+ <key>RunAtLoad</key><true/>
50
+ <key>KeepAlive</key><true/>
51
+ <key>StandardOutPath</key><string>${xmlEscape(log)}</string>
52
+ <key>StandardErrorPath</key><string>${xmlEscape(log)}</string>
53
+ </dict>
54
+ </plist>
55
+ `;
56
+ }
57
+ // systemd --user may likewise start with a manager PATH rather than the invoking shell's. ExecStart needs
58
+ // a single command line; args are space-joined (our args have no spaces except an absolute path with none
59
+ // in practice — quote the script path defensively).
60
+ export function unitFor({ args, pathEnv }) {
61
+ const cmd = args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ');
62
+ const environment = pathEnv ? `Environment=${JSON.stringify(`PATH=${pathEnv}`)}\n` : '';
63
+ return `[Unit]
64
+ Description=handmux — drive your tmux from your phone
65
+ After=network-online.target
66
+
67
+ [Service]
68
+ ExecStart=${cmd}
69
+ ${environment}Restart=always
70
+ RestartSec=2
71
+
72
+ [Install]
73
+ WantedBy=default.target
74
+ `;
75
+ }
76
+ export function installService(args, { home, platform = process.platform, exec = defaultExec, log = console, pathEnv = process.env.PATH, }) {
77
+ ensurePrivateDirectorySync(pocketHome(home));
78
+ const runtimeLog = logPath(home);
79
+ const descriptor = fs.openSync(runtimeLog, 'a', 0o600);
80
+ fs.closeSync(descriptor);
81
+ fs.chmodSync(runtimeLog, 0o600);
82
+ if (platform === 'darwin') {
83
+ const p = plistPath(home);
84
+ fs.mkdirSync(path.dirname(p), { recursive: true });
85
+ fs.writeFileSync(p, plistFor({ args, log: runtimeLog, ...(pathEnv ? { pathEnv } : {}) }), { mode: 0o600 });
86
+ fs.chmodSync(p, 0o600);
87
+ exec('launchctl', ['unload', p], { stdio: 'ignore' }); // best-effort: clear any prior load
88
+ const r = exec('launchctl', ['load', '-w', p], { encoding: 'utf8' });
89
+ if (r.status !== 0)
90
+ throw new Error(`launchctl load failed: ${failureDetail(r)}`);
91
+ log.log?.(`installed launchd agent: ${p}`);
92
+ return p;
93
+ }
94
+ if (platform === 'linux') {
95
+ const p = unitPath(home);
96
+ fs.mkdirSync(path.dirname(p), { recursive: true });
97
+ fs.writeFileSync(p, unitFor({ args, ...(pathEnv ? { pathEnv } : {}) }), { mode: 0o600 });
98
+ fs.chmodSync(p, 0o600);
99
+ exec('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
100
+ const enabled = exec('systemctl', ['--user', 'enable', UNIT], { encoding: 'utf8' });
101
+ if (enabled.status !== 0)
102
+ throw new Error(`systemctl enable failed: ${failureDetail(enabled)}`);
103
+ // `enable --now` does not restart an already-active unit. Always restart after rewriting ExecStart so
104
+ // an npm/brew upgrade (new CLI/Node path) and a changed baked config take effect immediately.
105
+ const restarted = exec('systemctl', ['--user', 'restart', UNIT], { encoding: 'utf8' });
106
+ if (restarted.status !== 0)
107
+ throw new Error(`systemctl restart failed: ${failureDetail(restarted)}`);
108
+ log.log?.(`installed systemd --user unit: ${p}`);
109
+ log.log?.('(for autostart before login: loginctl enable-linger "$USER")');
110
+ return p;
111
+ }
112
+ throw new Error(`autostart not supported on ${platform} yet`);
113
+ }
114
+ // Stop the currently-loaded service without removing/disable-ing its login registration. This is what the
115
+ // ordinary `handmux stop` command needs: stay stopped for this login session, then start normally again at
116
+ // the next login/boot. In particular, do NOT SIGTERM the supervisor directly while KeepAlive/Restart=always
117
+ // is active — the service manager would immediately resurrect it and race a second manual supervisor.
118
+ export function stopService({ home, platform = process.platform, exec = defaultExec }) {
119
+ if (platform === 'darwin') {
120
+ const r = exec('launchctl', ['unload', plistPath(home)], { encoding: 'utf8' });
121
+ // unload returns non-zero when it was already unloaded; `stop` stays idempotent.
122
+ return r.status === 0;
123
+ }
124
+ if (platform === 'linux') {
125
+ const r = exec('systemctl', ['--user', 'stop', UNIT], { encoding: 'utf8' });
126
+ if (r.status !== 0)
127
+ throw new Error(`systemctl stop failed: ${failureDetail(r)}`);
128
+ return true;
129
+ }
130
+ throw new Error(`autostart not supported on ${platform} yet`);
131
+ }
132
+ export function uninstallService({ home, platform = process.platform, exec = defaultExec, log = console }) {
133
+ if (platform === 'darwin') {
134
+ const p = plistPath(home);
135
+ exec('launchctl', ['unload', '-w', p], { stdio: 'ignore' });
136
+ try {
137
+ fs.unlinkSync(p);
138
+ }
139
+ catch { /* already gone */ }
140
+ log.log?.(`removed launchd agent: ${p}`);
141
+ return;
142
+ }
143
+ if (platform === 'linux') {
144
+ exec('systemctl', ['--user', 'disable', '--now', UNIT], { stdio: 'ignore' });
145
+ try {
146
+ fs.unlinkSync(unitPath(home));
147
+ }
148
+ catch { /* already gone */ }
149
+ exec('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
150
+ log.log?.(`removed systemd --user unit: ${unitPath(home)}`);
151
+ return;
152
+ }
153
+ throw new Error(`autostart not supported on ${platform} yet`);
154
+ }
@@ -0,0 +1,194 @@
1
+ // Pure model for `handmux setup`: the answer↔config mappers, the wizard-owned key sets, the connection
2
+ // summary, the cloudflared provisioning text helpers, and the clack validators. Extracted from the
3
+ // interactive shell (setupWizard.ts) so the tested, side-effect-free logic stands on its own. Depends only
4
+ // on i18n (t/getLocale) for user-facing strings — otherwise deterministic given its inputs.
5
+ import { t, getLocale } from './i18n/index.js';
6
+ import { TUNNELS } from './options.js';
7
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ const isTunnel = (value) => typeof value === 'string' && TUNNELS.includes(value);
9
+ const optionalString = (value) => typeof value === 'string' && value ? value : undefined;
10
+ const stringObject = (value, keys) => {
11
+ if (!isRecord(value))
12
+ return undefined;
13
+ const result = {};
14
+ for (const key of keys)
15
+ if (typeof value[key] === 'string')
16
+ result[key] = value[key];
17
+ return result;
18
+ };
19
+ // ~/.cloudflared/config.yml for a named tunnel: route the hostname to the local handmux port.
20
+ export function cfConfigYaml({ tunnelName, credentialsFile, hostname, port, }) {
21
+ return [
22
+ `tunnel: ${tunnelName}`,
23
+ `credentials-file: ${credentialsFile}`,
24
+ 'ingress:',
25
+ ` - hostname: ${hostname}`,
26
+ ` service: http://localhost:${port}`,
27
+ ' - service: http_status:404',
28
+ '',
29
+ ].join('\n');
30
+ }
31
+ // Extract the tunnel UUID + credentials path from `cloudflared tunnel create <name>` stdout.
32
+ export function parseTunnelCreate(out) {
33
+ const s = String(out || '');
34
+ const id = (s.match(/Created tunnel \S+ with id ([0-9a-fA-F-]+)/) || [])[1] || null;
35
+ const credentialsFile = (s.match(/credentials written to (\S+\.json)/i) || [])[1]?.replace(/\.$/, '') || null;
36
+ return { id, credentialsFile };
37
+ }
38
+ // Find an existing named tunnel's UUID in `cloudflared tunnel list --output json`. A named tunnel is a
39
+ // PERSISTENT object in the Cloudflare account, so a second `setup` would hit `cloudflared tunnel create`'s
40
+ // "tunnel with name X already exists" error — which used to force a rename and pile up junk tunnels in the
41
+ // account. Looking it up first lets provisioning REUSE it idempotently. Tolerates non-JSON / errors → null.
42
+ export function findTunnelId(listJsonOut, name) {
43
+ let arr;
44
+ try {
45
+ arr = JSON.parse(String(listJsonOut || ''));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ if (!Array.isArray(arr))
51
+ return null;
52
+ const hit = arr.find((candidate) => isRecord(candidate) && candidate.name === name);
53
+ return isRecord(hit) && typeof hit.id === 'string' && hit.id ? hit.id : null;
54
+ }
55
+ // The config keys the wizard owns: everything it can set. mergeConfig wipes these from the existing config
56
+ // before re-applying the answers, so switching a tunnel (or clearing an optional field) cleanly drops the
57
+ // old value instead of leaving a stale field behind. Anything NOT here (staticDir, uploadExts…) is
58
+ // preserved untouched. `token` IS owned so the Token row can pin one AND clear it back to auto — but it
59
+ // round-trips through answersFromConfig, so a re-run that never touches the row still writes it back.
60
+ const WIZARD_KEYS = [
61
+ 'lang', 'name', 'port', 'tunnel', 'token', 'previewDomain',
62
+ 'sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl',
63
+ 'authtoken', 'cpolarRegion',
64
+ 'vapid', 'xfyun',
65
+ ];
66
+ // The tunnel-specific keys, cleared whenever the tunnel changes so a switch never carries a stale field.
67
+ export const TUNNEL_KEYS = ['sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl', 'authtoken', 'cpolarRegion'];
68
+ // Wizard answers → the config fragment the user actually set (omit empty optional fields).
69
+ export function configFromAnswers(a) {
70
+ const cfg = { tunnel: a.tunnel, port: a.port };
71
+ if (a.lang)
72
+ cfg.lang = a.lang;
73
+ if (a.name)
74
+ cfg.name = a.name;
75
+ if (a.token)
76
+ cfg.token = a.token; // blank = don't pin one → the server mints a fresh token each start
77
+ if (a.previewDomain)
78
+ cfg.previewDomain = a.previewDomain;
79
+ if (a.tunnel === 'ssh') {
80
+ cfg.sshHost = a.sshHost;
81
+ cfg.remotePort = a.remotePort;
82
+ if (a.publicUrl)
83
+ cfg.publicUrl = a.publicUrl;
84
+ if (a.sshJump)
85
+ cfg.sshJump = a.sshJump;
86
+ }
87
+ if (a.tunnel === 'cloudflare-named') {
88
+ cfg.cfHostname = a.cfHostname;
89
+ cfg.cfTunnelName = a.cfTunnelName;
90
+ }
91
+ if (a.tunnel === 'natapp' || a.tunnel === 'cpolar') {
92
+ if (a.authtoken)
93
+ cfg.authtoken = a.authtoken;
94
+ if (a.publicUrl)
95
+ cfg.publicUrl = a.publicUrl; // fixed/reserved domain (blank = temporary random)
96
+ if (a.cpolarRegion)
97
+ cfg.cpolarRegion = a.cpolarRegion;
98
+ }
99
+ if (a.vapid)
100
+ cfg.vapid = a.vapid;
101
+ if (a.xfyun)
102
+ cfg.xfyun = a.xfyun;
103
+ return cfg;
104
+ }
105
+ // Fold this run's answers into an existing config: preserve every non-wizard field, replace the wizard's
106
+ // own fields wholesale. This is why re-running `setup` to switch tunnels (or edit the name) never drops
107
+ // your token / push keys / static dir, yet also never leaves the previous tunnel's stale keys around.
108
+ export function mergeConfig(existing = {}, answers) {
109
+ const out = isRecord(existing) ? { ...existing } : {};
110
+ for (const k of WIZARD_KEYS)
111
+ delete out[k];
112
+ return { ...out, ...configFromAnswers(answers) };
113
+ }
114
+ // Seed the working answers from an existing config so the hub shows current values and each edit starts
115
+ // from what's already there. A brand-new config yields safe defaults (none/LAN, port 19999).
116
+ export function answersFromConfig(config = {}) {
117
+ const cfg = isRecord(config) ? config : {};
118
+ const a = {
119
+ lang: optionalString(cfg.lang) || getLocale(),
120
+ name: optionalString(cfg.name) || '',
121
+ token: optionalString(cfg.token) || '', // '' = not pinned (auto each start); seeded so an untouched re-run rewrites it
122
+ previewDomain: optionalString(cfg.previewDomain) || '',
123
+ tunnel: isTunnel(cfg.tunnel) ? cfg.tunnel : 'none',
124
+ port: Number(cfg.port) || 19999,
125
+ };
126
+ const stringKeys = ['sshHost', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl', 'authtoken', 'cpolarRegion'];
127
+ for (const key of stringKeys) {
128
+ const value = optionalString(cfg[key]);
129
+ if (value)
130
+ a[key] = value;
131
+ }
132
+ if (typeof cfg.remotePort === 'number' && Number.isFinite(cfg.remotePort))
133
+ a.remotePort = cfg.remotePort;
134
+ const vapid = stringObject(cfg.vapid, ['public', 'private', 'subject']);
135
+ const xfyun = stringObject(cfg.xfyun, ['appId', 'apiKey', 'apiSecret']);
136
+ if (vapid)
137
+ a.vapid = vapid;
138
+ if (xfyun)
139
+ a.xfyun = xfyun;
140
+ return a;
141
+ }
142
+ // The dim one-line summary shown next to the Connection row (and reused nowhere else). Pure.
143
+ export function summarizeConnection(a) {
144
+ const bare = (url) => String(url || '').replace(/^https?:\/\//, '');
145
+ switch (a.tunnel) {
146
+ case 'cloudflare': return `cloudflare · ${t('setup.sumTemp')}`;
147
+ case 'cloudflare-named': return `cloudflare-named · ${a.cfHostname || '?'}`;
148
+ case 'ssh': return `ssh · ${a.sshHost || '?'}`;
149
+ case 'natapp':
150
+ case 'cpolar': return `${a.tunnel} · ${a.publicUrl ? `${t('setup.sumFixed')} ${bare(a.publicUrl)}` : t('setup.sumTemp')}`;
151
+ default: return `${t('setup.tunnelNone')} · ${t('setup.sumNoRelay')}`;
152
+ }
153
+ }
154
+ // ---- validators (clack: return a string to reject + show inline, undefined to accept). Pure. ----
155
+ export function validatePort(v) {
156
+ const n = Number(v);
157
+ if (!Number.isInteger(n) || n < 1 || n > 65535)
158
+ return t('setup.valPort');
159
+ return undefined;
160
+ }
161
+ export const validateNonEmpty = (label) => (value) => (String(value || '').trim() ? undefined : t('setup.valRequired', { label }));
162
+ export function validateHost(v) {
163
+ const s = String(v || '').trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
164
+ return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(s) ? undefined : t('setup.valHost');
165
+ }
166
+ // The built-in browser maps proxy origins below this domain. Blank deliberately leaves proxy mode disabled;
167
+ // a URL, wildcard, or port would produce an invalid host when Handmux adds its own stable subdomain.
168
+ export function validatePreviewDomain(v) {
169
+ const s = String(v || '').trim();
170
+ if (!s)
171
+ return undefined;
172
+ return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(s) ? undefined : t('setup.valPreviewDomain');
173
+ }
174
+ // VAPID subject: Apple (APNs) rejects a fake/.local domain with BadJwtToken, so require a real-looking
175
+ // mailto:you@host.tld or an https:// URL and reject the known-bad .local. Keeps push from silently
176
+ // failing on iOS. (Can't fully validate "real" client-side — this just catches the obvious footguns.)
177
+ export function validateContact(v) {
178
+ const s = String(v || '').trim();
179
+ const wellFormed = /^mailto:[^@\s]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(s)
180
+ || /^https:\/\/[a-z0-9-]+(\.[a-z0-9-]+)+/i.test(s);
181
+ if (!wellFormed || /\.local(?:[:/]|$)/i.test(s))
182
+ return t('setup.valContact');
183
+ return undefined;
184
+ }
185
+ // Access token: it rides in the phone's URL as ?token=…, so require something and reject whitespace (a space
186
+ // would break the link). Length/charset are otherwise up to the user — a pinned token is used verbatim.
187
+ export function validateToken(v) {
188
+ const s = String(v || '').trim();
189
+ if (!s)
190
+ return t('setup.valToken');
191
+ if (/\s/.test(s))
192
+ return t('setup.valTokenSpace');
193
+ return undefined;
194
+ }