termdock 1.0.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +385 -0
  3. package/dist/client/apple-splash-default-1170x2532.png +0 -0
  4. package/dist/client/apple-splash-landscape-1136x640.png +0 -0
  5. package/dist/client/apple-splash-landscape-1334x750.png +0 -0
  6. package/dist/client/apple-splash-landscape-1792x828.png +0 -0
  7. package/dist/client/apple-splash-landscape-2048x1536.png +0 -0
  8. package/dist/client/apple-splash-landscape-2160x1620.png +0 -0
  9. package/dist/client/apple-splash-landscape-2208x1242.png +0 -0
  10. package/dist/client/apple-splash-landscape-2224x1668.png +0 -0
  11. package/dist/client/apple-splash-landscape-2266x1488.png +0 -0
  12. package/dist/client/apple-splash-landscape-2360x1640.png +0 -0
  13. package/dist/client/apple-splash-landscape-2388x1668.png +0 -0
  14. package/dist/client/apple-splash-landscape-2436x1125.png +0 -0
  15. package/dist/client/apple-splash-landscape-2532x1170.png +0 -0
  16. package/dist/client/apple-splash-landscape-2556x1179.png +0 -0
  17. package/dist/client/apple-splash-landscape-2622x1206.png +0 -0
  18. package/dist/client/apple-splash-landscape-2688x1242.png +0 -0
  19. package/dist/client/apple-splash-landscape-2732x2048.png +0 -0
  20. package/dist/client/apple-splash-landscape-2778x1284.png +0 -0
  21. package/dist/client/apple-splash-landscape-2796x1290.png +0 -0
  22. package/dist/client/apple-splash-landscape-2868x1320.png +0 -0
  23. package/dist/client/apple-splash-portrait-1125x2436.png +0 -0
  24. package/dist/client/apple-splash-portrait-1170x2532.png +0 -0
  25. package/dist/client/apple-splash-portrait-1179x2556.png +0 -0
  26. package/dist/client/apple-splash-portrait-1206x2622.png +0 -0
  27. package/dist/client/apple-splash-portrait-1242x2208.png +0 -0
  28. package/dist/client/apple-splash-portrait-1242x2688.png +0 -0
  29. package/dist/client/apple-splash-portrait-1284x2778.png +0 -0
  30. package/dist/client/apple-splash-portrait-1290x2796.png +0 -0
  31. package/dist/client/apple-splash-portrait-1320x2868.png +0 -0
  32. package/dist/client/apple-splash-portrait-1488x2266.png +0 -0
  33. package/dist/client/apple-splash-portrait-1536x2048.png +0 -0
  34. package/dist/client/apple-splash-portrait-1620x2160.png +0 -0
  35. package/dist/client/apple-splash-portrait-1640x2360.png +0 -0
  36. package/dist/client/apple-splash-portrait-1668x2224.png +0 -0
  37. package/dist/client/apple-splash-portrait-1668x2388.png +0 -0
  38. package/dist/client/apple-splash-portrait-2048x2732.png +0 -0
  39. package/dist/client/apple-splash-portrait-640x1136.png +0 -0
  40. package/dist/client/apple-splash-portrait-750x1334.png +0 -0
  41. package/dist/client/apple-splash-portrait-828x1792.png +0 -0
  42. package/dist/client/apple-touch-icon-180x180.png +0 -0
  43. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  44. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  45. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  46. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  47. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  48. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  50. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  51. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  53. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  54. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  55. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  56. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  57. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  58. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  59. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  60. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  61. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  62. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  63. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  64. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  65. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  66. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  67. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  68. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  69. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  70. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  71. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  72. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  73. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  74. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  75. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  76. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  77. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  78. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  79. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  80. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  81. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  82. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  83. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  84. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  85. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  86. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  87. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  88. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  89. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  90. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  91. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  92. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  93. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  94. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  95. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  96. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  97. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  98. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  99. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  100. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  101. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  102. package/dist/client/assets/abnfDiagram-VRR7QNED-D0NxeSHA.js +1 -0
  103. package/dist/client/assets/arc-B7GoKc6u.js +1 -0
  104. package/dist/client/assets/architectureDiagram-ZJ3FMSHR-BQPW-wUx.js +36 -0
  105. package/dist/client/assets/blockDiagram-677ZJIJ3-CX_hbcgG.js +132 -0
  106. package/dist/client/assets/c4Diagram-LMCZKHZV-BwOMQA4x.js +10 -0
  107. package/dist/client/assets/channel-C2gUFcyU.js +1 -0
  108. package/dist/client/assets/chunk-2Q5K7J3B-BLJzEQXz.js +1 -0
  109. package/dist/client/assets/chunk-32BRIVSS-CjBDr_gz.js +1 -0
  110. package/dist/client/assets/chunk-5VM5RSS4-lddvv0x2.js +15 -0
  111. package/dist/client/assets/chunk-EX3LRPZG-Dtsq1Stg.js +231 -0
  112. package/dist/client/assets/chunk-JWPE2WC7-BG3PZx4j.js +1 -0
  113. package/dist/client/assets/chunk-MOJQB5TN-CMtlCuQg.js +88 -0
  114. package/dist/client/assets/chunk-RYQCIY6F-uplKqw64.js +1 -0
  115. package/dist/client/assets/chunk-V7JOEXUC-CpteLsY3.js +206 -0
  116. package/dist/client/assets/chunk-VR4S4FIN-1zYOJQXL.js +1 -0
  117. package/dist/client/assets/chunk-XXDRQBXY-C0wXH91I.js +1 -0
  118. package/dist/client/assets/classDiagram-OUVF2IWQ-BTOnA9HS.js +1 -0
  119. package/dist/client/assets/classDiagram-v2-EOCWNBFH-BTOnA9HS.js +1 -0
  120. package/dist/client/assets/cose-bilkent-JH36ORCC-BceT5ki2.js +1 -0
  121. package/dist/client/assets/cynefin-VYW2F7L2-BqCIkNeC.js +166 -0
  122. package/dist/client/assets/cynefinDiagram-TSTJHNR4-Dm7y-tsN.js +62 -0
  123. package/dist/client/assets/cytoscape.esm-D3_iZ_3b.js +321 -0
  124. package/dist/client/assets/dagre-VKFMJZFB-Bn8eh0lE.js +4 -0
  125. package/dist/client/assets/defaultLocale-CrowFXzY.js +1 -0
  126. package/dist/client/assets/diagram-FQU43EPY-DmZGMAsX.js +3 -0
  127. package/dist/client/assets/diagram-G47NLZAW-BLWSm_Rn.js +24 -0
  128. package/dist/client/assets/diagram-NH7WQ7WH-C2-HK8pv.js +24 -0
  129. package/dist/client/assets/diagram-OA4YK3LP-8Ex4XIU5.js +30 -0
  130. package/dist/client/assets/diagram-WEI45ONY-MRNIpBXG.js +41 -0
  131. package/dist/client/assets/diffWorker-B6msVyjo.js +18 -0
  132. package/dist/client/assets/dnd-BCFAPC0L.js +1 -0
  133. package/dist/client/assets/ebnfDiagram-CCIWWBDH-D7Z6wZi_.js +1 -0
  134. package/dist/client/assets/erDiagram-Q63AITRT-C96Q5x9y.js +85 -0
  135. package/dist/client/assets/flowDiagram-23GEKE2U-DzQp40QH.js +156 -0
  136. package/dist/client/assets/ganttDiagram-NO4QXBWP-ZzkchZJ8.js +292 -0
  137. package/dist/client/assets/gitGraphDiagram-IHSO6WYX-CFacEf54.js +106 -0
  138. package/dist/client/assets/graph-D2o_JWn5.js +1 -0
  139. package/dist/client/assets/index-Ca-rq7yX.js +6 -0
  140. package/dist/client/assets/index-Cv7q4nZR.css +1 -0
  141. package/dist/client/assets/index-XktPMm51.js +394 -0
  142. package/dist/client/assets/infoDiagram-FWYZ7A6U-CVRXs5rF.js +2 -0
  143. package/dist/client/assets/init-Gi6I4Gst.js +1 -0
  144. package/dist/client/assets/ishikawaDiagram-FXEZZL3T-B8FP_wzM.js +70 -0
  145. package/dist/client/assets/journeyDiagram-5HDEW3XC-BR__4Hbd.js +139 -0
  146. package/dist/client/assets/kanban-definition-HUTT4EX6-v39oZe6X.js +89 -0
  147. package/dist/client/assets/katex-55ZPZeOF.css +1 -0
  148. package/dist/client/assets/katex-DnlPpQZa.js +257 -0
  149. package/dist/client/assets/katex-HP8lGamR.js +257 -0
  150. package/dist/client/assets/layout-1eBIAoSl.js +1 -0
  151. package/dist/client/assets/linear-jlTiHqO6.js +1 -0
  152. package/dist/client/assets/map-BdqsVdb3.js +1 -0
  153. package/dist/client/assets/mermaid.core-DiiIN0EC.js +306 -0
  154. package/dist/client/assets/mindmap-definition-LN4V7U3C-AeUxAZeq.js +96 -0
  155. package/dist/client/assets/ordinal-Cboi1Yqb.js +1 -0
  156. package/dist/client/assets/pegDiagram-2B236MQR-Dh-bgAbv.js +1 -0
  157. package/dist/client/assets/pieDiagram-ENE6RG2P-De7Si7Tm.js +39 -0
  158. package/dist/client/assets/purify.es-CC4Brkr_.js +3 -0
  159. package/dist/client/assets/quadrantDiagram-ABIIQ3AL-CgWfHqy0.js +7 -0
  160. package/dist/client/assets/railroadDiagram-RFXS5EU6-BXnQW-pI.js +1 -0
  161. package/dist/client/assets/react-XDCjal2T.js +8 -0
  162. package/dist/client/assets/requirementDiagram-TGXJPOKE-CU1nnpUM.js +84 -0
  163. package/dist/client/assets/sankeyDiagram-HTMAVEWB-DFK-AXY6.js +40 -0
  164. package/dist/client/assets/sequenceDiagram-DBY2YBRQ-D_bVDWgq.js +162 -0
  165. package/dist/client/assets/sizeCapture-X5ZJPWSS-nh89ExHT.js +1 -0
  166. package/dist/client/assets/stateDiagram-2N3HPSRC-DUvT6Ruk.js +1 -0
  167. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dz-3r-Yq.js +1 -0
  168. package/dist/client/assets/swimlanes-5IMT3BWC-ZYa52gjG.js +2 -0
  169. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DXR_9lf9.js +8 -0
  170. package/dist/client/assets/terminal-C7X98Amf.js +10 -0
  171. package/dist/client/assets/timeline-definition-FHXFAJF6-Bp6A49KQ.js +120 -0
  172. package/dist/client/assets/vennDiagram-L72KCM5P-CDDUmLOI.js +34 -0
  173. package/dist/client/assets/wardleyDiagram-EHGQE667-CRc2ptu7.js +78 -0
  174. package/dist/client/assets/xychartDiagram-FW5EYKEG-ht_S0LUA.js +7 -0
  175. package/dist/client/favicon.ico +0 -0
  176. package/dist/client/fonts/JetBrainsMonoNL-Bold.woff2 +0 -0
  177. package/dist/client/fonts/JetBrainsMonoNL-BoldItalic.woff2 +0 -0
  178. package/dist/client/fonts/JetBrainsMonoNL-Italic.woff2 +0 -0
  179. package/dist/client/fonts/JetBrainsMonoNL-Regular.woff2 +0 -0
  180. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf +0 -0
  181. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf +0 -0
  182. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf +0 -0
  183. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf +0 -0
  184. package/dist/client/fonts/NotoSansMonoCJKsc-Bold-Subset.woff2 +0 -0
  185. package/dist/client/fonts/NotoSansMonoCJKsc-Subset.woff2 +0 -0
  186. package/dist/client/fonts/NotoSansSymbols2-Subset.woff2 +0 -0
  187. package/dist/client/fonts/SymbolsNerdFontMono-Regular.woff2 +0 -0
  188. package/dist/client/fonts/TermdockStarSymbol-Subset.woff2 +0 -0
  189. package/dist/client/index.html +255 -0
  190. package/dist/client/manifest.webmanifest +1 -0
  191. package/dist/client/maskable-icon-512x512.png +0 -0
  192. package/dist/client/pwa-192x192.png +0 -0
  193. package/dist/client/pwa-192x192.svg +5 -0
  194. package/dist/client/pwa-512x512.png +0 -0
  195. package/dist/client/pwa-512x512.svg +5 -0
  196. package/dist/client/pwa-64x64.png +0 -0
  197. package/dist/client/registerSW.js +1 -0
  198. package/dist/client/robots.txt +4 -0
  199. package/dist/client/shell-integration/termdock.bash +107 -0
  200. package/dist/client/shell-integration/termdock.fish +60 -0
  201. package/dist/client/shell-integration/termdock.zsh +164 -0
  202. package/dist/client/sw-notifications.js +42 -0
  203. package/dist/client/sw.js +1 -0
  204. package/dist/client/workbox-e4022e15.js +1 -0
  205. package/dist/server/certificateWatcher.js +111 -0
  206. package/dist/server/cli.js +2534 -0
  207. package/dist/server/config.js +54 -0
  208. package/dist/server/entry.js +571 -0
  209. package/dist/server/onboardingServer.js +54 -0
  210. package/dist/server/routes/auth.js +48 -0
  211. package/dist/server/routes/filesystem.js +3749 -0
  212. package/dist/server/routes/local.js +32 -0
  213. package/dist/server/routes/onboarding.js +134 -0
  214. package/dist/server/routes/terminal.js +4879 -0
  215. package/dist/server/utils/authProtection.js +338 -0
  216. package/dist/server/utils/caffeinate.js +124 -0
  217. package/dist/server/utils/changeAuditStore.js +521 -0
  218. package/dist/server/utils/cookieSecurity.js +13 -0
  219. package/dist/server/utils/csrfProtection.js +112 -0
  220. package/dist/server/utils/localAccess.js +211 -0
  221. package/dist/server/utils/pathValidator.js +272 -0
  222. package/dist/server/utils/requestSecurity.js +103 -0
  223. package/dist/server/utils/serverLogger.js +57 -0
  224. package/dist/server/utils/settings.js +157 -0
  225. package/dist/server/utils/tmuxFocus.js +45 -0
  226. package/dist/server/utils/tmuxProgramDetection.js +148 -0
  227. package/package.json +125 -0
@@ -0,0 +1,2534 @@
1
+ #!/usr/bin/env node
2
+ import { config as loadDotenv } from 'dotenv';
3
+ import os from 'os';
4
+ import { resolve } from 'path';
5
+ // 加载 .env 配置:用户级 ~/.termdock/.env 为基准,项目级 CWD .env 可覆盖
6
+ // dotenv 不会覆盖已存在的环境变量,因此系统环境变量和命令行参数优先级最高
7
+ loadDotenv({ path: resolve(os.homedir(), '.termdock', '.env'), quiet: true });
8
+ loadDotenv({ path: resolve(process.cwd(), '.env'), quiet: true });
9
+ import fs from 'fs';
10
+ import http from 'http';
11
+ import https from 'https';
12
+ import path from 'path';
13
+ import readline from 'readline';
14
+ import { createHash, randomUUID } from 'crypto';
15
+ import { spawn, execFile } from 'child_process';
16
+ import { promisify } from 'util';
17
+ import { Writable } from 'stream';
18
+ import { createRequire } from 'module';
19
+ import { PORT, DEFAULT_HOST } from './config.js';
20
+ import { isFirstRunCompleted, markFirstRunCompleted, normalizeLocalAccessName, setLocalAccessSetting, getLocalAccessSetting, getPreventSleepSetting } from './utils/settings.js';
21
+ import { localAccessManager, getLanIPv4Addresses } from './utils/localAccess.js';
22
+ import { clearAuthFile, destroyAllSessions, hashPassword, isAuthEnabled, isEnvPasswordSet, writeAuthFile, } from './utils/authProtection.js';
23
+ const execFileAsync = promisify(execFile);
24
+ const TERMDOCK_VERSION = (() => {
25
+ try {
26
+ const require_ = createRequire(import.meta.url);
27
+ const pkg = require_(path.join(__dirname || '', '..', '..', 'package.json'));
28
+ if (typeof pkg?.version === 'string')
29
+ return pkg.version;
30
+ }
31
+ catch { /* fall through */ }
32
+ try {
33
+ const require_ = createRequire(import.meta.url);
34
+ const pkg = require_('../../package.json');
35
+ if (typeof pkg?.version === 'string')
36
+ return pkg.version;
37
+ }
38
+ catch { /* ignore */ }
39
+ return '0.0.0';
40
+ })();
41
+ const TERMDOCK_HOST = os.hostname();
42
+ const TERMDOCK_PID = String(process.pid);
43
+ const parsedTmuxHistoryLimit = Number.parseInt(process.env.TERMDOCK_TMUX_HISTORY_LIMIT || '10000', 10);
44
+ const TERMDOCK_TMUX_HISTORY_LIMIT = Number.isFinite(parsedTmuxHistoryLimit) && parsedTmuxHistoryLimit > 0
45
+ ? parsedTmuxHistoryLimit
46
+ : 10000;
47
+ const stateDir = path.join(os.homedir(), '.termdock');
48
+ const stateFilePath = path.join(stateDir, 'server.json');
49
+ const logFilePath = path.join(stateDir, 'server.log');
50
+ const globalSessionStateFilePath = path.join(stateDir, 'global-session-state.json');
51
+ const localApiTokenPath = path.join(stateDir, 'local-api-token');
52
+ const changeAuditSnapshotPath = path.join(stateDir, 'change-audit-snapshot.json');
53
+ const certDir = path.join(stateDir, 'certs');
54
+ const defaultHttpsCertPath = path.join(certDir, 'termdock-local.pem');
55
+ const defaultHttpsKeyPath = path.join(certDir, 'termdock-local-key.pem');
56
+ const defaultHttpsCaPath = path.join(certDir, 'rootCA.pem');
57
+ const restartBridgeCaffeinateSeconds = 300;
58
+ // ANSI color helpers. Disabled when stdout is not a TTY (e.g. piped, log file)
59
+ // or when the user opts out via NO_COLOR / FORCE_COLOR=0.
60
+ const colorsEnabled = (() => {
61
+ if (process.env.NO_COLOR)
62
+ return false;
63
+ if (process.env.FORCE_COLOR === '0')
64
+ return false;
65
+ return Boolean(process.stdout.isTTY);
66
+ })();
67
+ function paint(code, text) {
68
+ return colorsEnabled ? `\x1b[${code}m${text}\x1b[0m` : text;
69
+ }
70
+ const c = {
71
+ bold: (s) => paint('1', s),
72
+ dim: (s) => paint('2', s),
73
+ red: (s) => paint('31', s),
74
+ green: (s) => paint('32', s),
75
+ yellow: (s) => paint('33', s),
76
+ cyan: (s) => paint('36', s),
77
+ gray: (s) => paint('90', s),
78
+ };
79
+ const ICON = {
80
+ ok: c.green('✓'),
81
+ warn: c.yellow('!'),
82
+ err: c.red('✗'),
83
+ info: c.cyan('›'),
84
+ lock: '🔒',
85
+ };
86
+ function startRestartBridgeCaffeinate() {
87
+ if (process.platform !== 'darwin')
88
+ return false;
89
+ if (!getPreventSleepSetting())
90
+ return false;
91
+ try {
92
+ const child = spawn('caffeinate', ['-i', '-s', '-t', String(restartBridgeCaffeinateSeconds)], {
93
+ detached: true,
94
+ stdio: 'ignore',
95
+ });
96
+ child.unref();
97
+ return true;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ function printHelp() {
104
+ console.log(`Usage: td [options]
105
+ td <cmd> [args]
106
+ termdock [options]
107
+
108
+ Options:
109
+ --host <host> Host to bind to (default: ${DEFAULT_HOST})
110
+ --port <port> Port to listen on (default: ${PORT.backend})
111
+ --https-cert <p> HTTPS certificate path for local access
112
+ --https-key <p> HTTPS private key path for local access
113
+ --https-ca <p> CA certificate path exposed through a temporary mobile setup server
114
+ --setup-local-https
115
+ Generate and trust local HTTPS certs with mkcert
116
+ --foreground Run in the foreground
117
+ --status Show background server status
118
+ --stop Stop the background server
119
+ --set-password Set or update the access password (interactive prompt)
120
+ --clear-password Remove the access password and disable authentication
121
+ --tls List termdock-managed tmux sessions (reads tmux directly,
122
+ no server connection required)
123
+ -a, --all With --tls/--attach-tmux: include tmux sessions not stamped by termdock
124
+ --json With --tls: emit JSON instead of a table
125
+ --attach-tmux [n] Attach to a termdock-managed tmux session.
126
+ With no name: interactive picker.
127
+ With name: attach directly (e.g. --attach-tmux wt-foo).
128
+ Add --all to include unmanaged tmux sessions in matching.
129
+ --new-tmux [name] Create (or ensure) and attach to a tmux session for termdock.
130
+ New sessions start in the directory where td was invoked.
131
+ With no name: auto-generate a wt-* name and enter it directly.
132
+ Use --tls to inspect sessions without attaching.
133
+ --new-tmux-detached [name]
134
+ Create (or ensure) a tmux session and leave it detached.
135
+ --inject-change-audit [file]
136
+ Inject AI-generated hunk explanations into the running
137
+ Termdock server. Reads JSON from file or stdin.
138
+ --inject-branch-audit [file]
139
+ Inject AI-generated branch hunk explanations into the
140
+ running Termdock server. Reads JSON from file or stdin.
141
+ --branch-audit-export <base> [cwd]
142
+ Export branch-vs-base diff hunks for branch audit.
143
+ --change-audit-list [cwd]
144
+ List current Git diff hunks with stable Termdock hunk IDs.
145
+ --change-audit-export [cwd]
146
+ Export current Git diff hunks with full diff text for one-pass review.
147
+ --change-audit-show <id> [cwd]
148
+ Print one hunk's diff/context by Termdock hunk ID.
149
+ --change-audit-explain <id> [cwd]
150
+ Read a natural-language explanation from stdin and inject
151
+ it for the specified Termdock hunk ID.
152
+ -h, --help Show this help message
153
+
154
+ Short commands:
155
+ s Same as --status
156
+ st Same as --status
157
+ x Same as --stop
158
+ stop Same as --stop
159
+ l Same as --tls
160
+ ls Same as --tls
161
+ la Same as --tls --all
162
+ a [name] Same as --attach-tmux [name]
163
+ at [name] Same as --attach-tmux [name]
164
+ n [name] Same as --new-tmux [name]
165
+ nt [name] Same as --new-tmux [name]
166
+ nd [name] Same as --new-tmux-detached [name]
167
+ audit [file] Same as --inject-change-audit [file]
168
+ branch-audit [file]
169
+ Same as --inject-branch-audit [file]
170
+ branch-audit-export <base> [cwd]
171
+ Same as --branch-audit-export <base> [cwd]
172
+ audit-list [cwd] Same as --change-audit-list [cwd]
173
+ audit-export [cwd] Same as --change-audit-export [cwd]
174
+ audit-show <id> [cwd]
175
+ Same as --change-audit-show <id> [cwd]
176
+ audit-explain <id> [cwd]
177
+ Same as --change-audit-explain <id> [cwd]
178
+ p Same as --set-password
179
+ pw Same as --set-password
180
+ pc Same as --clear-password
181
+ pwc Same as --clear-password
182
+ https Same as --setup-local-https
183
+
184
+ Password examples:
185
+ ${c.dim('# Set password interactively (recommended)')}
186
+ td --set-password
187
+
188
+ ${c.dim('# Pipe a password from stdin (CI / scripted setup)')}
189
+ echo "my-secret" | td --set-password
190
+
191
+ ${c.dim('# Disable authentication entirely')}
192
+ td --clear-password
193
+
194
+ Auth state lives in ${c.cyan(path.join('~', '.termdock', 'auth.json'))} (mode 0600).`);
195
+ }
196
+ function ensureStateDir() {
197
+ fs.mkdirSync(stateDir, { recursive: true });
198
+ }
199
+ function readState() {
200
+ try {
201
+ return JSON.parse(fs.readFileSync(stateFilePath, 'utf8'));
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
207
+ function removeStateFile() {
208
+ try {
209
+ fs.rmSync(stateFilePath, { force: true });
210
+ }
211
+ catch {
212
+ // ignore cleanup errors
213
+ }
214
+ }
215
+ function isProcessRunning(pid) {
216
+ try {
217
+ process.kill(pid, 0);
218
+ return true;
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ }
224
+ function getRunningState() {
225
+ const state = readState();
226
+ if (!state) {
227
+ return null;
228
+ }
229
+ if (!isProcessRunning(state.pid)) {
230
+ removeStateFile();
231
+ return null;
232
+ }
233
+ return state;
234
+ }
235
+ function writeState(state) {
236
+ ensureStateDir();
237
+ fs.writeFileSync(stateFilePath, JSON.stringify(state, null, 2));
238
+ }
239
+ function getOrCreateLocalApiToken() {
240
+ ensureStateDir();
241
+ try {
242
+ const existing = fs.readFileSync(localApiTokenPath, 'utf8').trim();
243
+ if (existing.length >= 24)
244
+ return existing;
245
+ }
246
+ catch {
247
+ // Create a token below.
248
+ }
249
+ const token = randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, '');
250
+ fs.writeFileSync(localApiTokenPath, `${token}\n`, { mode: 0o600 });
251
+ return token;
252
+ }
253
+ function buildServerState(params) {
254
+ return {
255
+ pid: params.pid,
256
+ host: params.host,
257
+ port: params.port,
258
+ scheme: params.scheme,
259
+ localUrl: `${params.scheme}://${params.host === '0.0.0.0' ? 'localhost' : params.host}:${params.port}`,
260
+ localAccessReason: params.localAccessReason,
261
+ logFile: logFilePath,
262
+ startedAt: new Date().toISOString(),
263
+ localApiToken: params.localApiToken,
264
+ };
265
+ }
266
+ function fileExists(filePath) {
267
+ return typeof filePath === 'string' && filePath.length > 0 && fs.existsSync(filePath);
268
+ }
269
+ function resolveHttpsOptions(options) {
270
+ if (options.httpsCert || options.httpsKey || options.httpsCa) {
271
+ return {
272
+ cert: options.httpsCert,
273
+ key: options.httpsKey,
274
+ ca: options.httpsCa,
275
+ source: options.httpsCert && options.httpsKey ? 'explicit' : 'none',
276
+ };
277
+ }
278
+ if (fileExists(defaultHttpsCertPath) && fileExists(defaultHttpsKeyPath)) {
279
+ return {
280
+ cert: defaultHttpsCertPath,
281
+ key: defaultHttpsKeyPath,
282
+ ca: fileExists(defaultHttpsCaPath) ? defaultHttpsCaPath : undefined,
283
+ source: 'default',
284
+ };
285
+ }
286
+ return { source: 'none' };
287
+ }
288
+ const EXTRA_EXECUTABLE_DIRS = ['/opt/homebrew/bin', '/usr/local/bin'];
289
+ function executableCandidates(command) {
290
+ if (command.includes(path.sep))
291
+ return [command];
292
+ const dirs = [...(process.env.PATH ?? '').split(path.delimiter), ...EXTRA_EXECUTABLE_DIRS].filter(Boolean);
293
+ const seen = new Set();
294
+ return dirs
295
+ .map((dir) => path.join(dir, command))
296
+ .filter((candidate) => {
297
+ if (seen.has(candidate))
298
+ return false;
299
+ seen.add(candidate);
300
+ return true;
301
+ });
302
+ }
303
+ async function findExecutable(command) {
304
+ for (const candidate of executableCandidates(command)) {
305
+ try {
306
+ await execFileAsync(candidate, ['--version'], { timeout: 5000, maxBuffer: 128 * 1024 });
307
+ return candidate;
308
+ }
309
+ catch { /* try next candidate */ }
310
+ }
311
+ return null;
312
+ }
313
+ async function ensureMkcertInstalled() {
314
+ const existing = await findExecutable('mkcert');
315
+ if (existing)
316
+ return existing;
317
+ const brew = await findExecutable('brew');
318
+ if (!brew) {
319
+ throw new Error(`${ICON.err} ${c.red('mkcert is required for local HTTPS setup, and Homebrew was not found.')}\n ${c.dim('Install Homebrew first, or manually install:')} ${c.cyan('brew install mkcert')}`);
320
+ }
321
+ console.log(`${ICON.info} ${c.dim('mkcert is not installed. Installing with Homebrew...')}`);
322
+ try {
323
+ await execFileAsync(brew, ['install', 'mkcert'], { timeout: 10 * 60 * 1000, maxBuffer: 8 * 1024 * 1024 });
324
+ }
325
+ catch (error) {
326
+ const err = error;
327
+ const detail = (err.stderr || err.message || '').toString().trim();
328
+ throw new Error(`${ICON.err} ${c.red('Failed to install mkcert with Homebrew.')}${detail ? `\n${detail}` : ''}`);
329
+ }
330
+ const installed = await findExecutable('mkcert');
331
+ if (!installed) {
332
+ throw new Error(`${ICON.err} ${c.red('mkcert installation completed, but mkcert was not found on PATH.')}`);
333
+ }
334
+ return installed;
335
+ }
336
+ function getRequiredLocalHttpsNames() {
337
+ const localName = getLocalAccessSetting().name;
338
+ return [
339
+ '*.termdock.local',
340
+ `${localName}.termdock.local`,
341
+ ...getLanIPv4Addresses(),
342
+ 'localhost',
343
+ '127.0.0.1',
344
+ '::1',
345
+ ];
346
+ }
347
+ async function readCertificateSans(certPath) {
348
+ try {
349
+ const { stdout } = await execFileAsync('openssl', ['x509', '-in', certPath, '-noout', '-ext', 'subjectAltName'], {
350
+ timeout: 5000,
351
+ maxBuffer: 256 * 1024,
352
+ });
353
+ return stdout
354
+ .split(/[\n,]/)
355
+ .map((part) => part.trim())
356
+ .filter(Boolean);
357
+ }
358
+ catch {
359
+ return [];
360
+ }
361
+ }
362
+ function sanMatches(required, sans) {
363
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(required) || required.includes(':')) {
364
+ return sans.some((san) => san === `IP Address:${required}` || san === `IP:${required}`);
365
+ }
366
+ return sans.some((san) => san === `DNS:${required}`);
367
+ }
368
+ async function defaultCertificateNeedsRefresh() {
369
+ if (!fileExists(defaultHttpsCertPath) || !fileExists(defaultHttpsKeyPath))
370
+ return false;
371
+ const sans = await readCertificateSans(defaultHttpsCertPath);
372
+ if (sans.length === 0)
373
+ return true;
374
+ return getRequiredLocalHttpsNames().some((name) => !sanMatches(name, sans));
375
+ }
376
+ async function ensureDefaultHttpsCertificateFresh() {
377
+ if (!(await defaultCertificateNeedsRefresh()))
378
+ return false;
379
+ console.log(`${ICON.info} ${c.dim('Local HTTPS certificate is missing current domain/IP SANs; regenerating...')}`);
380
+ await runSetupLocalHttps({ quietRestartHint: true });
381
+ return true;
382
+ }
383
+ async function runSetupLocalHttps(options = {}) {
384
+ const mkcert = await ensureMkcertInstalled();
385
+ ensureStateDir();
386
+ fs.mkdirSync(certDir, { recursive: true, mode: 0o700 });
387
+ console.log(`${ICON.info} ${c.dim('Installing local CA into this computer trust store...')}`);
388
+ await execFileAsync(mkcert, ['-install'], { timeout: 120000, maxBuffer: 1024 * 1024 });
389
+ console.log(`${ICON.info} ${c.dim('Generating local certificate for Termdock domains...')}`);
390
+ await execFileAsync(mkcert, [
391
+ '-cert-file', defaultHttpsCertPath,
392
+ '-key-file', defaultHttpsKeyPath,
393
+ ...getRequiredLocalHttpsNames(),
394
+ ], { cwd: certDir, timeout: 120000, maxBuffer: 1024 * 1024 });
395
+ try {
396
+ const { stdout } = await execFileAsync(mkcert, ['-CAROOT'], { timeout: 5000, maxBuffer: 64 * 1024 });
397
+ const rootCA = path.join(stdout.trim(), 'rootCA.pem');
398
+ if (fs.existsSync(rootCA)) {
399
+ fs.copyFileSync(rootCA, defaultHttpsCaPath);
400
+ try {
401
+ fs.chmodSync(defaultHttpsCaPath, 0o600);
402
+ }
403
+ catch { /* ignore */ }
404
+ }
405
+ }
406
+ catch { /* CA download is optional but recommended */ }
407
+ try {
408
+ fs.chmodSync(defaultHttpsCertPath, 0o600);
409
+ }
410
+ catch { /* ignore */ }
411
+ try {
412
+ fs.chmodSync(defaultHttpsKeyPath, 0o600);
413
+ }
414
+ catch { /* ignore */ }
415
+ console.log(`${ICON.ok} ${c.green('Local HTTPS certificates are ready.')}`);
416
+ console.log(` ${c.dim('Cert:')} ${c.cyan(defaultHttpsCertPath)}`);
417
+ console.log(` ${c.dim('Key:')} ${c.cyan(defaultHttpsKeyPath)}`);
418
+ if (fs.existsSync(defaultHttpsCaPath)) {
419
+ console.log(` ${c.dim('CA:')} ${c.cyan(defaultHttpsCaPath)} ${c.dim('(served on mobile setup page)')}`);
420
+ }
421
+ console.log('');
422
+ if (!options.quietRestartHint) {
423
+ console.log(`${ICON.info} ${c.dim('Restart Termdock to use HTTPS:')} ${c.cyan('td --stop && td')}`);
424
+ }
425
+ }
426
+ function printRunningState(state) {
427
+ const displayHost = state.host === '0.0.0.0' ? 'localhost' : state.host;
428
+ const authLine = isAuthEnabled()
429
+ ? `${c.green('enabled')} ${c.dim('(password required)')}`
430
+ : `${c.red('disabled')} ${c.dim('(no password — anyone on the LAN can connect)')}`;
431
+ console.log(`${ICON.ok} ${c.green('Termdock is running in background.')}`);
432
+ console.log(` ${c.dim('PID:')} ${state.pid}`);
433
+ console.log(` ${c.dim('URL:')} ${c.cyan(state.localUrl ?? `http://${displayHost}:${state.port}`)}`);
434
+ if (state.lanUrl)
435
+ console.log(` ${c.dim('LAN:')} ${c.cyan(state.lanUrl)} ${state.localAccessStatus ? c.dim(`(${state.localAccessStatus})`) : ''}`);
436
+ if (state.onboardingUrl)
437
+ console.log(` ${c.dim('Setup:')} ${c.cyan(state.onboardingUrl)} ${c.dim('(open this on your phone to download the CA certificate)')}`);
438
+ if (state.localAccessReason)
439
+ console.log(` ${c.dim('LAN note:')} ${state.localAccessReason}`);
440
+ console.log(` ${c.dim('Log:')} ${state.logFile}`);
441
+ console.log(` ${c.dim('Auth:')} ${authLine}`);
442
+ }
443
+ function parseArgs(argv) {
444
+ let host;
445
+ let port;
446
+ let httpsCert;
447
+ let httpsKey;
448
+ let httpsCa;
449
+ let setupLocalHttps = false;
450
+ let foreground = false;
451
+ let status = false;
452
+ let stop = false;
453
+ let setPassword = false;
454
+ let clearPassword = false;
455
+ let tls = false;
456
+ let tlsAll = false;
457
+ let tlsJson = false;
458
+ let attachTmux = false;
459
+ let attachTmuxName;
460
+ let newTmux = false;
461
+ let newTmuxName;
462
+ let newTmuxAttach = true;
463
+ let injectChangeAudit;
464
+ let injectBranchAudit;
465
+ let branchAuditExport;
466
+ let changeAuditExport;
467
+ let changeAuditList;
468
+ let changeAuditShow;
469
+ let injectChangeAuditHunk;
470
+ // Short command aliases for the common path. Keep these positional-only so
471
+ // long-form flags remain the single source of truth for option semantics.
472
+ if (argv[0] && !argv[0].startsWith('-')) {
473
+ const command = argv[0];
474
+ const next = argv[1];
475
+ if (command === 'tls' || command === 'l' || command === 'ls') {
476
+ tls = true;
477
+ argv = argv.slice(1);
478
+ }
479
+ else if (command === 'la') {
480
+ tls = true;
481
+ tlsAll = true;
482
+ argv = argv.slice(1);
483
+ }
484
+ else if (command === 's' || command === 'st') {
485
+ status = true;
486
+ argv = argv.slice(1);
487
+ }
488
+ else if (command === 'x' || command === 'stop') {
489
+ stop = true;
490
+ argv = argv.slice(1);
491
+ }
492
+ else if (command === 'p' || command === 'pw') {
493
+ setPassword = true;
494
+ argv = argv.slice(1);
495
+ }
496
+ else if (command === 'pc' || command === 'pwc') {
497
+ clearPassword = true;
498
+ argv = argv.slice(1);
499
+ }
500
+ else if (command === 'https') {
501
+ setupLocalHttps = true;
502
+ argv = argv.slice(1);
503
+ }
504
+ else if (command === 'a' || command === 'at') {
505
+ attachTmux = true;
506
+ if (next && !next.startsWith('-')) {
507
+ attachTmuxName = next;
508
+ argv = argv.slice(2);
509
+ }
510
+ else {
511
+ argv = argv.slice(1);
512
+ }
513
+ }
514
+ else if (command === 'n' || command === 'nt') {
515
+ newTmux = true;
516
+ newTmuxAttach = true;
517
+ if (next && !next.startsWith('-')) {
518
+ newTmuxName = next;
519
+ argv = argv.slice(2);
520
+ }
521
+ else {
522
+ argv = argv.slice(1);
523
+ }
524
+ }
525
+ else if (command === 'nd') {
526
+ newTmux = true;
527
+ newTmuxAttach = false;
528
+ if (next && !next.startsWith('-')) {
529
+ newTmuxName = next;
530
+ argv = argv.slice(2);
531
+ }
532
+ else {
533
+ argv = argv.slice(1);
534
+ }
535
+ }
536
+ else if (command === 'audit') {
537
+ injectChangeAudit = next && !next.startsWith('-') ? next : true;
538
+ argv = argv.slice(injectChangeAudit === true ? 1 : 2);
539
+ }
540
+ else if (command === 'branch-audit') {
541
+ injectBranchAudit = next && !next.startsWith('-') ? next : true;
542
+ argv = argv.slice(injectBranchAudit === true ? 1 : 2);
543
+ }
544
+ else if (command === 'branch-audit-export' && next && !next.startsWith('-')) {
545
+ const cwd = argv[2] && !argv[2].startsWith('-') ? argv[2] : undefined;
546
+ branchAuditExport = { base: next, cwd };
547
+ argv = argv.slice(cwd ? 3 : 2);
548
+ }
549
+ else if (command === 'audit-list') {
550
+ changeAuditList = next && !next.startsWith('-') ? next : true;
551
+ argv = argv.slice(changeAuditList === true ? 1 : 2);
552
+ }
553
+ else if (command === 'audit-export') {
554
+ changeAuditExport = next && !next.startsWith('-') ? next : true;
555
+ argv = argv.slice(changeAuditExport === true ? 1 : 2);
556
+ }
557
+ else if ((command === 'audit-show' || command === 'audit-explain') && next && !next.startsWith('-')) {
558
+ const cwd = argv[2] && !argv[2].startsWith('-') ? argv[2] : undefined;
559
+ if (command === 'audit-show')
560
+ changeAuditShow = { id: next, cwd };
561
+ else
562
+ injectChangeAuditHunk = { id: next, cwd };
563
+ argv = argv.slice(cwd ? 3 : 2);
564
+ }
565
+ }
566
+ for (let index = 0; index < argv.length; index += 1) {
567
+ const arg = argv[index];
568
+ if (arg === '-h' || arg === '--help') {
569
+ printHelp();
570
+ process.exit(0);
571
+ }
572
+ if (arg === '--host') {
573
+ host = argv[index + 1];
574
+ index += 1;
575
+ continue;
576
+ }
577
+ if (arg === '--port') {
578
+ const nextValue = argv[index + 1];
579
+ const parsedPort = Number(nextValue);
580
+ if (!Number.isInteger(parsedPort) || parsedPort <= 0) {
581
+ console.error(`${ICON.err} ${c.red(`Invalid port: ${nextValue ?? ''}`)}`);
582
+ process.exit(1);
583
+ }
584
+ port = parsedPort;
585
+ index += 1;
586
+ continue;
587
+ }
588
+ if (arg === '--https-cert') {
589
+ httpsCert = argv[index + 1];
590
+ index += 1;
591
+ continue;
592
+ }
593
+ if (arg === '--https-key') {
594
+ httpsKey = argv[index + 1];
595
+ index += 1;
596
+ continue;
597
+ }
598
+ if (arg === '--https-ca') {
599
+ httpsCa = argv[index + 1];
600
+ index += 1;
601
+ continue;
602
+ }
603
+ if (arg === '--setup-local-https') {
604
+ setupLocalHttps = true;
605
+ continue;
606
+ }
607
+ if (arg === '--foreground') {
608
+ foreground = true;
609
+ continue;
610
+ }
611
+ if (arg === '--status') {
612
+ status = true;
613
+ continue;
614
+ }
615
+ if (arg === '--stop') {
616
+ stop = true;
617
+ continue;
618
+ }
619
+ if (arg === '--set-password') {
620
+ setPassword = true;
621
+ continue;
622
+ }
623
+ if (arg === '--clear-password') {
624
+ clearPassword = true;
625
+ continue;
626
+ }
627
+ if (arg === '--tls') {
628
+ tls = true;
629
+ continue;
630
+ }
631
+ if (arg === '-a' || arg === '--all') {
632
+ tlsAll = true;
633
+ continue;
634
+ }
635
+ if (arg === '--json') {
636
+ tlsJson = true;
637
+ continue;
638
+ }
639
+ if (arg === '--attach-tmux') {
640
+ attachTmux = true;
641
+ const next = argv[index + 1];
642
+ if (next && !next.startsWith('-')) {
643
+ attachTmuxName = next;
644
+ index += 1;
645
+ }
646
+ continue;
647
+ }
648
+ if (arg === '--new-tmux') {
649
+ newTmux = true;
650
+ newTmuxAttach = true;
651
+ const next = argv[index + 1];
652
+ if (next && !next.startsWith('-')) {
653
+ newTmuxName = next;
654
+ index += 1;
655
+ }
656
+ continue;
657
+ }
658
+ if (arg === '--new-tmux-detached') {
659
+ newTmux = true;
660
+ newTmuxAttach = false;
661
+ const next = argv[index + 1];
662
+ if (next && !next.startsWith('-')) {
663
+ newTmuxName = next;
664
+ index += 1;
665
+ }
666
+ continue;
667
+ }
668
+ if (arg === '--inject-change-audit') {
669
+ const next = argv[index + 1];
670
+ if (next && !next.startsWith('-')) {
671
+ injectChangeAudit = next;
672
+ index += 1;
673
+ }
674
+ else {
675
+ injectChangeAudit = true;
676
+ }
677
+ continue;
678
+ }
679
+ if (arg === '--inject-branch-audit') {
680
+ const next = argv[index + 1];
681
+ if (next && !next.startsWith('-')) {
682
+ injectBranchAudit = next;
683
+ index += 1;
684
+ }
685
+ else {
686
+ injectBranchAudit = true;
687
+ }
688
+ continue;
689
+ }
690
+ if (arg === '--branch-audit-export') {
691
+ const base = argv[index + 1];
692
+ if (!base || base.startsWith('-')) {
693
+ console.error(`${ICON.err} ${c.red('--branch-audit-export requires a base branch/ref')}`);
694
+ process.exit(1);
695
+ }
696
+ const cwd = argv[index + 2] && !argv[index + 2].startsWith('-') ? argv[index + 2] : undefined;
697
+ branchAuditExport = { base, cwd };
698
+ index += cwd ? 2 : 1;
699
+ continue;
700
+ }
701
+ if (arg === '--change-audit-list') {
702
+ const next = argv[index + 1];
703
+ if (next && !next.startsWith('-')) {
704
+ changeAuditList = next;
705
+ index += 1;
706
+ }
707
+ else {
708
+ changeAuditList = true;
709
+ }
710
+ continue;
711
+ }
712
+ if (arg === '--change-audit-export') {
713
+ const next = argv[index + 1];
714
+ if (next && !next.startsWith('-')) {
715
+ changeAuditExport = next;
716
+ index += 1;
717
+ }
718
+ else {
719
+ changeAuditExport = true;
720
+ }
721
+ continue;
722
+ }
723
+ if (arg === '--change-audit-show' || arg === '--change-audit-explain') {
724
+ const id = argv[index + 1];
725
+ if (!id || id.startsWith('-')) {
726
+ console.error(`${ICON.err} ${c.red(`${arg} requires a hunk id`)}`);
727
+ process.exit(1);
728
+ }
729
+ const cwd = argv[index + 2] && !argv[index + 2].startsWith('-') ? argv[index + 2] : undefined;
730
+ if (arg === '--change-audit-show')
731
+ changeAuditShow = { id, cwd };
732
+ else
733
+ injectChangeAuditHunk = { id, cwd };
734
+ index += cwd ? 2 : 1;
735
+ continue;
736
+ }
737
+ if (!arg.startsWith('-')) {
738
+ if (attachTmux && !attachTmuxName) {
739
+ attachTmuxName = arg;
740
+ continue;
741
+ }
742
+ if (newTmux && !newTmuxName) {
743
+ newTmuxName = arg;
744
+ continue;
745
+ }
746
+ }
747
+ console.error(`${ICON.err} ${c.red(`Unknown argument: ${arg}`)}`);
748
+ printHelp();
749
+ process.exit(1);
750
+ }
751
+ return {
752
+ host,
753
+ port,
754
+ httpsCert,
755
+ httpsKey,
756
+ httpsCa,
757
+ setupLocalHttps,
758
+ foreground,
759
+ status,
760
+ stop,
761
+ setPassword,
762
+ clearPassword,
763
+ tls,
764
+ tlsAll,
765
+ tlsJson,
766
+ attachTmux,
767
+ attachTmuxName,
768
+ newTmux,
769
+ newTmuxName,
770
+ newTmuxAttach,
771
+ injectChangeAudit,
772
+ injectBranchAudit,
773
+ branchAuditExport,
774
+ changeAuditExport,
775
+ changeAuditList,
776
+ changeAuditShow,
777
+ injectChangeAuditHunk,
778
+ };
779
+ }
780
+ // Reads a single line from stdin without echoing keystrokes. Used for password
781
+ // entry. Falls back to plain readline (with echo) if stdin is not a TTY, e.g.
782
+ // when piping a password in via `echo ... | td --set-password`.
783
+ async function promptHidden(prompt) {
784
+ const isTty = Boolean(process.stdin.isTTY);
785
+ if (!isTty) {
786
+ return new Promise((resolve) => {
787
+ const rl = readline.createInterface({ input: process.stdin });
788
+ rl.once('line', (line) => {
789
+ rl.close();
790
+ resolve(line);
791
+ });
792
+ });
793
+ }
794
+ process.stdout.write(prompt);
795
+ return new Promise((resolve) => {
796
+ // Mute stdout so the typed password is not echoed.
797
+ let muted = true;
798
+ const mutableStdout = new Writable({
799
+ write(chunk, encoding, callback) {
800
+ if (!muted) {
801
+ process.stdout.write(chunk, encoding);
802
+ }
803
+ callback();
804
+ },
805
+ });
806
+ const rl = readline.createInterface({
807
+ input: process.stdin,
808
+ output: mutableStdout,
809
+ terminal: true,
810
+ });
811
+ // Allow newline once the user finishes typing.
812
+ rl.once('line', (line) => {
813
+ muted = false;
814
+ process.stdout.write('\n');
815
+ rl.close();
816
+ resolve(line);
817
+ });
818
+ });
819
+ }
820
+ async function runSetPassword() {
821
+ const isTty = Boolean(process.stdin.isTTY);
822
+ const wasEnabled = isAuthEnabled();
823
+ if (isTty) {
824
+ console.log(`${ICON.lock} ${c.bold('Termdock — set access password')}`);
825
+ console.log(c.dim(' Password must be at least 6 characters. Input is hidden.'));
826
+ console.log('');
827
+ }
828
+ const password = await promptHidden(' New password: ');
829
+ if (!password) {
830
+ console.error(`\n${ICON.err} ${c.red('Password cannot be empty.')}`);
831
+ process.exit(1);
832
+ }
833
+ if (password.length < 6) {
834
+ console.error(`\n${ICON.err} ${c.red('Password must be at least 6 characters.')}`);
835
+ process.exit(1);
836
+ }
837
+ if (isTty) {
838
+ const confirm = await promptHidden(' Confirm password: ');
839
+ if (confirm !== password) {
840
+ console.error(`\n${ICON.err} ${c.red('Passwords do not match. Try again.')}`);
841
+ process.exit(1);
842
+ }
843
+ }
844
+ const hash = hashPassword(password);
845
+ writeAuthFile(hash);
846
+ // Changing the password invalidates all existing sessions so old clients
847
+ // are forced to re-authenticate.
848
+ destroyAllSessions();
849
+ console.log('');
850
+ console.log(`${ICON.ok} ${c.green(wasEnabled ? 'Password updated.' : 'Password set. Authentication enabled.')}`);
851
+ console.log(` ${c.dim('Stored at:')} ${c.cyan(path.join('~', '.termdock', 'auth.json'))} ${c.dim('(mode 0600, scrypt hash)')}`);
852
+ console.log(` ${c.dim('Sessions:')} ${c.dim('all existing browsers were signed out')}`);
853
+ if (isEnvPasswordSet()) {
854
+ console.log(`${ICON.warn} ${c.yellow('TERMDOCK_PASSWORD is set in the environment — it takes precedence over the stored password.')}`);
855
+ }
856
+ // If the server is running, nudge the user to restart so the in-memory
857
+ // auth state reloads from disk.
858
+ const running = getRunningState();
859
+ if (running) {
860
+ console.log('');
861
+ console.log(`${ICON.info} ${c.yellow('Termdock is currently running — restart it so the change takes effect:')}`);
862
+ console.log(` ${c.cyan('td --stop && td')}`);
863
+ }
864
+ else {
865
+ console.log('');
866
+ console.log(`${ICON.info} ${c.dim('Start Termdock with:')} ${c.cyan('td')}`);
867
+ }
868
+ }
869
+ function runClearPassword() {
870
+ if (!isAuthEnabled()) {
871
+ console.log(`${ICON.info} ${c.dim('Authentication is already disabled — nothing to clear.')}`);
872
+ process.exit(0);
873
+ }
874
+ clearAuthFile();
875
+ destroyAllSessions();
876
+ if (isEnvPasswordSet()) {
877
+ console.log(`${ICON.ok} ${c.green('Stored password cleared.')}`);
878
+ console.log(` ${c.dim('All existing sessions have been invalidated.')}`);
879
+ console.log('');
880
+ console.log(`${ICON.info} ${c.yellow('TERMDOCK_PASSWORD is set in the environment — authentication stays enabled.')}`);
881
+ console.log(` ${c.dim('Unset the variable to fully disable authentication.')}`);
882
+ }
883
+ else {
884
+ console.log(`${ICON.ok} ${c.green('Password cleared. Authentication is now disabled.')}`);
885
+ console.log(` ${c.dim('All existing sessions have been invalidated.')}`);
886
+ console.log('');
887
+ console.log(`${ICON.warn} ${c.yellow('Warning:')} ${c.dim('the server is now reachable by anyone who can reach the host/port.')}`);
888
+ console.log(` ${c.dim('Re-enable auth at any time with:')} ${c.cyan('td --set-password')}`);
889
+ }
890
+ const running = getRunningState();
891
+ if (running) {
892
+ console.log('');
893
+ console.log(`${ICON.info} ${c.yellow('Termdock is currently running — restart it so the change takes effect:')}`);
894
+ console.log(` ${c.cyan('td --stop && td')}`);
895
+ }
896
+ }
897
+ async function readStdinText() {
898
+ return new Promise((resolve, reject) => {
899
+ let value = '';
900
+ process.stdin.setEncoding('utf8');
901
+ process.stdin.on('data', (chunk) => { value += chunk; });
902
+ process.stdin.on('end', () => resolve(value));
903
+ process.stdin.on('error', reject);
904
+ });
905
+ }
906
+ async function postLocalJson(baseUrl, token, endpoint, payload) {
907
+ const url = new URL(endpoint, baseUrl);
908
+ const body = JSON.stringify(payload);
909
+ const isHttps = url.protocol === 'https:';
910
+ const requestImpl = isHttps ? https.request : http.request;
911
+ const ca = isHttps && fs.existsSync(defaultHttpsCaPath) ? fs.readFileSync(defaultHttpsCaPath) : undefined;
912
+ return new Promise((resolve, reject) => {
913
+ const req = requestImpl({
914
+ protocol: url.protocol,
915
+ hostname: url.hostname,
916
+ port: url.port,
917
+ path: `${url.pathname}${url.search}`,
918
+ method: 'POST',
919
+ ca,
920
+ headers: {
921
+ 'Content-Type': 'application/json',
922
+ 'Content-Length': Buffer.byteLength(body),
923
+ 'X-Termdock-Local-Token': token,
924
+ },
925
+ }, (res) => {
926
+ let responseBody = '';
927
+ res.setEncoding('utf8');
928
+ res.on('data', (chunk) => { responseBody += chunk; });
929
+ res.on('end', () => resolve({ statusCode: res.statusCode ?? 0, body: responseBody }));
930
+ });
931
+ req.on('error', reject);
932
+ req.write(body);
933
+ req.end();
934
+ });
935
+ }
936
+ async function runInjectChangeAudit(source) {
937
+ const raw = source === true
938
+ ? await readStdinText()
939
+ : fs.readFileSync(path.resolve(source), 'utf8');
940
+ let payload;
941
+ try {
942
+ payload = JSON.parse(raw);
943
+ }
944
+ catch (error) {
945
+ const message = error instanceof Error ? error.message : String(error);
946
+ console.error(`${ICON.err} ${c.red(`Invalid change audit JSON: ${message}`)}`);
947
+ process.exit(1);
948
+ }
949
+ await postChangeAuditPayload(payload);
950
+ }
951
+ async function postChangeAuditPayload(payload) {
952
+ const runningState = getRunningState();
953
+ if (!runningState) {
954
+ console.error(`${ICON.err} ${c.red('Termdock is not running. Start it before injecting change audit explanations.')}`);
955
+ process.exit(1);
956
+ }
957
+ const token = runningState.localApiToken;
958
+ if (!token) {
959
+ console.error(`${ICON.err} ${c.red('Running Termdock server does not expose a local injection token. Restart Termdock first.')}`);
960
+ process.exit(1);
961
+ }
962
+ const baseUrl = runningState.localUrl ?? `${runningState.scheme ?? 'http'}://${runningState.host === '0.0.0.0' ? 'localhost' : runningState.host}:${runningState.port}`;
963
+ const response = await postLocalJson(baseUrl, token, '/api/local/change-audit', payload);
964
+ const body = JSON.parse(response.body || '{}');
965
+ if (response.statusCode < 200 || response.statusCode >= 300) {
966
+ console.error(`${ICON.err} ${c.red(body.error || 'Failed to inject change audit explanations')}`);
967
+ process.exit(1);
968
+ }
969
+ console.log(`${ICON.ok} ${c.green('Injected change audit explanations.')}`);
970
+ console.log(` ${c.dim('Inserted:')} ${body.inserted ?? 0}`);
971
+ console.log(` ${c.dim('Total:')} ${body.total ?? 0}`);
972
+ if (typeof body.walkthroughs === 'number') {
973
+ console.log(` ${c.dim('Walkthroughs:')} ${body.walkthroughs}`);
974
+ }
975
+ }
976
+ async function runInjectBranchAudit(source) {
977
+ const runningState = getRunningState();
978
+ if (!runningState) {
979
+ console.error(`${ICON.err} ${c.red('Termdock is not running. Start it before injecting branch explanations.')}`);
980
+ process.exit(1);
981
+ }
982
+ const token = runningState.localApiToken;
983
+ if (!token) {
984
+ console.error(`${ICON.err} ${c.red('Running Termdock server does not expose a local injection token. Restart Termdock first.')}`);
985
+ process.exit(1);
986
+ }
987
+ const raw = source === true
988
+ ? await readStdinText()
989
+ : fs.readFileSync(path.resolve(source), 'utf8');
990
+ let payload;
991
+ try {
992
+ payload = JSON.parse(raw);
993
+ }
994
+ catch (error) {
995
+ const message = error instanceof Error ? error.message : String(error);
996
+ console.error(`${ICON.err} ${c.red(`Invalid branch audit JSON: ${message}`)}`);
997
+ process.exit(1);
998
+ }
999
+ const baseUrl = runningState.localUrl ?? `${runningState.scheme ?? 'http'}://${runningState.host === '0.0.0.0' ? 'localhost' : runningState.host}:${runningState.port}`;
1000
+ const response = await postLocalJson(baseUrl, token, '/api/local/branch-audit', payload);
1001
+ const body = JSON.parse(response.body || '{}');
1002
+ if (response.statusCode < 200 || response.statusCode >= 300) {
1003
+ console.error(`${ICON.err} ${c.red(body.error || 'Failed to inject branch explanation')}`);
1004
+ process.exit(1);
1005
+ }
1006
+ console.log(`${ICON.ok} ${c.green('Injected branch explanation.')}`);
1007
+ console.log(` ${c.dim('Inserted:')} ${body.inserted ?? 0}`);
1008
+ console.log(` ${c.dim('Total:')} ${body.total ?? 0}`);
1009
+ if (typeof body.walkthroughs === 'number') {
1010
+ console.log(` ${c.dim('Walkthroughs:')} ${body.walkthroughs}`);
1011
+ }
1012
+ }
1013
+ const CHANGE_AUDIT_SNAPSHOT_MAX_AGE_MS = 10 * 60 * 1000;
1014
+ function fnv1a32(text) {
1015
+ let hash = 0x811c9dc5;
1016
+ for (let i = 0; i < text.length; i += 1) {
1017
+ hash ^= text.charCodeAt(i);
1018
+ hash = Math.imul(hash, 0x01000193);
1019
+ }
1020
+ return (hash >>> 0).toString(16).padStart(8, '0');
1021
+ }
1022
+ function normalizeDiffPath(value) {
1023
+ if (!value || value === '/dev/null')
1024
+ return null;
1025
+ return value.startsWith('a/') || value.startsWith('b/') ? value.slice(2) : value;
1026
+ }
1027
+ function buildHunkChangeFingerprint(lines) {
1028
+ const changedLines = lines
1029
+ .filter((line) => line.startsWith('+') || line.startsWith('-'))
1030
+ .filter((line) => !line.startsWith('+++') && !line.startsWith('---'))
1031
+ .map((line) => `${line.startsWith('+') ? 'insert' : 'delete'}:${line.slice(1)}`);
1032
+ const text = changedLines.length > 0
1033
+ ? changedLines.join('\n')
1034
+ : lines.map((line) => line.slice(1)).join('\n');
1035
+ return fnv1a32(text);
1036
+ }
1037
+ function buildAuditSectionFingerprint(lines) {
1038
+ const text = lines
1039
+ .filter((line) => line.startsWith('+') || line.startsWith('-'))
1040
+ .filter((line) => !line.startsWith('+++') && !line.startsWith('---'))
1041
+ .map((line) => `${line.startsWith('+') ? 'insert' : 'delete'}:${line.slice(1)}`)
1042
+ .join('\n');
1043
+ return fnv1a32(text);
1044
+ }
1045
+ function buildAuditSections(lines, hunkHeader, contextSize = 2) {
1046
+ const sections = [];
1047
+ let cursor = 0;
1048
+ while (cursor < lines.length) {
1049
+ while (cursor < lines.length && !lines[cursor].startsWith('+') && !lines[cursor].startsWith('-'))
1050
+ cursor += 1;
1051
+ while (cursor < lines.length && (lines[cursor].startsWith('+++') || lines[cursor].startsWith('---')))
1052
+ cursor += 1;
1053
+ if (cursor >= lines.length)
1054
+ break;
1055
+ const start = cursor;
1056
+ while (cursor < lines.length && (lines[cursor].startsWith('+') || lines[cursor].startsWith('-')) && !lines[cursor].startsWith('+++') && !lines[cursor].startsWith('---'))
1057
+ cursor += 1;
1058
+ const end = cursor;
1059
+ const contextBefore = lines.slice(Math.max(0, start - contextSize), start).filter((line) => line.startsWith(' '));
1060
+ const contextAfter = lines.slice(end, Math.min(lines.length, end + contextSize)).filter((line) => line.startsWith(' '));
1061
+ const changed = lines.slice(start, end);
1062
+ sections.push({
1063
+ index: sections.length,
1064
+ sectionFingerprint: buildAuditSectionFingerprint(changed),
1065
+ additions: changed.filter((line) => line.startsWith('+') && !line.startsWith('+++')).length,
1066
+ deletions: changed.filter((line) => line.startsWith('-') && !line.startsWith('---')).length,
1067
+ diff: [hunkHeader, ...contextBefore, ...changed, ...contextAfter].join('\n'),
1068
+ });
1069
+ }
1070
+ return sections;
1071
+ }
1072
+ async function getGitRoot(cwd) {
1073
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
1074
+ cwd,
1075
+ timeout: 5000,
1076
+ maxBuffer: 1024 * 1024,
1077
+ });
1078
+ return stdout.trim();
1079
+ }
1080
+ async function hasGitMetadata(candidate) {
1081
+ return fs.promises.stat(path.join(candidate, '.git')).then(() => true).catch(() => false);
1082
+ }
1083
+ async function getDirectoryTarget(candidate) {
1084
+ try {
1085
+ const stat = await fs.promises.lstat(candidate);
1086
+ if (stat.isDirectory())
1087
+ return candidate;
1088
+ if (!stat.isSymbolicLink())
1089
+ return null;
1090
+ const realPath = await fs.promises.realpath(candidate);
1091
+ return (await fs.promises.stat(realPath)).isDirectory() ? realPath : null;
1092
+ }
1093
+ catch {
1094
+ return null;
1095
+ }
1096
+ }
1097
+ const AUDIT_NESTED_IGNORED_NAMES = new Set([
1098
+ '.git',
1099
+ 'node_modules',
1100
+ 'dist',
1101
+ 'build',
1102
+ 'out',
1103
+ '.next',
1104
+ '.vite',
1105
+ '.turbo',
1106
+ '.cache',
1107
+ 'coverage',
1108
+ 'Pods',
1109
+ 'DerivedData',
1110
+ ]);
1111
+ async function discoverAuditRepositories(workspaceRoot) {
1112
+ const workspaceGitRoot = await getGitRoot(workspaceRoot);
1113
+ const targets = [{
1114
+ workspaceRoot,
1115
+ repoRoot: workspaceGitRoot,
1116
+ displayRoot: workspaceRoot,
1117
+ relativeRoot: '.',
1118
+ }];
1119
+ const seen = new Set([workspaceGitRoot]);
1120
+ const deadline = Date.now() + 1_000;
1121
+ async function visit(dir) {
1122
+ if (Date.now() > deadline)
1123
+ return;
1124
+ let entries;
1125
+ try {
1126
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
1127
+ }
1128
+ catch {
1129
+ return;
1130
+ }
1131
+ for (const entry of entries) {
1132
+ if (Date.now() > deadline)
1133
+ return;
1134
+ if (AUDIT_NESTED_IGNORED_NAMES.has(entry.name))
1135
+ continue;
1136
+ const candidate = path.join(dir, entry.name);
1137
+ const target = await getDirectoryTarget(candidate);
1138
+ if (!target)
1139
+ continue;
1140
+ if (await hasGitMetadata(candidate) || await hasGitMetadata(target)) {
1141
+ const repoRoot = await getGitRoot(target).catch(() => null);
1142
+ if (repoRoot && !seen.has(repoRoot)) {
1143
+ seen.add(repoRoot);
1144
+ const relativeRoot = path.relative(workspaceRoot, candidate).split(path.sep).join('/') || '.';
1145
+ targets.push({ workspaceRoot, repoRoot, displayRoot: candidate, relativeRoot });
1146
+ }
1147
+ continue;
1148
+ }
1149
+ if (!entry.isSymbolicLink())
1150
+ await visit(target);
1151
+ }
1152
+ }
1153
+ await visit(workspaceRoot);
1154
+ return targets.sort((a, b) => a.relativeRoot.localeCompare(b.relativeRoot));
1155
+ }
1156
+ async function readWorkingTreeDiff(repoRoot) {
1157
+ const [cached, worktree, untracked] = await Promise.all([
1158
+ execFileAsync('git', ['diff', '-M', '--cached'], { cwd: repoRoot, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }).then((r) => r.stdout).catch(() => ''),
1159
+ execFileAsync('git', ['diff', '-M'], { cwd: repoRoot, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }).then((r) => r.stdout).catch(() => ''),
1160
+ execFileAsync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: repoRoot, timeout: 30_000, maxBuffer: 4 * 1024 * 1024 }).then(async ({ stdout }) => {
1161
+ const pieces = [];
1162
+ const files = stdout.split('\0').filter(Boolean);
1163
+ for (const filePath of files) {
1164
+ try {
1165
+ const { stdout: diff } = await execFileAsync('git', ['diff', '--no-index', '--', '/dev/null', filePath], {
1166
+ cwd: repoRoot,
1167
+ timeout: 30_000,
1168
+ maxBuffer: 2 * 1024 * 1024,
1169
+ });
1170
+ if (diff)
1171
+ pieces.push(diff);
1172
+ }
1173
+ catch (error) {
1174
+ const maybe = error;
1175
+ if (maybe.stdout)
1176
+ pieces.push(maybe.stdout);
1177
+ }
1178
+ }
1179
+ const diff = pieces.join('\n');
1180
+ return { files, diff, bytes: Buffer.byteLength(diff, 'utf8') };
1181
+ }).catch(() => ({ files: [], diff: '', bytes: 0 })),
1182
+ ]);
1183
+ const trackedDiff = [cached, worktree].filter(Boolean).join('\n');
1184
+ return {
1185
+ diff: [trackedDiff, untracked.diff].filter(Boolean).join('\n'),
1186
+ trackedDiffBytes: Buffer.byteLength(trackedDiff, 'utf8'),
1187
+ untrackedFiles: untracked.files,
1188
+ untrackedDiffBytes: untracked.bytes,
1189
+ };
1190
+ }
1191
+ function parseAuditHunks(target, diffText) {
1192
+ const hunks = [];
1193
+ const lines = diffText.split('\n');
1194
+ let oldPath = null;
1195
+ let newPath = null;
1196
+ let currentHeader = null;
1197
+ let currentLines = [];
1198
+ let hunkIndexByFile = 0;
1199
+ const flush = () => {
1200
+ if (!currentHeader || (!oldPath && !newPath))
1201
+ return;
1202
+ const filePath = newPath ?? oldPath ?? '';
1203
+ const displayPath = target.relativeRoot === '.' ? filePath : `${target.relativeRoot}/${filePath}`;
1204
+ const oldDiffPath = oldPath ? `a/${oldPath}` : '/dev/null';
1205
+ const newDiffPath = newPath ? `b/${newPath}` : '/dev/null';
1206
+ const hunkDiff = [
1207
+ `diff --git a/${oldPath ?? filePath} b/${newPath ?? filePath}`,
1208
+ `--- ${oldDiffPath}`,
1209
+ `+++ ${newDiffPath}`,
1210
+ currentHeader,
1211
+ ...currentLines,
1212
+ ].join('\n');
1213
+ const fingerprint = buildHunkChangeFingerprint(currentLines);
1214
+ const id = createHash('sha256')
1215
+ .update(`${target.repoRoot}\0${filePath}\0${currentHeader}\0${hunkIndexByFile}\0${fingerprint}`, 'utf8')
1216
+ .digest('hex')
1217
+ .slice(0, 16);
1218
+ hunks.push({
1219
+ id,
1220
+ workspaceRoot: target.workspaceRoot,
1221
+ repoRoot: target.repoRoot,
1222
+ displayRoot: target.displayRoot,
1223
+ relativeRoot: target.relativeRoot,
1224
+ filePath,
1225
+ displayPath,
1226
+ oldPath,
1227
+ newPath,
1228
+ hunkHeader: currentHeader,
1229
+ hunkIndex: hunkIndexByFile,
1230
+ fingerprint,
1231
+ additions: currentLines.filter((line) => line.startsWith('+') && !line.startsWith('+++')).length,
1232
+ deletions: currentLines.filter((line) => line.startsWith('-') && !line.startsWith('---')).length,
1233
+ sections: buildAuditSections(currentLines, currentHeader),
1234
+ diff: hunkDiff,
1235
+ });
1236
+ hunkIndexByFile += 1;
1237
+ };
1238
+ for (const line of lines) {
1239
+ if (line.startsWith('diff --git ')) {
1240
+ flush();
1241
+ currentHeader = null;
1242
+ currentLines = [];
1243
+ hunkIndexByFile = 0;
1244
+ const match = /^diff --git (.+) (.+)$/.exec(line);
1245
+ oldPath = normalizeDiffPath(match?.[1] ?? '');
1246
+ newPath = normalizeDiffPath(match?.[2] ?? '');
1247
+ continue;
1248
+ }
1249
+ if (line.startsWith('--- ')) {
1250
+ oldPath = normalizeDiffPath(line.slice(4).trim());
1251
+ continue;
1252
+ }
1253
+ if (line.startsWith('+++ ')) {
1254
+ newPath = normalizeDiffPath(line.slice(4).trim());
1255
+ continue;
1256
+ }
1257
+ if (line.startsWith('@@ ')) {
1258
+ flush();
1259
+ currentHeader = line;
1260
+ currentLines = [];
1261
+ continue;
1262
+ }
1263
+ if (currentHeader)
1264
+ currentLines.push(line);
1265
+ }
1266
+ flush();
1267
+ return hunks;
1268
+ }
1269
+ async function getAuditHunksWithCoverage(cwdInput) {
1270
+ const workspaceRoot = typeof cwdInput === 'string' ? path.resolve(cwdInput) : process.cwd();
1271
+ const targets = await discoverAuditRepositories(workspaceRoot);
1272
+ const byRepo = await Promise.all(targets.map(async (target) => {
1273
+ const result = await readWorkingTreeDiff(target.repoRoot).catch(() => ({
1274
+ diff: '',
1275
+ trackedDiffBytes: 0,
1276
+ untrackedFiles: [],
1277
+ untrackedDiffBytes: 0,
1278
+ }));
1279
+ return {
1280
+ hunks: parseAuditHunks(target, result.diff),
1281
+ coverage: {
1282
+ repoRoot: target.repoRoot,
1283
+ relativeRoot: target.relativeRoot,
1284
+ trackedDiffBytes: result.trackedDiffBytes,
1285
+ untrackedFiles: result.untrackedFiles,
1286
+ untrackedDiffBytes: result.untrackedDiffBytes,
1287
+ },
1288
+ };
1289
+ }));
1290
+ return {
1291
+ hunks: byRepo.flatMap((entry) => entry.hunks),
1292
+ coverage: byRepo.map((entry) => entry.coverage),
1293
+ };
1294
+ }
1295
+ async function getAuditHunks(cwdInput) {
1296
+ return (await getAuditHunksWithCoverage(cwdInput)).hunks;
1297
+ }
1298
+ function normalizeAuditSourceRoot(cwdInput) {
1299
+ return typeof cwdInput === 'string' ? path.resolve(cwdInput) : process.cwd();
1300
+ }
1301
+ function isChangeAuditCliHunk(value) {
1302
+ if (!value || typeof value !== 'object')
1303
+ return false;
1304
+ const hunk = value;
1305
+ return typeof hunk.id === 'string'
1306
+ && typeof hunk.workspaceRoot === 'string'
1307
+ && typeof hunk.repoRoot === 'string'
1308
+ && typeof hunk.filePath === 'string'
1309
+ && typeof hunk.hunkHeader === 'string'
1310
+ && typeof hunk.fingerprint === 'string';
1311
+ }
1312
+ function readChangeAuditSnapshot(source) {
1313
+ try {
1314
+ const parsed = JSON.parse(fs.readFileSync(changeAuditSnapshotPath, 'utf8'));
1315
+ if (parsed.version !== 1 || !Array.isArray(parsed.hunks) || typeof parsed.createdAt !== 'number')
1316
+ return null;
1317
+ if (Date.now() - parsed.createdAt > CHANGE_AUDIT_SNAPSHOT_MAX_AGE_MS)
1318
+ return null;
1319
+ const sourceRoot = normalizeAuditSourceRoot(source);
1320
+ if (typeof parsed.sourceRoot !== 'string' || path.resolve(parsed.sourceRoot) !== sourceRoot)
1321
+ return null;
1322
+ const hunks = parsed.hunks.filter(isChangeAuditCliHunk);
1323
+ return { version: 1, createdAt: parsed.createdAt, sourceRoot: parsed.sourceRoot, hunks };
1324
+ }
1325
+ catch {
1326
+ return null;
1327
+ }
1328
+ }
1329
+ function writeChangeAuditSnapshot(source, hunks) {
1330
+ try {
1331
+ fs.mkdirSync(stateDir, { recursive: true });
1332
+ const payload = {
1333
+ version: 1,
1334
+ createdAt: Date.now(),
1335
+ sourceRoot: normalizeAuditSourceRoot(source),
1336
+ hunks,
1337
+ };
1338
+ fs.writeFileSync(changeAuditSnapshotPath, JSON.stringify(payload), { mode: 0o600 });
1339
+ }
1340
+ catch {
1341
+ // Snapshot writes are best-effort; the CLI can always fall back to live git diff.
1342
+ }
1343
+ }
1344
+ async function getAuditHunkById(id, cwdInput) {
1345
+ const snapshot = readChangeAuditSnapshot(cwdInput);
1346
+ const cached = snapshot?.hunks.find((candidate) => candidate.id === id);
1347
+ if (cached)
1348
+ return cached;
1349
+ return (await getAuditHunks(cwdInput)).find((candidate) => candidate.id === id) ?? null;
1350
+ }
1351
+ async function readBranchDiff(repoRoot, baseInput) {
1352
+ const base = baseInput.trim();
1353
+ let baseRef = base.includes('/') ? base : `origin/${base}`;
1354
+ if (base.includes('/')) {
1355
+ await execFileAsync('git', ['rev-parse', '--verify', '--quiet', baseRef], { cwd: repoRoot, timeout: 10_000, maxBuffer: 128 * 1024 });
1356
+ }
1357
+ else {
1358
+ try {
1359
+ await execFileAsync('git', ['fetch', 'origin', base, '--no-tags'], { cwd: repoRoot, timeout: 30_000, maxBuffer: 512 * 1024 });
1360
+ }
1361
+ catch {
1362
+ await execFileAsync('git', ['rev-parse', '--verify', '--quiet', base], { cwd: repoRoot, timeout: 10_000, maxBuffer: 128 * 1024 });
1363
+ baseRef = base;
1364
+ }
1365
+ }
1366
+ const [branchName, headRef, branchDiff, workingDiff, untrackedDiff] = await Promise.all([
1367
+ execFileAsync('git', ['branch', '--show-current'], { cwd: repoRoot, timeout: 10_000, maxBuffer: 128 * 1024 }).then((r) => r.stdout.trim() || null).catch(() => null),
1368
+ execFileAsync('git', ['rev-parse', '--short=12', 'HEAD'], { cwd: repoRoot, timeout: 10_000, maxBuffer: 128 * 1024 }).then((r) => r.stdout.trim() || null).catch(() => null),
1369
+ execFileAsync('git', ['diff', `${baseRef}...HEAD`], { cwd: repoRoot, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }).then((r) => r.stdout).catch(() => ''),
1370
+ execFileAsync('git', ['diff', 'HEAD'], { cwd: repoRoot, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }).then((r) => r.stdout).catch(() => ''),
1371
+ execFileAsync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: repoRoot, timeout: 30_000, maxBuffer: 4 * 1024 * 1024 }).then(async ({ stdout }) => {
1372
+ const pieces = [];
1373
+ for (const filePath of stdout.split('\0').filter(Boolean)) {
1374
+ try {
1375
+ const { stdout: diff } = await execFileAsync('git', ['diff', '--no-index', '--', '/dev/null', filePath], {
1376
+ cwd: repoRoot,
1377
+ timeout: 30_000,
1378
+ maxBuffer: 2 * 1024 * 1024,
1379
+ });
1380
+ if (diff)
1381
+ pieces.push(diff);
1382
+ }
1383
+ catch (error) {
1384
+ const maybe = error;
1385
+ if (maybe.stdout)
1386
+ pieces.push(maybe.stdout);
1387
+ }
1388
+ }
1389
+ return pieces.join('\n');
1390
+ }).catch(() => ''),
1391
+ ]);
1392
+ const diff = [branchDiff, workingDiff, untrackedDiff].filter(Boolean).join('\n');
1393
+ const diffFingerprint = createHash('sha256').update([repoRoot, baseRef, branchName ?? '', headRef ?? '', diff].join('\n'), 'utf8').digest('hex').slice(0, 16);
1394
+ return { baseRef, branchName, headRef, diff, diffFingerprint };
1395
+ }
1396
+ async function runBranchAuditExport(request) {
1397
+ const workspaceRoot = path.resolve(request.cwd ?? process.cwd());
1398
+ const repoRoot = await getGitRoot(workspaceRoot);
1399
+ const target = {
1400
+ workspaceRoot,
1401
+ repoRoot,
1402
+ displayRoot: workspaceRoot,
1403
+ relativeRoot: '.',
1404
+ };
1405
+ const branchDiff = await readBranchDiff(repoRoot, request.base);
1406
+ const hunks = parseAuditHunks(target, branchDiff.diff);
1407
+ console.log(JSON.stringify({
1408
+ version: 1,
1409
+ scope: 'branch',
1410
+ workspaceRoot,
1411
+ repoRoot,
1412
+ baseRef: branchDiff.baseRef,
1413
+ branchName: branchDiff.branchName,
1414
+ headRef: branchDiff.headRef,
1415
+ diffFingerprint: branchDiff.diffFingerprint,
1416
+ count: hunks.length,
1417
+ hunks,
1418
+ }, null, 2));
1419
+ }
1420
+ async function runChangeAuditList(source) {
1421
+ const hunks = await getAuditHunks(source);
1422
+ writeChangeAuditSnapshot(source, hunks);
1423
+ console.log(JSON.stringify({
1424
+ version: 1,
1425
+ count: hunks.length,
1426
+ hunks: hunks.map(({ diff, ...hunk }) => hunk),
1427
+ }, null, 2));
1428
+ }
1429
+ async function runChangeAuditExport(source) {
1430
+ const { hunks, coverage } = await getAuditHunksWithCoverage(source);
1431
+ writeChangeAuditSnapshot(source, hunks);
1432
+ console.log(JSON.stringify({
1433
+ version: 1,
1434
+ count: hunks.length,
1435
+ coverage,
1436
+ hunks,
1437
+ }, null, 2));
1438
+ }
1439
+ async function runChangeAuditShow(request) {
1440
+ const hunk = await getAuditHunkById(request.id, request.cwd);
1441
+ if (!hunk) {
1442
+ console.error(`${ICON.err} ${c.red(`No current diff hunk found for id ${request.id}`)}`);
1443
+ process.exit(1);
1444
+ }
1445
+ console.log(JSON.stringify(hunk, null, 2));
1446
+ }
1447
+ async function runInjectChangeAuditHunk(request) {
1448
+ const explanation = (await readStdinText()).trim();
1449
+ if (!explanation) {
1450
+ console.error(`${ICON.err} ${c.red('Explanation is empty. Pipe explanation text into this command.')}`);
1451
+ process.exit(1);
1452
+ }
1453
+ const hunk = await getAuditHunkById(request.id, request.cwd);
1454
+ if (!hunk) {
1455
+ console.error(`${ICON.err} ${c.red(`No current diff hunk found for id ${request.id}`)}`);
1456
+ process.exit(1);
1457
+ }
1458
+ const payload = {
1459
+ workspaceRoot: hunk.workspaceRoot,
1460
+ repoRoot: hunk.repoRoot,
1461
+ generatedBy: 'ai-cli',
1462
+ records: [{
1463
+ repoRoot: hunk.repoRoot,
1464
+ filePath: hunk.filePath,
1465
+ oldPath: hunk.oldPath,
1466
+ newPath: hunk.newPath,
1467
+ hunkHeader: hunk.hunkHeader,
1468
+ hunkIndex: hunk.hunkIndex,
1469
+ fingerprint: hunk.fingerprint,
1470
+ summary: explanation.split('\n')[0]?.slice(0, 120) || null,
1471
+ explanation,
1472
+ }],
1473
+ };
1474
+ await postChangeAuditPayload(payload);
1475
+ }
1476
+ const options = parseArgs(process.argv.slice(2));
1477
+ if (options.setPassword && options.clearPassword) {
1478
+ console.error(`${ICON.err} ${c.red('Cannot use --set-password and --clear-password together.')}`);
1479
+ process.exit(1);
1480
+ }
1481
+ // Print a clear, attention-grabbing warning whenever the server starts without
1482
+ // a password set. Easy to miss otherwise — and the security implications
1483
+ // (anyone on the LAN can drive your shell) are real.
1484
+ function warnIfAuthDisabled(host) {
1485
+ if (isAuthEnabled())
1486
+ return;
1487
+ const exposedToLan = host === '0.0.0.0' || (host !== '127.0.0.1' && host !== 'localhost');
1488
+ console.log('');
1489
+ console.log(c.yellow(' ╭─────────────────────────────────────────────────────────────╮'));
1490
+ console.log(c.yellow(' │ ') + ICON.warn + c.bold(c.yellow(' Authentication is DISABLED'))
1491
+ + c.yellow(' │'));
1492
+ console.log(c.yellow(' │ ') + c.dim('Anyone who can reach this server can run shell commands.')
1493
+ + c.yellow(' │'));
1494
+ if (exposedToLan) {
1495
+ console.log(c.yellow(' │ ') + c.dim('The server is bound to ') + c.red(host)
1496
+ + c.dim(' — reachable from the LAN.') + c.yellow(' │'));
1497
+ }
1498
+ console.log(c.yellow(' │ ') + c.dim('Set a password with: ') + c.cyan('td --set-password')
1499
+ + c.yellow(' │'));
1500
+ console.log(c.yellow(' ╰─────────────────────────────────────────────────────────────╯'));
1501
+ console.log('');
1502
+ }
1503
+ function normalizeTmuxSessionName(input) {
1504
+ if (typeof input !== 'string') {
1505
+ const timePart = Date.now().toString(36);
1506
+ const randomPart = Math.random().toString(36).slice(2, 8);
1507
+ return `wt-${timePart}${randomPart}`;
1508
+ }
1509
+ const normalized = input.trim();
1510
+ if (normalized.length > 0)
1511
+ return normalized;
1512
+ const timePart = Date.now().toString(36);
1513
+ const randomPart = Math.random().toString(36).slice(2, 8);
1514
+ return `wt-${timePart}${randomPart}`;
1515
+ }
1516
+ async function tmuxSessionExists(sessionName) {
1517
+ try {
1518
+ await execFileAsync('tmux', ['has-session', '-t', sessionName], { timeout: 5000, maxBuffer: 64 * 1024 });
1519
+ return true;
1520
+ }
1521
+ catch {
1522
+ return false;
1523
+ }
1524
+ }
1525
+ async function setTmuxOption(sessionName, key, value) {
1526
+ await execFileAsync('tmux', ['set-option', '-t', sessionName, key, value], {
1527
+ timeout: 5000,
1528
+ maxBuffer: 256 * 1024,
1529
+ });
1530
+ }
1531
+ async function ensureTmuxFocusEvents() {
1532
+ const { stdout } = await execFileAsync('tmux', ['show-options', '-gqv', 'focus-events'], {
1533
+ timeout: 5000,
1534
+ maxBuffer: 64 * 1024,
1535
+ });
1536
+ if (stdout.trim() === 'on')
1537
+ return;
1538
+ await execFileAsync('tmux', ['set-option', '-g', 'focus-events', 'on'], {
1539
+ timeout: 5000,
1540
+ maxBuffer: 64 * 1024,
1541
+ });
1542
+ }
1543
+ async function ensureTmuxScrollbackProfile(sessionName) {
1544
+ await execFileAsync('tmux', ['set-option', '-g', 'history-limit', String(TERMDOCK_TMUX_HISTORY_LIMIT)], {
1545
+ timeout: 5000,
1546
+ maxBuffer: 64 * 1024,
1547
+ });
1548
+ await execFileAsync('tmux', ['set-option', '-t', sessionName, 'history-limit', String(TERMDOCK_TMUX_HISTORY_LIMIT)], {
1549
+ timeout: 5000,
1550
+ maxBuffer: 64 * 1024,
1551
+ });
1552
+ try {
1553
+ await execFileAsync('tmux', ['set-option', '-gw', 'scroll-on-clear', 'off'], {
1554
+ timeout: 5000,
1555
+ maxBuffer: 64 * 1024,
1556
+ });
1557
+ const { stdout } = await execFileAsync('tmux', ['list-windows', '-t', sessionName, '-F', '#{window_id}'], {
1558
+ timeout: 5000,
1559
+ maxBuffer: 256 * 1024,
1560
+ });
1561
+ const windowIds = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
1562
+ for (const windowId of windowIds) {
1563
+ await execFileAsync('tmux', ['set-option', '-w', '-t', windowId, 'scroll-on-clear', 'off'], {
1564
+ timeout: 5000,
1565
+ maxBuffer: 64 * 1024,
1566
+ });
1567
+ }
1568
+ }
1569
+ catch {
1570
+ // Older tmux versions do not have scroll-on-clear; keep CLI session creation working.
1571
+ }
1572
+ }
1573
+ async function ensureTmuxCliSessionOptions(sessionName) {
1574
+ await execFileAsync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], {
1575
+ timeout: 5000,
1576
+ maxBuffer: 64 * 1024,
1577
+ });
1578
+ await execFileAsync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], {
1579
+ timeout: 5000,
1580
+ maxBuffer: 64 * 1024,
1581
+ });
1582
+ await ensureTmuxScrollbackProfile(sessionName);
1583
+ }
1584
+ async function ensureTmuxColorEnvironment(sessionName) {
1585
+ const forceColor = process.env.TERMDOCK_FORCE_COLOR === '1';
1586
+ await execFileAsync('tmux', ['set-environment', '-g', 'COLORTERM', 'truecolor'], {
1587
+ timeout: 5000,
1588
+ maxBuffer: 64 * 1024,
1589
+ });
1590
+ if (forceColor) {
1591
+ await execFileAsync('tmux', ['set-environment', '-g', 'FORCE_COLOR', '1'], {
1592
+ timeout: 5000,
1593
+ maxBuffer: 64 * 1024,
1594
+ });
1595
+ await execFileAsync('tmux', ['set-environment', '-g', '-u', 'NO_COLOR'], {
1596
+ timeout: 5000,
1597
+ maxBuffer: 64 * 1024,
1598
+ });
1599
+ }
1600
+ else {
1601
+ await execFileAsync('tmux', ['set-environment', '-g', '-u', 'FORCE_COLOR'], {
1602
+ timeout: 5000,
1603
+ maxBuffer: 64 * 1024,
1604
+ });
1605
+ }
1606
+ if (!sessionName)
1607
+ return;
1608
+ await execFileAsync('tmux', ['set-environment', '-t', sessionName, 'COLORTERM', 'truecolor'], {
1609
+ timeout: 5000,
1610
+ maxBuffer: 64 * 1024,
1611
+ });
1612
+ if (forceColor) {
1613
+ await execFileAsync('tmux', ['set-environment', '-t', sessionName, 'FORCE_COLOR', '1'], {
1614
+ timeout: 5000,
1615
+ maxBuffer: 64 * 1024,
1616
+ });
1617
+ await execFileAsync('tmux', ['set-environment', '-t', sessionName, '-u', 'NO_COLOR'], {
1618
+ timeout: 5000,
1619
+ maxBuffer: 64 * 1024,
1620
+ });
1621
+ }
1622
+ else {
1623
+ await execFileAsync('tmux', ['set-environment', '-t', sessionName, '-u', 'FORCE_COLOR'], {
1624
+ timeout: 5000,
1625
+ maxBuffer: 64 * 1024,
1626
+ });
1627
+ }
1628
+ }
1629
+ async function getTmuxOption(sessionName, key) {
1630
+ try {
1631
+ const { stdout } = await execFileAsync('tmux', ['show-option', '-vqt', sessionName, key], {
1632
+ timeout: 5000,
1633
+ maxBuffer: 128 * 1024,
1634
+ });
1635
+ const value = stdout.trim();
1636
+ return value.length > 0 ? value : null;
1637
+ }
1638
+ catch {
1639
+ return null;
1640
+ }
1641
+ }
1642
+ function resolveInvocationCwd() {
1643
+ const fallback = process.cwd();
1644
+ const pwd = process.env.PWD;
1645
+ if (!pwd || !path.isAbsolute(pwd))
1646
+ return fallback;
1647
+ try {
1648
+ const pwdStat = fs.statSync(pwd);
1649
+ const fallbackStat = fs.statSync(fallback);
1650
+ if (pwdStat.dev === fallbackStat.dev && pwdStat.ino === fallbackStat.ino && pwdStat.isDirectory()) {
1651
+ return pwd;
1652
+ }
1653
+ }
1654
+ catch {
1655
+ // Fall back to Node's cwd when the shell-provided PWD is stale.
1656
+ }
1657
+ return fallback;
1658
+ }
1659
+ function shellQuote(value) {
1660
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1661
+ }
1662
+ function resolveUserShell() {
1663
+ const shell = process.env.SHELL;
1664
+ if (shell && path.isAbsolute(shell) && fs.existsSync(shell)) {
1665
+ return shell;
1666
+ }
1667
+ return process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
1668
+ }
1669
+ async function ensureStampedTmuxSession(sessionName, cwd) {
1670
+ const exists = await tmuxSessionExists(sessionName);
1671
+ if (!exists) {
1672
+ const args = ['new-session', '-d', '-s', sessionName, '-e', 'COLORTERM=truecolor'];
1673
+ if (process.env.TERMDOCK_FORCE_COLOR === '1') {
1674
+ args.push('-e', 'FORCE_COLOR=1');
1675
+ }
1676
+ if (cwd) {
1677
+ args.push('-c', cwd);
1678
+ }
1679
+ if (cwd && process.platform !== 'win32') {
1680
+ args.push(`cd ${shellQuote(cwd)} && exec ${shellQuote(resolveUserShell())}`);
1681
+ }
1682
+ await execFileAsync('tmux', args, {
1683
+ timeout: 5000,
1684
+ maxBuffer: 256 * 1024,
1685
+ });
1686
+ }
1687
+ await ensureTmuxFocusEvents();
1688
+ await ensureTmuxColorEnvironment(sessionName);
1689
+ await ensureTmuxCliSessionOptions(sessionName);
1690
+ await setTmuxOption(sessionName, '@termdock-version', TERMDOCK_VERSION);
1691
+ await setTmuxOption(sessionName, '@termdock-host', TERMDOCK_HOST);
1692
+ await setTmuxOption(sessionName, '@termdock-pid', TERMDOCK_PID);
1693
+ const existingCreatedAt = await getTmuxOption(sessionName, '@termdock-created-at');
1694
+ if (!existingCreatedAt) {
1695
+ await setTmuxOption(sessionName, '@termdock-created-at', String(Date.now()));
1696
+ }
1697
+ if (!exists && cwd) {
1698
+ await setTmuxOption(sessionName, '@termdock-cwd', cwd);
1699
+ await setTmuxOption(sessionName, '@termdock-label', getCwdLeafName(cwd));
1700
+ }
1701
+ return { sessionName, created: !exists };
1702
+ }
1703
+ const TLS_FIELDS = [
1704
+ 'name',
1705
+ 'attachedCount',
1706
+ 'friendlyName',
1707
+ 'program',
1708
+ 'cwd',
1709
+ 'label',
1710
+ 'clientCount',
1711
+ 'host',
1712
+ 'pid',
1713
+ 'version',
1714
+ 'createdAt',
1715
+ 'lastActiveAt',
1716
+ ];
1717
+ const TLS_FORMAT = [
1718
+ '#{session_name}',
1719
+ '#{session_attached}',
1720
+ '#{@termdock-friendly-name}',
1721
+ '#{@termdock-program}',
1722
+ '#{@termdock-cwd}',
1723
+ '#{@termdock-label}',
1724
+ '#{@termdock-client-count}',
1725
+ '#{@termdock-host}',
1726
+ '#{@termdock-pid}',
1727
+ '#{@termdock-version}',
1728
+ '#{@termdock-created-at}',
1729
+ '#{@termdock-last-active-at}',
1730
+ ].join('\t');
1731
+ function formatIdle(lastActiveAtRaw) {
1732
+ const ts = Number(lastActiveAtRaw);
1733
+ if (!Number.isFinite(ts) || ts <= 0)
1734
+ return '-';
1735
+ const diffMs = Date.now() - ts;
1736
+ if (diffMs < 0)
1737
+ return '0s';
1738
+ const sec = Math.floor(diffMs / 1000);
1739
+ if (sec < 60)
1740
+ return `${sec}s`;
1741
+ const min = Math.floor(sec / 60);
1742
+ if (min < 60)
1743
+ return `${min}m`;
1744
+ const hr = Math.floor(min / 60);
1745
+ if (hr < 24)
1746
+ return `${hr}h`;
1747
+ const days = Math.floor(hr / 24);
1748
+ return `${days}d`;
1749
+ }
1750
+ function getCwdLeafName(cwd) {
1751
+ if (cwd === '/')
1752
+ return '/';
1753
+ const segments = cwd.replace(/\/+$/, '').split('/');
1754
+ return segments[segments.length - 1] || cwd;
1755
+ }
1756
+ function buildTmuxFolderGroups(rows) {
1757
+ const groups = [];
1758
+ const byKey = new Map();
1759
+ for (const row of rows) {
1760
+ const key = row.cwd.trim().length > 0 ? row.cwd.trim() : '';
1761
+ let group = byKey.get(key);
1762
+ if (!group) {
1763
+ group = {
1764
+ key,
1765
+ label: key ? getCwdLeafName(key) : 'Other',
1766
+ sessions: [],
1767
+ };
1768
+ byKey.set(key, group);
1769
+ groups.push(group);
1770
+ }
1771
+ group.sessions.push(row);
1772
+ }
1773
+ return groups.sort((a, b) => (a.key === '' ? 1 : 0) - (b.key === '' ? 1 : 0));
1774
+ }
1775
+ function normalizePersistedCliSession(input) {
1776
+ if (!input || typeof input !== 'object')
1777
+ return null;
1778
+ const candidate = input;
1779
+ if (typeof candidate.sessionId !== 'string' || typeof candidate.name !== 'string') {
1780
+ return null;
1781
+ }
1782
+ return {
1783
+ sessionId: candidate.sessionId,
1784
+ name: candidate.name,
1785
+ customName: candidate.customName === true ? true : undefined,
1786
+ backendSessionId: typeof candidate.backendSessionId === 'string' && candidate.backendSessionId.trim().length > 0
1787
+ ? candidate.backendSessionId
1788
+ : null,
1789
+ mode: candidate.mode === 'tmux' ? 'tmux' : 'shell',
1790
+ tmuxSessionName: typeof candidate.tmuxSessionName === 'string' && candidate.tmuxSessionName.trim().length > 0
1791
+ ? candidate.tmuxSessionName
1792
+ : null,
1793
+ createdAt: typeof candidate.createdAt === 'number' && Number.isFinite(candidate.createdAt)
1794
+ ? Math.floor(candidate.createdAt)
1795
+ : Date.now(),
1796
+ lastActivity: typeof candidate.lastActivity === 'number' && Number.isFinite(candidate.lastActivity)
1797
+ ? Math.floor(candidate.lastActivity)
1798
+ : Date.now(),
1799
+ };
1800
+ }
1801
+ function readGlobalCliSessionState() {
1802
+ try {
1803
+ if (!fs.existsSync(globalSessionStateFilePath)) {
1804
+ return { sessions: [], updatedAt: Date.now() };
1805
+ }
1806
+ const raw = fs.readFileSync(globalSessionStateFilePath, 'utf8');
1807
+ const parsed = JSON.parse(raw);
1808
+ const sessions = Array.isArray(parsed.sessions)
1809
+ ? parsed.sessions
1810
+ .map((session) => normalizePersistedCliSession(session))
1811
+ .filter((session) => session !== null)
1812
+ : [];
1813
+ return {
1814
+ sessions,
1815
+ updatedAt: typeof parsed.updatedAt === 'number' && Number.isFinite(parsed.updatedAt)
1816
+ ? Math.floor(parsed.updatedAt)
1817
+ : Date.now(),
1818
+ };
1819
+ }
1820
+ catch {
1821
+ return { sessions: [], updatedAt: Date.now() };
1822
+ }
1823
+ }
1824
+ function writeGlobalCliSessionState(state) {
1825
+ ensureStateDir();
1826
+ fs.writeFileSync(globalSessionStateFilePath, JSON.stringify(state, null, 2));
1827
+ }
1828
+ function registerGuiTmuxSession(sessionName) {
1829
+ const now = Date.now();
1830
+ const state = readGlobalCliSessionState();
1831
+ const nextRecord = {
1832
+ sessionId: randomUUID(),
1833
+ name: `tmux:${sessionName}`,
1834
+ backendSessionId: null,
1835
+ mode: 'tmux',
1836
+ tmuxSessionName: sessionName,
1837
+ createdAt: now,
1838
+ lastActivity: now,
1839
+ };
1840
+ const nextSessions = [];
1841
+ let replaced = false;
1842
+ for (const session of state.sessions) {
1843
+ if (session.mode === 'tmux' && session.tmuxSessionName === sessionName) {
1844
+ nextSessions.push({
1845
+ ...session,
1846
+ lastActivity: now,
1847
+ });
1848
+ replaced = true;
1849
+ continue;
1850
+ }
1851
+ nextSessions.push(session);
1852
+ }
1853
+ if (!replaced) {
1854
+ nextSessions.push(nextRecord);
1855
+ }
1856
+ writeGlobalCliSessionState({
1857
+ sessions: nextSessions,
1858
+ updatedAt: now,
1859
+ });
1860
+ }
1861
+ function toSortableTs(raw) {
1862
+ const value = Number(raw);
1863
+ return Number.isFinite(value) && value > 0 ? value : Number.POSITIVE_INFINITY;
1864
+ }
1865
+ function compareTmuxRowsByCreation(a, b) {
1866
+ const byCreatedAt = toSortableTs(a.createdAt) - toSortableTs(b.createdAt);
1867
+ if (byCreatedAt !== 0)
1868
+ return byCreatedAt;
1869
+ return a.name.localeCompare(b.name);
1870
+ }
1871
+ function readPersistedTmuxOrder() {
1872
+ try {
1873
+ const raw = fs.readFileSync(globalSessionStateFilePath, 'utf8');
1874
+ const parsed = JSON.parse(raw);
1875
+ const order = new Map();
1876
+ const sessions = Array.isArray(parsed.sessions) ? parsed.sessions : [];
1877
+ sessions.forEach((entry, index) => {
1878
+ if (!entry || typeof entry !== 'object')
1879
+ return;
1880
+ const session = entry;
1881
+ if (session.mode !== 'tmux')
1882
+ return;
1883
+ if (typeof session.tmuxSessionName !== 'string' || session.tmuxSessionName.trim().length === 0)
1884
+ return;
1885
+ if (!order.has(session.tmuxSessionName)) {
1886
+ order.set(session.tmuxSessionName, index);
1887
+ }
1888
+ });
1889
+ return order;
1890
+ }
1891
+ catch {
1892
+ return new Map();
1893
+ }
1894
+ }
1895
+ function compareTmuxRowsByPersistedOrder(order) {
1896
+ return (a, b) => {
1897
+ const aRank = order.get(a.name) ?? Number.POSITIVE_INFINITY;
1898
+ const bRank = order.get(b.name) ?? Number.POSITIVE_INFINITY;
1899
+ if (aRank !== bRank)
1900
+ return aRank - bRank;
1901
+ return compareTmuxRowsByCreation(a, b);
1902
+ };
1903
+ }
1904
+ function getTmuxDisplayTitle(row) {
1905
+ return row.friendlyName || row.label || row.name || '(unnamed)';
1906
+ }
1907
+ function getTmuxDisplaySubtitle(row) {
1908
+ if (!row.friendlyName && !row.label)
1909
+ return null;
1910
+ return `tmux:${row.name}`;
1911
+ }
1912
+ function formatTmuxConnectionLabels(row) {
1913
+ const labels = [];
1914
+ if (row.clientCount && row.clientCount !== '0') {
1915
+ labels.push(`Web clients ${row.clientCount}`);
1916
+ }
1917
+ if (row.attachedCount && row.attachedCount !== '0') {
1918
+ labels.push(`Native tmux ${row.attachedCount}`);
1919
+ }
1920
+ return labels;
1921
+ }
1922
+ function normalizeMatchText(value) {
1923
+ return value.trim().toLowerCase();
1924
+ }
1925
+ function tmuxRowAliases(row) {
1926
+ return [row.name, row.friendlyName, row.label]
1927
+ .map((value) => value.trim())
1928
+ .filter((value, index, values) => value.length > 0 && values.indexOf(value) === index);
1929
+ }
1930
+ function currentCwdTmuxRows(rows) {
1931
+ const cwd = resolveInvocationCwd();
1932
+ return rows.filter((row) => row.cwd === cwd);
1933
+ }
1934
+ function resolveTmuxAttachTarget(input, candidates) {
1935
+ const query = normalizeMatchText(input);
1936
+ if (!query)
1937
+ return { row: null, ambiguous: [], suggestions: [] };
1938
+ if (input.trim() === '.') {
1939
+ const cwdMatches = currentCwdTmuxRows(candidates);
1940
+ if (cwdMatches.length === 1) {
1941
+ return { row: cwdMatches[0], ambiguous: [], suggestions: [] };
1942
+ }
1943
+ if (cwdMatches.length > 1) {
1944
+ return { row: null, ambiguous: cwdMatches, suggestions: [] };
1945
+ }
1946
+ }
1947
+ const exactMatches = candidates.filter((row) => tmuxRowAliases(row).some((alias) => normalizeMatchText(alias) === query));
1948
+ if (exactMatches.length === 1) {
1949
+ return { row: exactMatches[0], ambiguous: [], suggestions: [] };
1950
+ }
1951
+ if (exactMatches.length > 1) {
1952
+ return { row: null, ambiguous: exactMatches, suggestions: [] };
1953
+ }
1954
+ const partialMatches = candidates.filter((row) => tmuxRowAliases(row).some((alias) => normalizeMatchText(alias).includes(query)));
1955
+ if (partialMatches.length === 1) {
1956
+ return { row: partialMatches[0], ambiguous: [], suggestions: [] };
1957
+ }
1958
+ if (partialMatches.length > 1) {
1959
+ return { row: null, ambiguous: partialMatches, suggestions: [] };
1960
+ }
1961
+ return { row: null, ambiguous: [], suggestions: candidates.slice(0, 6) };
1962
+ }
1963
+ function buildAttachCandidates(rows, includeAll) {
1964
+ const managed = rows.filter((row) => row.version.length > 0);
1965
+ const selected = includeAll || managed.length === 0 ? rows : managed;
1966
+ const tmuxOrder = readPersistedTmuxOrder();
1967
+ return selected.slice().sort(compareTmuxRowsByPersistedOrder(tmuxOrder));
1968
+ }
1969
+ function renderBlocks(rows) {
1970
+ const blocks = [];
1971
+ for (const row of rows) {
1972
+ const heading = getTmuxDisplayTitle(row);
1973
+ const subtitle = getTmuxDisplaySubtitle(row);
1974
+ const lines = [];
1975
+ lines.push(`${c.bold(c.cyan('●'))} ${c.bold(heading)}${subtitle ? ` ${c.dim(`(${subtitle})`)}` : ''}`);
1976
+ const kv = [];
1977
+ if (row.program)
1978
+ kv.push(['Program', row.program]);
1979
+ if (row.cwd)
1980
+ kv.push(['CWD', row.cwd]);
1981
+ const meta = [];
1982
+ meta.push(...formatTmuxConnectionLabels(row));
1983
+ meta.push(`idle=${formatIdle(row.lastActiveAt)}`);
1984
+ if (row.host)
1985
+ meta.push(`host=${row.host}`);
1986
+ if (row.version)
1987
+ meta.push(`v${row.version}`);
1988
+ kv.push(['Status', meta.join(' ')]);
1989
+ const keyWidth = Math.max(...kv.map(([k]) => k.length));
1990
+ for (const [k, v] of kv) {
1991
+ lines.push(` ${c.dim(k.padEnd(keyWidth))} ${v}`);
1992
+ }
1993
+ blocks.push(lines.join('\n'));
1994
+ }
1995
+ return blocks.join('\n\n');
1996
+ }
1997
+ function renderCompactTmuxRows(rows, indent = ' ') {
1998
+ return rows.map((row) => {
1999
+ const title = getTmuxDisplayTitle(row);
2000
+ const subtitle = getTmuxDisplaySubtitle(row);
2001
+ const bits = [];
2002
+ if (row.cwd)
2003
+ bits.push(row.cwd);
2004
+ bits.push(...formatTmuxConnectionLabels(row));
2005
+ const suffix = bits.length > 0 ? c.dim(` ${bits.join(' · ')}`) : '';
2006
+ return `${indent}${c.cyan(row.name)}${title !== row.name ? ` ${c.dim(`(${title})`)}` : ''}${subtitle && title === row.name ? ` ${c.dim(`(${subtitle})`)}` : ''}${suffix}`;
2007
+ }).join('\n');
2008
+ }
2009
+ // Fetch tmux sessions with termdock metadata. Returns null if tmux is not
2010
+ // running (so callers can decide to print a friendly message and exit).
2011
+ // Hard errors (tmux missing, query failure) `process.exit(1)` directly.
2012
+ async function fetchTlsRows() {
2013
+ let stdout;
2014
+ try {
2015
+ const result = await execFileAsync('tmux', ['list-sessions', '-F', TLS_FORMAT], {
2016
+ timeout: 5000,
2017
+ maxBuffer: 4 * 1024 * 1024,
2018
+ });
2019
+ stdout = result.stdout;
2020
+ }
2021
+ catch (error) {
2022
+ const err = error;
2023
+ if (err?.code === 'ENOENT') {
2024
+ console.error(`${ICON.err} ${c.red('tmux is not installed or not on PATH.')}`);
2025
+ process.exit(1);
2026
+ }
2027
+ const stderr = (err?.stderr || '').toString().trim();
2028
+ if (stderr.includes('no server running') || stderr.includes('no current session')) {
2029
+ return null;
2030
+ }
2031
+ console.error(`${ICON.err} ${c.red('Failed to query tmux:')} ${stderr || (err?.message ?? String(err))}`);
2032
+ process.exit(1);
2033
+ }
2034
+ return stdout
2035
+ .split('\n')
2036
+ .map((line) => line.trimEnd())
2037
+ .filter((line) => line.length > 0)
2038
+ .map((line) => {
2039
+ const cols = line.split('\t');
2040
+ const row = {};
2041
+ TLS_FIELDS.forEach((field, idx) => {
2042
+ row[field] = cols[idx] ?? '';
2043
+ });
2044
+ return row;
2045
+ });
2046
+ }
2047
+ async function runTls(opts) {
2048
+ const allRows = await fetchTlsRows();
2049
+ if (allRows === null) {
2050
+ if (opts.json) {
2051
+ process.stdout.write('[]\n');
2052
+ return;
2053
+ }
2054
+ console.log(`${ICON.info} ${c.dim('No tmux server running on this host.')}`);
2055
+ return;
2056
+ }
2057
+ const tmuxOrder = readPersistedTmuxOrder();
2058
+ const rows = (opts.all ? allRows : allRows.filter((row) => row.version.length > 0))
2059
+ .slice()
2060
+ .sort(compareTmuxRowsByPersistedOrder(tmuxOrder));
2061
+ if (opts.json) {
2062
+ const json = rows.map((row) => ({
2063
+ sessionName: row.name,
2064
+ attachedCount: row.attachedCount ? Number(row.attachedCount) : null,
2065
+ friendlyName: row.friendlyName || null,
2066
+ program: row.program || null,
2067
+ cwd: row.cwd || null,
2068
+ label: row.label || null,
2069
+ clientCount: row.clientCount ? Number(row.clientCount) : null,
2070
+ host: row.host || null,
2071
+ pid: row.pid ? Number(row.pid) : null,
2072
+ version: row.version || null,
2073
+ createdAt: row.createdAt ? Number(row.createdAt) : null,
2074
+ lastActiveAt: row.lastActiveAt ? Number(row.lastActiveAt) : null,
2075
+ }));
2076
+ process.stdout.write(`${JSON.stringify(json, null, 2)}\n`);
2077
+ return;
2078
+ }
2079
+ if (rows.length === 0) {
2080
+ if (opts.all) {
2081
+ console.log(`${ICON.info} ${c.dim('No tmux sessions on this host.')}`);
2082
+ }
2083
+ else {
2084
+ console.log(`${ICON.info} ${c.dim('No termdock-managed tmux sessions on this host.')}`);
2085
+ console.log(` ${c.dim('Use')} ${c.cyan('td la')} ${c.dim('to include all tmux sessions.')}`);
2086
+ }
2087
+ return;
2088
+ }
2089
+ console.log(renderBlocks(rows));
2090
+ }
2091
+ // ── end --tls ──
2092
+ // ── `td --attach-tmux [name]` / `td a [name]` / `td at [name]` ──
2093
+ //
2094
+ // Attach to a termdock-managed tmux session. If `name` is provided, attach
2095
+ // directly. Otherwise show an interactive picker that lists termdock sessions
2096
+ // (with -a, all tmux sessions). Replaces the current process with `tmux
2097
+ // attach -t <name>` so the user gets a normal tmux experience.
2098
+ function execTmuxAttach(sessionName) {
2099
+ // Use spawn with `stdio: 'inherit'` (not exec) so tmux owns the tty.
2100
+ // Don't `process.exit(0)` — wait for tmux to detach, then propagate code.
2101
+ const child = spawn('tmux', ['attach', '-t', sessionName], { stdio: 'inherit' });
2102
+ child.on('exit', (code, signal) => {
2103
+ if (signal) {
2104
+ // Re-raise the signal so the parent shell sees it.
2105
+ process.kill(process.pid, signal);
2106
+ return;
2107
+ }
2108
+ process.exit(code ?? 0);
2109
+ });
2110
+ child.on('error', (error) => {
2111
+ console.error(`${ICON.err} ${c.red('Failed to spawn tmux:')} ${getMessage(error)}`);
2112
+ process.exit(1);
2113
+ });
2114
+ // Make TypeScript happy — we never actually return.
2115
+ return undefined;
2116
+ }
2117
+ function getMessage(err) {
2118
+ if (err instanceof Error)
2119
+ return err.message;
2120
+ return String(err);
2121
+ }
2122
+ async function pickTmuxSession(rows) {
2123
+ if (rows.length === 0)
2124
+ return null;
2125
+ if (rows.length === 1) {
2126
+ const only = rows[0];
2127
+ console.log(`${ICON.info} ${c.dim('Only one session — attaching:')} ${c.cyan(only.name)}`);
2128
+ return only.name;
2129
+ }
2130
+ if (!process.stdin.isTTY) {
2131
+ console.error(`${ICON.err} ${c.red('No TTY — pass a session name explicitly: td --attach-tmux <name>')}`);
2132
+ return null;
2133
+ }
2134
+ console.log(c.bold('Select a tmux session to attach:'));
2135
+ const indexWidth = String(rows.length).length;
2136
+ const groups = buildTmuxFolderGroups(rows);
2137
+ const orderedRows = groups.flatMap((group) => group.sessions);
2138
+ let optionIndex = 0;
2139
+ for (const group of groups) {
2140
+ const groupTitle = group.key ? `${group.label} ${c.dim(`(${group.key})`)}` : `${group.label} ${c.dim('(no cwd)')}`;
2141
+ console.log(` ${c.bold(groupTitle)}`);
2142
+ for (const row of group.sessions) {
2143
+ optionIndex += 1;
2144
+ const num = c.cyan(String(optionIndex).padStart(indexWidth));
2145
+ const heading = getTmuxDisplayTitle(row);
2146
+ const subtitle = getTmuxDisplaySubtitle(row);
2147
+ const meta = [];
2148
+ if (row.cwd)
2149
+ meta.push(row.cwd);
2150
+ meta.push(...formatTmuxConnectionLabels(row));
2151
+ const tail = meta.length > 0 ? c.dim(` ${meta.join(' · ')}`) : '';
2152
+ console.log(` ${num}. ${heading}${subtitle ? ` ${c.dim(`(${subtitle})`)}` : ''}${tail}`);
2153
+ }
2154
+ }
2155
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2156
+ const answer = await new Promise((resolve) => {
2157
+ rl.question(`Choice [1-${rows.length}, q to quit]: `, (input) => {
2158
+ rl.close();
2159
+ resolve(input.trim());
2160
+ });
2161
+ });
2162
+ if (!answer || answer.toLowerCase() === 'q')
2163
+ return null;
2164
+ const choice = Number(answer);
2165
+ if (!Number.isInteger(choice) || choice < 1 || choice > orderedRows.length) {
2166
+ console.error(`${ICON.err} ${c.red(`Invalid choice: ${answer}`)}`);
2167
+ return null;
2168
+ }
2169
+ return orderedRows[choice - 1]?.name ?? null;
2170
+ }
2171
+ async function runAttachTmux(opts) {
2172
+ const allRows = await fetchTlsRows();
2173
+ if (allRows === null) {
2174
+ console.log(`${ICON.info} ${c.dim('No tmux server running on this host.')}`);
2175
+ process.exit(0);
2176
+ }
2177
+ const candidates = buildAttachCandidates(allRows, opts.all === true);
2178
+ // Direct attach when caller already knows the name.
2179
+ if (opts.name) {
2180
+ const exactRaw = allRows.find((row) => row.name === opts.name);
2181
+ const target = exactRaw ?? resolveTmuxAttachTarget(opts.name, candidates).row;
2182
+ if (!target) {
2183
+ const resolved = resolveTmuxAttachTarget(opts.name, candidates);
2184
+ if (resolved.ambiguous.length > 0) {
2185
+ console.error(`${ICON.err} ${c.red(`Ambiguous tmux session: ${opts.name}`)}`);
2186
+ console.error(renderCompactTmuxRows(resolved.ambiguous));
2187
+ console.error(` ${c.dim('Use the raw tmux session name, or run')} ${c.cyan(opts.all ? 'td a --all' : 'td a')} ${c.dim('for the picker.')}`);
2188
+ process.exit(1);
2189
+ }
2190
+ console.error(`${ICON.err} ${c.red(`No matching tmux session: ${opts.name}`)}`);
2191
+ if (!opts.all && allRows.some((row) => row.version.length === 0)) {
2192
+ console.error(` ${c.dim('Use')} ${c.cyan('td a --all')} ${c.dim('to include unmanaged tmux sessions.')}`);
2193
+ }
2194
+ const suggestions = resolved.suggestions.length > 0 ? resolved.suggestions : candidates.slice(0, 6);
2195
+ if (suggestions.length > 0) {
2196
+ console.error(` ${c.dim('Available sessions:')}`);
2197
+ console.error(renderCompactTmuxRows(suggestions, ' '));
2198
+ }
2199
+ process.exit(1);
2200
+ }
2201
+ await ensureTmuxFocusEvents();
2202
+ if (target.version.length > 0) {
2203
+ registerGuiTmuxSession(target.name);
2204
+ }
2205
+ execTmuxAttach(target.name);
2206
+ return;
2207
+ }
2208
+ if (candidates.length === 0) {
2209
+ console.log(`${ICON.info} ${c.dim(opts.all ? 'No tmux sessions on this host.' : 'No termdock-managed tmux sessions on this host.')}`);
2210
+ if (!opts.all) {
2211
+ console.log(` ${c.dim('Use')} ${c.cyan('td a --all')} ${c.dim('to include unmanaged tmux sessions.')}`);
2212
+ }
2213
+ process.exit(0);
2214
+ }
2215
+ const picked = await pickTmuxSession(candidates);
2216
+ if (!picked) {
2217
+ console.log(`${ICON.info} ${c.dim('Cancelled.')}`);
2218
+ process.exit(0);
2219
+ }
2220
+ await ensureTmuxFocusEvents();
2221
+ const pickedRow = candidates.find((row) => row.name === picked);
2222
+ if (pickedRow?.version) {
2223
+ registerGuiTmuxSession(pickedRow.name);
2224
+ }
2225
+ execTmuxAttach(picked);
2226
+ }
2227
+ async function runNewTmux(opts) {
2228
+ const sessionName = normalizeTmuxSessionName(opts.name);
2229
+ const cwd = resolveInvocationCwd();
2230
+ const result = await ensureStampedTmuxSession(sessionName, cwd);
2231
+ registerGuiTmuxSession(result.sessionName);
2232
+ const verb = result.created ? 'Created' : 'Reused';
2233
+ console.log(`${ICON.ok} ${c.green(`${verb} tmux session:`)} ${c.cyan(result.sessionName)}`);
2234
+ if (result.created) {
2235
+ console.log(` ${c.dim('Directory:')} ${c.cyan(cwd)}`);
2236
+ }
2237
+ if (opts.attach === false) {
2238
+ console.log(` ${c.dim('Tip: attach with')} ${c.cyan(`td --attach-tmux ${result.sessionName}`)}`);
2239
+ return;
2240
+ }
2241
+ console.log(`${ICON.info} ${c.dim('Attaching...')}`);
2242
+ execTmuxAttach(result.sessionName);
2243
+ }
2244
+ // ── end --attach-tmux / --new-tmux ──
2245
+ async function promptLine(prompt) {
2246
+ if (!process.stdin.isTTY)
2247
+ return '';
2248
+ return new Promise((resolve) => {
2249
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2250
+ rl.question(prompt, (answer) => {
2251
+ rl.close();
2252
+ resolve(answer.trim());
2253
+ });
2254
+ });
2255
+ }
2256
+ async function runFirstRunWizard() {
2257
+ if (!process.stdin.isTTY || isFirstRunCompleted())
2258
+ return;
2259
+ console.log(`${ICON.info} ${c.bold('Termdock first-time local access setup')}`);
2260
+ const prefix = await promptLine(' Choose your .termdock.local prefix (e.g. jovn): ');
2261
+ const normalized = normalizeLocalAccessName(prefix);
2262
+ if (normalized) {
2263
+ setLocalAccessSetting({ name: normalized, source: 'manual' });
2264
+ console.log(` ${ICON.ok} ${c.dim('Local URL prefix:')} ${c.cyan(`${normalized}.termdock.local`)}`);
2265
+ }
2266
+ else {
2267
+ console.log(` ${ICON.warn} ${c.yellow('No valid prefix entered; using generated default for now.')}`);
2268
+ }
2269
+ const enableHttps = (await promptLine(' Enable HTTPS automatically now? [Y/n] ')).toLowerCase();
2270
+ if (enableHttps !== 'n' && enableHttps !== 'no') {
2271
+ try {
2272
+ await runSetupLocalHttps();
2273
+ }
2274
+ catch (error) {
2275
+ console.error(error instanceof Error ? error.message : error);
2276
+ console.log(` ${ICON.info} ${c.dim('You can enable HTTPS later with:')} ${c.cyan('td --setup-local-https')}`);
2277
+ }
2278
+ }
2279
+ else {
2280
+ console.log(` ${ICON.info} ${c.dim('You can enable HTTPS later with:')} ${c.cyan('td --setup-local-https')}`);
2281
+ }
2282
+ markFirstRunCompleted();
2283
+ console.log('');
2284
+ }
2285
+ async function waitForServerMetadata(result, fallbackPort) {
2286
+ for (let attempt = 0; attempt < 20; attempt += 1) {
2287
+ const state = result.getLocalAccessState();
2288
+ const onboardingUrl = result.getOnboardingUrl() ?? state.onboardingUrl;
2289
+ if (state.status !== 'disabled' || state.reason !== 'Local access has not started yet.') {
2290
+ return {
2291
+ lanUrl: state.url,
2292
+ onboardingUrl,
2293
+ status: state.status,
2294
+ reason: state.reason,
2295
+ };
2296
+ }
2297
+ await new Promise((resolve) => setTimeout(resolve, 100));
2298
+ }
2299
+ const state = result.getLocalAccessState();
2300
+ return {
2301
+ lanUrl: state.url || undefined,
2302
+ onboardingUrl: result.getOnboardingUrl() ?? state.onboardingUrl,
2303
+ status: state.status,
2304
+ reason: state.reason ?? `Metadata still initializing on port ${fallbackPort}.`,
2305
+ };
2306
+ }
2307
+ async function refreshDefaultHttpsCertificateSafely() {
2308
+ try {
2309
+ return await ensureDefaultHttpsCertificateFresh();
2310
+ }
2311
+ catch (error) {
2312
+ console.error(error instanceof Error ? error.message : error);
2313
+ return false;
2314
+ }
2315
+ }
2316
+ async function main() {
2317
+ if (options.setupLocalHttps) {
2318
+ try {
2319
+ await runSetupLocalHttps();
2320
+ process.exit(0);
2321
+ }
2322
+ catch (error) {
2323
+ console.error(error instanceof Error ? error.message : error);
2324
+ process.exit(1);
2325
+ }
2326
+ }
2327
+ if (options.tls) {
2328
+ await runTls({ all: options.tlsAll, json: options.tlsJson });
2329
+ process.exit(0);
2330
+ }
2331
+ if (options.newTmux) {
2332
+ await runNewTmux({ name: options.newTmuxName, attach: options.newTmuxAttach });
2333
+ return; // execTmuxAttach handles process exit when attach=true
2334
+ }
2335
+ if (options.attachTmux) {
2336
+ await runAttachTmux({ name: options.attachTmuxName, all: options.tlsAll });
2337
+ return; // execTmuxAttach handles process exit
2338
+ }
2339
+ if (options.injectChangeAudit) {
2340
+ await runInjectChangeAudit(options.injectChangeAudit);
2341
+ process.exit(0);
2342
+ }
2343
+ if (options.injectBranchAudit) {
2344
+ await runInjectBranchAudit(options.injectBranchAudit);
2345
+ process.exit(0);
2346
+ }
2347
+ if (options.branchAuditExport) {
2348
+ await runBranchAuditExport(options.branchAuditExport);
2349
+ process.exit(0);
2350
+ }
2351
+ if (options.changeAuditList) {
2352
+ await runChangeAuditList(options.changeAuditList);
2353
+ process.exit(0);
2354
+ }
2355
+ if (options.changeAuditExport) {
2356
+ await runChangeAuditExport(options.changeAuditExport);
2357
+ process.exit(0);
2358
+ }
2359
+ if (options.changeAuditShow) {
2360
+ await runChangeAuditShow(options.changeAuditShow);
2361
+ process.exit(0);
2362
+ }
2363
+ if (options.injectChangeAuditHunk) {
2364
+ await runInjectChangeAuditHunk(options.injectChangeAuditHunk);
2365
+ process.exit(0);
2366
+ }
2367
+ if (options.setPassword) {
2368
+ await runSetPassword();
2369
+ process.exit(0);
2370
+ }
2371
+ if (options.clearPassword) {
2372
+ runClearPassword();
2373
+ process.exit(0);
2374
+ }
2375
+ const localApiToken = getOrCreateLocalApiToken();
2376
+ const https = resolveHttpsOptions(options);
2377
+ const isManagedDefaultHttps = Boolean(https.cert === defaultHttpsCertPath && https.key === defaultHttpsKeyPath);
2378
+ if (options.status) {
2379
+ const runningState = getRunningState();
2380
+ if (!runningState) {
2381
+ console.log(`${ICON.info} ${c.dim('Termdock is not running.')}`);
2382
+ process.exit(0);
2383
+ }
2384
+ printRunningState(runningState);
2385
+ process.exit(0);
2386
+ }
2387
+ if (options.stop) {
2388
+ const runningState = getRunningState();
2389
+ if (!runningState) {
2390
+ console.log(`${ICON.info} ${c.dim('Termdock is not running.')}`);
2391
+ process.exit(0);
2392
+ }
2393
+ const bridgeCaffeinateStarted = startRestartBridgeCaffeinate();
2394
+ process.kill(runningState.pid, 'SIGTERM');
2395
+ removeStateFile();
2396
+ console.log(`${ICON.ok} ${c.green(`Stopped Termdock (PID ${runningState.pid}).`)}`);
2397
+ if (bridgeCaffeinateStarted) {
2398
+ console.log(`${ICON.info} ${c.dim(`Keeping macOS awake for up to ${restartBridgeCaffeinateSeconds}s while Termdock restarts.`)}`);
2399
+ }
2400
+ process.exit(0);
2401
+ }
2402
+ if (options.foreground) {
2403
+ warnIfAuthDisabled(options.host ?? DEFAULT_HOST);
2404
+ // Dynamic import keeps terminal.ts side-effects (loadClientStatesFromDisk
2405
+ // etc.) out of fast paths like `termdock --tls` / `--status`.
2406
+ const { startServer } = await import('./entry.js');
2407
+ const result = startServer({
2408
+ host: options.host,
2409
+ port: options.port,
2410
+ httpsCertPath: https.cert,
2411
+ httpsKeyPath: https.key,
2412
+ httpsCaPath: https.ca,
2413
+ localApiToken,
2414
+ onCertificateRefreshNeeded: isManagedDefaultHttps
2415
+ ? async () => {
2416
+ console.log(`${ICON.info} ${c.dim('Termdock detected network/certificate changes; regenerating certificate and reloading TLS context...')}`);
2417
+ const refreshed = await refreshDefaultHttpsCertificateSafely();
2418
+ if (!refreshed)
2419
+ return { reloaded: false };
2420
+ const state = await localAccessManager.start({
2421
+ host: options.host ?? DEFAULT_HOST,
2422
+ port: options.port ?? PORT.backend,
2423
+ scheme: result.scheme,
2424
+ caCertPath: https.ca,
2425
+ onboardingPort: options.port ?? PORT.backend,
2426
+ });
2427
+ writeState({
2428
+ pid: process.pid,
2429
+ host: options.host ?? DEFAULT_HOST,
2430
+ port: options.port ?? PORT.backend,
2431
+ scheme: result.scheme,
2432
+ localUrl: `${result.scheme}://${(options.host ?? DEFAULT_HOST) === '0.0.0.0' ? 'localhost' : (options.host ?? DEFAULT_HOST)}:${options.port ?? PORT.backend}`,
2433
+ lanUrl: state.url,
2434
+ onboardingUrl: result.getOnboardingUrl(),
2435
+ localAccessStatus: state.status,
2436
+ localAccessReason: state.reason,
2437
+ logFile: logFilePath,
2438
+ startedAt: new Date().toISOString(),
2439
+ localApiToken,
2440
+ });
2441
+ return { reloaded: false, localAccessState: state };
2442
+ }
2443
+ : () => {
2444
+ console.warn('[cert-watch] certificate is missing current domain/IP SANs; restart Termdock with an updated certificate.');
2445
+ return { reloaded: false };
2446
+ },
2447
+ });
2448
+ result.server.on('close', () => {
2449
+ const runningState = readState();
2450
+ if (runningState?.pid === process.pid) {
2451
+ removeStateFile();
2452
+ }
2453
+ });
2454
+ const metadata = await waitForServerMetadata(result, options.port ?? PORT.backend);
2455
+ writeState({
2456
+ pid: process.pid,
2457
+ host: options.host ?? DEFAULT_HOST,
2458
+ port: options.port ?? PORT.backend,
2459
+ scheme: result.scheme,
2460
+ localUrl: `${result.scheme}://${(options.host ?? DEFAULT_HOST) === '0.0.0.0' ? 'localhost' : (options.host ?? DEFAULT_HOST)}:${options.port ?? PORT.backend}`,
2461
+ lanUrl: metadata.lanUrl,
2462
+ onboardingUrl: metadata.onboardingUrl,
2463
+ localAccessStatus: metadata.status,
2464
+ localAccessReason: metadata.reason,
2465
+ logFile: logFilePath,
2466
+ startedAt: new Date().toISOString(),
2467
+ localApiToken,
2468
+ });
2469
+ return;
2470
+ }
2471
+ const runningState = getRunningState();
2472
+ if (runningState) {
2473
+ printRunningState(runningState);
2474
+ process.exit(0);
2475
+ }
2476
+ await runFirstRunWizard();
2477
+ await refreshDefaultHttpsCertificateSafely();
2478
+ const refreshedHttps = resolveHttpsOptions(options);
2479
+ const activeHttps = refreshedHttps;
2480
+ ensureStateDir();
2481
+ const childArgs = [path.resolve(process.argv[1]), '--foreground'];
2482
+ const childHost = options.host ?? DEFAULT_HOST;
2483
+ const childPort = options.port ?? PORT.backend;
2484
+ if (options.host) {
2485
+ childArgs.push('--host', options.host);
2486
+ }
2487
+ if (options.port) {
2488
+ childArgs.push('--port', String(options.port));
2489
+ }
2490
+ if (activeHttps.cert) {
2491
+ childArgs.push('--https-cert', activeHttps.cert);
2492
+ }
2493
+ if (activeHttps.key) {
2494
+ childArgs.push('--https-key', activeHttps.key);
2495
+ }
2496
+ if (activeHttps.ca) {
2497
+ childArgs.push('--https-ca', activeHttps.ca);
2498
+ }
2499
+ const scheme = activeHttps.cert && activeHttps.key ? 'https' : 'http';
2500
+ const localAccessReason = activeHttps.source === 'default'
2501
+ ? 'Using local HTTPS certificates from ~/.termdock/certs.'
2502
+ : null;
2503
+ const logFileFd = fs.openSync(logFilePath, 'a');
2504
+ const child = spawn(process.execPath, childArgs, {
2505
+ detached: true,
2506
+ stdio: ['ignore', logFileFd, logFileFd],
2507
+ });
2508
+ fs.closeSync(logFileFd);
2509
+ child.unref();
2510
+ writeState(buildServerState({
2511
+ pid: child.pid,
2512
+ host: childHost,
2513
+ port: childPort,
2514
+ scheme,
2515
+ localAccessReason,
2516
+ localApiToken,
2517
+ }));
2518
+ console.log(`${ICON.ok} ${c.green('Termdock started in background.')}`);
2519
+ console.log(` ${c.dim('URL:')} ${c.cyan(`${scheme}://${childHost === '0.0.0.0' ? 'localhost' : childHost}:${childPort}`)}`);
2520
+ if (scheme === 'https') {
2521
+ console.log(` ${c.dim('HTTPS:')} ${activeHttps.source === 'default' ? c.green('auto') : c.green('enabled')}`);
2522
+ }
2523
+ else {
2524
+ console.log(` ${c.dim('HTTPS:')} ${c.dim('not configured — run td --setup-local-https')}`);
2525
+ }
2526
+ console.log(` ${c.dim('PID:')} ${child.pid}`);
2527
+ console.log(` ${c.dim('Log:')} ${logFilePath}`);
2528
+ warnIfAuthDisabled(childHost);
2529
+ process.exit(0);
2530
+ }
2531
+ main().catch((err) => {
2532
+ console.error(err instanceof Error ? err.message : err);
2533
+ process.exit(1);
2534
+ });