@repo-os/repoos 0.5.45

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 (362) hide show
  1. package/LICENSE.md +110 -0
  2. package/README.md +272 -0
  3. package/dist/.build-info.json +4 -0
  4. package/dist/.build-stamp.json +3 -0
  5. package/dist/cli/colors.d.ts +13 -0
  6. package/dist/cli/colors.js +37 -0
  7. package/dist/cli/index.d.ts +2 -0
  8. package/dist/cli/index.js +221 -0
  9. package/dist/commands/check.d.ts +170 -0
  10. package/dist/commands/check.js +1227 -0
  11. package/dist/commands/docs.d.ts +2 -0
  12. package/dist/commands/docs.js +70 -0
  13. package/dist/commands/gc.d.ts +1 -0
  14. package/dist/commands/gc.js +57 -0
  15. package/dist/commands/init.d.ts +9 -0
  16. package/dist/commands/init.js +699 -0
  17. package/dist/commands/screenshots.d.ts +7 -0
  18. package/dist/commands/screenshots.js +77 -0
  19. package/dist/commands/serve.d.ts +55 -0
  20. package/dist/commands/serve.js +204 -0
  21. package/dist/commands/status.d.ts +140 -0
  22. package/dist/commands/status.js +569 -0
  23. package/dist/commands/stop.d.ts +1 -0
  24. package/dist/commands/stop.js +59 -0
  25. package/dist/commands/tasks.d.ts +47 -0
  26. package/dist/commands/tasks.js +334 -0
  27. package/dist/commands/tunnel.d.ts +1 -0
  28. package/dist/commands/tunnel.js +1271 -0
  29. package/dist/commands/ui-harness.d.ts +53 -0
  30. package/dist/commands/ui-harness.js +62 -0
  31. package/dist/commands/ui-smoke.d.ts +6 -0
  32. package/dist/commands/ui-smoke.js +220 -0
  33. package/dist/commands/upgrade.d.ts +15 -0
  34. package/dist/commands/upgrade.js +188 -0
  35. package/dist/core/auth-store.d.ts +81 -0
  36. package/dist/core/auth-store.js +530 -0
  37. package/dist/core/auth.d.ts +75 -0
  38. package/dist/core/auth.js +124 -0
  39. package/dist/core/bootstrap.d.ts +41 -0
  40. package/dist/core/bootstrap.js +236 -0
  41. package/dist/core/build.d.ts +92 -0
  42. package/dist/core/build.js +249 -0
  43. package/dist/core/canary.d.ts +10 -0
  44. package/dist/core/canary.js +14 -0
  45. package/dist/core/check-failure-summary.d.ts +10 -0
  46. package/dist/core/check-failure-summary.js +34 -0
  47. package/dist/core/config.d.ts +160 -0
  48. package/dist/core/config.js +1288 -0
  49. package/dist/core/context-pack.d.ts +74 -0
  50. package/dist/core/context-pack.js +778 -0
  51. package/dist/core/db.d.ts +236 -0
  52. package/dist/core/db.js +747 -0
  53. package/dist/core/detect.d.ts +69 -0
  54. package/dist/core/detect.js +251 -0
  55. package/dist/core/docs.d.ts +76 -0
  56. package/dist/core/docs.js +225 -0
  57. package/dist/core/frontmatter.d.ts +29 -0
  58. package/dist/core/frontmatter.js +215 -0
  59. package/dist/core/git.d.ts +505 -0
  60. package/dist/core/git.js +1453 -0
  61. package/dist/core/indexer.d.ts +33 -0
  62. package/dist/core/indexer.js +282 -0
  63. package/dist/core/input.d.ts +66 -0
  64. package/dist/core/input.js +222 -0
  65. package/dist/core/logger.d.ts +70 -0
  66. package/dist/core/logger.js +138 -0
  67. package/dist/core/models.d.ts +45 -0
  68. package/dist/core/models.js +291 -0
  69. package/dist/core/net-probe.d.ts +4 -0
  70. package/dist/core/net-probe.js +20 -0
  71. package/dist/core/providers/deepinfra.d.ts +23 -0
  72. package/dist/core/providers/deepinfra.js +44 -0
  73. package/dist/core/providers/index.d.ts +24 -0
  74. package/dist/core/providers/index.js +59 -0
  75. package/dist/core/providers/openrouter.d.ts +28 -0
  76. package/dist/core/providers/openrouter.js +82 -0
  77. package/dist/core/providers/spend.d.ts +86 -0
  78. package/dist/core/providers/spend.js +286 -0
  79. package/dist/core/providers/types.d.ts +56 -0
  80. package/dist/core/providers/types.js +39 -0
  81. package/dist/core/repoos.d.ts +59 -0
  82. package/dist/core/repoos.js +221 -0
  83. package/dist/core/runtime.d.ts +35 -0
  84. package/dist/core/runtime.js +167 -0
  85. package/dist/core/skills-registry.d.ts +39 -0
  86. package/dist/core/skills-registry.js +148 -0
  87. package/dist/core/skills-registry.test.d.ts +1 -0
  88. package/dist/core/skills-registry.test.js +52 -0
  89. package/dist/core/sysmem.d.ts +7 -0
  90. package/dist/core/sysmem.js +55 -0
  91. package/dist/core/tailscale.d.ts +2 -0
  92. package/dist/core/tailscale.js +19 -0
  93. package/dist/core/task.d.ts +53 -0
  94. package/dist/core/task.js +310 -0
  95. package/dist/core/toml-line.d.ts +20 -0
  96. package/dist/core/toml-line.js +52 -0
  97. package/dist/core/toml-validate.d.ts +44 -0
  98. package/dist/core/toml-validate.js +335 -0
  99. package/dist/core/tunnel-assistant.d.ts +30 -0
  100. package/dist/core/tunnel-assistant.js +65 -0
  101. package/dist/core/tunnel-registry.d.ts +96 -0
  102. package/dist/core/tunnel-registry.js +267 -0
  103. package/dist/core/tunnel-registry.test.d.ts +1 -0
  104. package/dist/core/tunnel-registry.test.js +309 -0
  105. package/dist/core/tunnel.d.ts +103 -0
  106. package/dist/core/tunnel.js +320 -0
  107. package/dist/core/types.d.ts +811 -0
  108. package/dist/core/types.js +27 -0
  109. package/dist/core/worktree-gc.d.ts +57 -0
  110. package/dist/core/worktree-gc.js +194 -0
  111. package/dist/server/__tests__/integration-job.test.d.ts +1 -0
  112. package/dist/server/__tests__/integration-job.test.js +129 -0
  113. package/dist/server/agents.d.ts +1020 -0
  114. package/dist/server/agents.js +4070 -0
  115. package/dist/server/attachments.d.ts +51 -0
  116. package/dist/server/attachments.js +130 -0
  117. package/dist/server/auto-engineering.d.ts +45 -0
  118. package/dist/server/auto-engineering.js +275 -0
  119. package/dist/server/branchless-release.d.ts +22 -0
  120. package/dist/server/branchless-release.js +67 -0
  121. package/dist/server/built-in-agents.d.ts +305 -0
  122. package/dist/server/built-in-agents.js +1838 -0
  123. package/dist/server/cto-monitor.d.ts +35 -0
  124. package/dist/server/cto-monitor.js +281 -0
  125. package/dist/server/cto.d.ts +125 -0
  126. package/dist/server/cto.js +590 -0
  127. package/dist/server/deployments.d.ts +119 -0
  128. package/dist/server/deployments.js +325 -0
  129. package/dist/server/diff-snapshot.d.ts +20 -0
  130. package/dist/server/diff-snapshot.js +73 -0
  131. package/dist/server/done.d.ts +160 -0
  132. package/dist/server/done.js +488 -0
  133. package/dist/server/freeform.d.ts +55 -0
  134. package/dist/server/freeform.js +179 -0
  135. package/dist/server/handoff.d.ts +99 -0
  136. package/dist/server/handoff.js +575 -0
  137. package/dist/server/hetzner.d.ts +45 -0
  138. package/dist/server/hetzner.js +117 -0
  139. package/dist/server/icons.d.ts +8 -0
  140. package/dist/server/icons.js +195 -0
  141. package/dist/server/integration-job.d.ts +75 -0
  142. package/dist/server/integration-job.js +144 -0
  143. package/dist/server/integration-orchestrator.d.ts +211 -0
  144. package/dist/server/integration-orchestrator.js +1299 -0
  145. package/dist/server/integration-status.d.ts +48 -0
  146. package/dist/server/integration-status.js +71 -0
  147. package/dist/server/live-index.d.ts +295 -0
  148. package/dist/server/live-index.js +452 -0
  149. package/dist/server/model-test.d.ts +20 -0
  150. package/dist/server/model-test.js +100 -0
  151. package/dist/server/ntfy.d.ts +57 -0
  152. package/dist/server/ntfy.js +112 -0
  153. package/dist/server/pm-attachments.d.ts +35 -0
  154. package/dist/server/pm-attachments.js +181 -0
  155. package/dist/server/pm-runs.d.ts +58 -0
  156. package/dist/server/pm-runs.js +80 -0
  157. package/dist/server/preview.d.ts +174 -0
  158. package/dist/server/preview.js +641 -0
  159. package/dist/server/release.d.ts +88 -0
  160. package/dist/server/release.js +449 -0
  161. package/dist/server/reload.d.ts +194 -0
  162. package/dist/server/reload.js +586 -0
  163. package/dist/server/remote-validation.d.ts +132 -0
  164. package/dist/server/remote-validation.js +478 -0
  165. package/dist/server/repo-lock.d.ts +36 -0
  166. package/dist/server/repo-lock.js +141 -0
  167. package/dist/server/review-guard.d.ts +14 -0
  168. package/dist/server/review-guard.js +159 -0
  169. package/dist/server/review.d.ts +243 -0
  170. package/dist/server/review.js +1211 -0
  171. package/dist/server/routes/agents.d.ts +6 -0
  172. package/dist/server/routes/agents.js +28 -0
  173. package/dist/server/routes/auth.d.ts +60 -0
  174. package/dist/server/routes/auth.js +755 -0
  175. package/dist/server/routes/config.d.ts +20 -0
  176. package/dist/server/routes/config.js +347 -0
  177. package/dist/server/routes/debugger.d.ts +33 -0
  178. package/dist/server/routes/debugger.js +360 -0
  179. package/dist/server/routes/deployments.d.ts +3 -0
  180. package/dist/server/routes/deployments.js +16 -0
  181. package/dist/server/routes/docs.d.ts +8 -0
  182. package/dist/server/routes/docs.js +135 -0
  183. package/dist/server/routes/helpers.d.ts +21 -0
  184. package/dist/server/routes/helpers.js +171 -0
  185. package/dist/server/routes/index.d.ts +19 -0
  186. package/dist/server/routes/index.js +18 -0
  187. package/dist/server/routes/info.d.ts +17 -0
  188. package/dist/server/routes/info.js +177 -0
  189. package/dist/server/routes/inputs.d.ts +7 -0
  190. package/dist/server/routes/inputs.js +125 -0
  191. package/dist/server/routes/model-providers.d.ts +27 -0
  192. package/dist/server/routes/model-providers.js +94 -0
  193. package/dist/server/routes/models.d.ts +3 -0
  194. package/dist/server/routes/models.js +36 -0
  195. package/dist/server/routes/notify.d.ts +2 -0
  196. package/dist/server/routes/notify.js +17 -0
  197. package/dist/server/routes/playground.d.ts +29 -0
  198. package/dist/server/routes/playground.js +81 -0
  199. package/dist/server/routes/release.d.ts +20 -0
  200. package/dist/server/routes/release.js +86 -0
  201. package/dist/server/routes/router.d.ts +12 -0
  202. package/dist/server/routes/router.js +32 -0
  203. package/dist/server/routes/skill-registry.d.ts +5 -0
  204. package/dist/server/routes/skill-registry.js +51 -0
  205. package/dist/server/routes/tasks.d.ts +58 -0
  206. package/dist/server/routes/tasks.js +1442 -0
  207. package/dist/server/routes/transcribe.d.ts +2 -0
  208. package/dist/server/routes/transcribe.js +124 -0
  209. package/dist/server/routes/types.d.ts +68 -0
  210. package/dist/server/routes/types.js +1 -0
  211. package/dist/server/routes/ui.d.ts +5 -0
  212. package/dist/server/routes/ui.js +81 -0
  213. package/dist/server/routes/utils.d.ts +3 -0
  214. package/dist/server/routes/utils.js +23 -0
  215. package/dist/server/serve-reaper.d.ts +135 -0
  216. package/dist/server/serve-reaper.js +491 -0
  217. package/dist/server/server.d.ts +49 -0
  218. package/dist/server/server.js +2171 -0
  219. package/dist/server/supervisor.d.ts +89 -0
  220. package/dist/server/supervisor.js +231 -0
  221. package/dist/server/system.d.ts +198 -0
  222. package/dist/server/system.js +480 -0
  223. package/dist/server/task-check.d.ts +45 -0
  224. package/dist/server/task-check.js +71 -0
  225. package/dist/server/task-transitions.d.ts +51 -0
  226. package/dist/server/task-transitions.js +78 -0
  227. package/dist/server/task-watchdog.d.ts +158 -0
  228. package/dist/server/task-watchdog.js +681 -0
  229. package/dist/server/test-run.d.ts +28 -0
  230. package/dist/server/test-run.js +81 -0
  231. package/dist/server/watcher.d.ts +21 -0
  232. package/dist/server/watcher.js +194 -0
  233. package/dist/server/write.d.ts +94 -0
  234. package/dist/server/write.js +345 -0
  235. package/dist/ui/assets/AgentsView-CF4wRvHh.css +1 -0
  236. package/dist/ui/assets/AgentsView-D-myL-kA.js +1 -0
  237. package/dist/ui/assets/ContextView-D3TC-RSp.css +1 -0
  238. package/dist/ui/assets/ContextView-DsOqAdyE.js +4 -0
  239. package/dist/ui/assets/DashboardView-BOhsIJqO.js +1 -0
  240. package/dist/ui/assets/DashboardView-Ck9o0RP2.css +1 -0
  241. package/dist/ui/assets/DeploymentsView--iIUVfEH.css +1 -0
  242. package/dist/ui/assets/DeploymentsView-DXu_FYR2.js +1 -0
  243. package/dist/ui/assets/InputsView-24HVbVPk.js +1 -0
  244. package/dist/ui/assets/InputsView-BLb7GHtx.css +1 -0
  245. package/dist/ui/assets/LoginView-6QlGg6cx.css +1 -0
  246. package/dist/ui/assets/LoginView-BqupI0Lt.js +1 -0
  247. package/dist/ui/assets/ReleasesView-BEG1A0YH.css +1 -0
  248. package/dist/ui/assets/ReleasesView-CAgMuxpe.js +3 -0
  249. package/dist/ui/assets/SettingsView-CA-NYSMK.css +1 -0
  250. package/dist/ui/assets/SettingsView-O3iZSnjg.js +3 -0
  251. package/dist/ui/assets/WorkView-D8Tl6qwy.js +1 -0
  252. package/dist/ui/assets/WorkView-DMYL2s3W.css +1 -0
  253. package/dist/ui/assets/abnfDiagram-VCTEODGH-J5lkSnEE.js +1 -0
  254. package/dist/ui/assets/arc-BHZnmhhM.js +1 -0
  255. package/dist/ui/assets/architecture-7GRP2DOG-pLdFt-xS.js +1 -0
  256. package/dist/ui/assets/architectureDiagram-5GKGNRK7-C3k89uKX.js +36 -0
  257. package/dist/ui/assets/array-BifhSqXX.js +1 -0
  258. package/dist/ui/assets/blockDiagram-I7D4REHJ-AWNoBzop.js +129 -0
  259. package/dist/ui/assets/button-lbikqSph.js +1 -0
  260. package/dist/ui/assets/c4Diagram-7LVT6UL2-DKG1D5gd.js +38 -0
  261. package/dist/ui/assets/channel-gA4qBv3A.js +1 -0
  262. package/dist/ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  263. package/dist/ui/assets/chunk-4HAMMTFA-D1YR8lF_.js +62 -0
  264. package/dist/ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  265. package/dist/ui/assets/chunk-75Z2AOVW-5GuBxfsL.js +2 -0
  266. package/dist/ui/assets/chunk-DU6HZSFF-DfdNNqwa.js +127 -0
  267. package/dist/ui/assets/chunk-F27PBJKO-BPZzinQ4.js +1 -0
  268. package/dist/ui/assets/chunk-FOHPRMQF-l2z4K2jD.js +161 -0
  269. package/dist/ui/assets/chunk-GMAD6QVW-DGWvSKj5.js +72 -0
  270. package/dist/ui/assets/chunk-GVQU2GXP-D6Aw75wJ.js +1 -0
  271. package/dist/ui/assets/chunk-IMKFNOWR-B8mMBKCW.js +231 -0
  272. package/dist/ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  273. package/dist/ui/assets/chunk-L3NEJ4N5-D5OJuXYE.js +1 -0
  274. package/dist/ui/assets/chunk-OSK3NFVY-B7r9w2UW.js +10 -0
  275. package/dist/ui/assets/chunk-P2QGCYS3-C74X4AQX.js +1 -0
  276. package/dist/ui/assets/chunk-POPQ4Y6H-C5_u1gUS.js +1 -0
  277. package/dist/ui/assets/chunk-PWAF6VOD-DfoTLtxn.js +1 -0
  278. package/dist/ui/assets/chunk-SHT3W25Y-D40m2eqz.js +168 -0
  279. package/dist/ui/assets/chunk-SVP7TREG-euxi0Kap.js +88 -0
  280. package/dist/ui/assets/chunk-TICWLB2K-fkdxngzN.js +206 -0
  281. package/dist/ui/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
  282. package/dist/ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  283. package/dist/ui/assets/classDiagram-ZZMXUADV-BzW8bq2P.js +1 -0
  284. package/dist/ui/assets/classDiagram-v2-VYDZK3BY-BzW8bq2P.js +1 -0
  285. package/dist/ui/assets/cose-bilkent-JH36ORCC-BTcgMon1.js +1 -0
  286. package/dist/ui/assets/cynefin-OW5HDTMX-B5m_COsS.js +1 -0
  287. package/dist/ui/assets/cynefinDiagram-5FMLGOSQ-C-AG6CGe.js +62 -0
  288. package/dist/ui/assets/cytoscape.esm-FBntH-9t.js +321 -0
  289. package/dist/ui/assets/dagre-GXQ25YYZ-DDfDnp8L.js +4 -0
  290. package/dist/ui/assets/dagre-PrKaheQc.js +1 -0
  291. package/dist/ui/assets/defaultLocale-BFoDCU3G.js +1 -0
  292. package/dist/ui/assets/diagram-S7CK7UJ4-DjQZAlEY.js +30 -0
  293. package/dist/ui/assets/diagram-UQ7AKVKN-DAZ9yp1B.js +41 -0
  294. package/dist/ui/assets/diagram-VSXAHHWV-BWepUKJD.js +3 -0
  295. package/dist/ui/assets/diagram-VX7I27RA-EcV1J81b.js +24 -0
  296. package/dist/ui/assets/diagram-Z3DM3KII-Dbz7XU_K.js +24 -0
  297. package/dist/ui/assets/dist-DTg6UBE_.js +1 -0
  298. package/dist/ui/assets/ebnfDiagram-PWID7BFC-BUyQw8Mm.js +1 -0
  299. package/dist/ui/assets/erDiagram-RLTQ6QDP-WVf8ukKt.js +99 -0
  300. package/dist/ui/assets/eventmodeling-NTZA5JFV-DHqyLOAn.js +1 -0
  301. package/dist/ui/assets/flowDiagram-HODETNUW-CYk_ze5K.js +1 -0
  302. package/dist/ui/assets/ganttDiagram-EL5Y4UJY-rB4MZuB-.js +292 -0
  303. package/dist/ui/assets/gitGraph-4MIJSDKK-BOTWM8dY.js +1 -0
  304. package/dist/ui/assets/gitGraphDiagram-WWUBYQGX-eYscuhtP.js +106 -0
  305. package/dist/ui/assets/graphlib-DS17s2tU.js +1 -0
  306. package/dist/ui/assets/index-BACBfnga.css +1 -0
  307. package/dist/ui/assets/index-BOQ-ust6.js +26 -0
  308. package/dist/ui/assets/info-A6RAGUB7-_67wOORe.js +1 -0
  309. package/dist/ui/assets/infoDiagram-27XIBGKW-XVXbddTA.js +2 -0
  310. package/dist/ui/assets/init-C-OQMol4.js +1 -0
  311. package/dist/ui/assets/ishikawaDiagram-5VMMS53U-ClLNiEru.js +70 -0
  312. package/dist/ui/assets/journeyDiagram-3NMN7TZE-NRawjR6i.js +139 -0
  313. package/dist/ui/assets/kanban-definition-UXKFOSKX-BgSzBfIx.js +89 -0
  314. package/dist/ui/assets/katex-DolUETbr.js +257 -0
  315. package/dist/ui/assets/line-KAyfuul4.js +1 -0
  316. package/dist/ui/assets/linear-cCCPOpu7.js +1 -0
  317. package/dist/ui/assets/mermaid-parser.core-CgXZk1iN.js +7 -0
  318. package/dist/ui/assets/mermaid.core-BUUkChHW.js +44 -0
  319. package/dist/ui/assets/mindmap-definition-YA3MSWOX-Cpn7vW4r.js +96 -0
  320. package/dist/ui/assets/ordinal-BDEzSJ7C.js +1 -0
  321. package/dist/ui/assets/packet-AYTQ26CC-0lZwM-d6.js +1 -0
  322. package/dist/ui/assets/path-COt_16Va.js +1 -0
  323. package/dist/ui/assets/pegDiagram-XKGWAZYB-impy0inn.js +1 -0
  324. package/dist/ui/assets/pie-WAS4IAKB-CqfdGHYg.js +1 -0
  325. package/dist/ui/assets/pieDiagram-E7YTZNPT-CxjL4I1h.js +39 -0
  326. package/dist/ui/assets/quadrantDiagram-AXDQQJYC-Cjd1QMIe.js +7 -0
  327. package/dist/ui/assets/radar-RG4KPBEZ-CRkQIwiv.js +1 -0
  328. package/dist/ui/assets/railroad-74A4TZTK-i67XSWTB.js +1 -0
  329. package/dist/ui/assets/railroad-abnf-HS5TGJTU-Cv9m5aS3.js +1 -0
  330. package/dist/ui/assets/railroad-ebnf-LZEXJU2U-C5ZIn-Jh.js +1 -0
  331. package/dist/ui/assets/railroad-peg-WCYAUIDC-CGra9Oq-.js +1 -0
  332. package/dist/ui/assets/railroadDiagram-O6MQD6OU-_pK0qyaY.js +1 -0
  333. package/dist/ui/assets/releases-B0WNV-SY.js +1 -0
  334. package/dist/ui/assets/repoos-cto-square.webp +0 -0
  335. package/dist/ui/assets/repoos-helpful-pm-square.webp +0 -0
  336. package/dist/ui/assets/repoos-orchestrator-square.webp +0 -0
  337. package/dist/ui/assets/repoos-performance-agent-square.webp +0 -0
  338. package/dist/ui/assets/repoos-ross-from-friends-square.webp +0 -0
  339. package/dist/ui/assets/repoos-tech-debt-agent-square.webp +0 -0
  340. package/dist/ui/assets/requirementDiagram-BXWQKSXE-CRrFX-yw.js +84 -0
  341. package/dist/ui/assets/rough.esm-By172zw-.js +1 -0
  342. package/dist/ui/assets/sankeyDiagram-P5KCCOFB-X_xFRj9E.js +40 -0
  343. package/dist/ui/assets/sequenceDiagram-WJ2MYXX4-Byb49lKf.js +162 -0
  344. package/dist/ui/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
  345. package/dist/ui/assets/src-BH-TyZbA.js +1 -0
  346. package/dist/ui/assets/stateDiagram-D77RDMKH-CzaPrqYF.js +1 -0
  347. package/dist/ui/assets/stateDiagram-v2-MP3YSRHH-BCXckz7K.js +1 -0
  348. package/dist/ui/assets/swimlanes-42K2YHIH-Cx_1lnqr.js +1 -0
  349. package/dist/ui/assets/swimlanesDiagram-VR7AAH4N-54qsKi6A.js +8 -0
  350. package/dist/ui/assets/timeline-definition-24CTP7MA-B1IbZPQ-.js +120 -0
  351. package/dist/ui/assets/treeView-Q6P3EWNA-DFWhVE3d.js +1 -0
  352. package/dist/ui/assets/treemap-WGGIJYW6-BmbYPThn.js +1 -0
  353. package/dist/ui/assets/vendor-ui-DPm7zLxi.js +3 -0
  354. package/dist/ui/assets/vendor-vue-CobgyEJp.js +1 -0
  355. package/dist/ui/assets/vennDiagram-4TSXK5OY-Ae2AtRpt.js +34 -0
  356. package/dist/ui/assets/wardley-WFR3VGLG-BtD4X4tL.js +1 -0
  357. package/dist/ui/assets/wardleyDiagram-VM6X3IG4-B3YN2fTd.js +78 -0
  358. package/dist/ui/assets/xychartDiagram-S5SC5T6Z-CIzJfMKA.js +7 -0
  359. package/dist/ui/favicon.svg +11 -0
  360. package/dist/ui/index.html +49 -0
  361. package/dist/ui/sw.js +48 -0
  362. package/package.json +91 -0
@@ -0,0 +1,1271 @@
1
+ /**
2
+ * `repoos tunnel ...` — Cloudflare Tunnel + Zero Trust publishing.
3
+ *
4
+ * RepoOS orchestrates `cloudflared` (it never reimplements the tunnel
5
+ * protocol) and reconciles Cloudflare Access policies from RepoOS's own
6
+ * `[tunnel]` state in repoos.toml. One machine runs one tunnel with many
7
+ * hostname → local service ingress routes; every published app is protected by
8
+ * an Access policy restricted to its email allowlist.
9
+ *
10
+ * `cloudflared` is one system service reading one config file per machine —
11
+ * it has no notion of "repo." So `repoos tunnel` commands are machine-global,
12
+ * not repo-local: `repoos.toml [tunnel.apps.*]` stays each repo's own,
13
+ * git-tracked source of truth for the apps IT publishes, but `install`/
14
+ * `start` always render the ingress from the UNION of every repo's apps on
15
+ * this box, tracked in the machine-level registry
16
+ * (`~/.config/repoos/tunnel-apps.toml`, see `../core/tunnel-registry.ts` and
17
+ * docs/tunnel-registry.md). Running `install` from any one repo therefore
18
+ * serves every app published by any repo, and never clobbers another repo's
19
+ * routes the way overwriting the ingress from a single repo's config used to.
20
+ *
21
+ * No Cloudflare credentials or tunnel secrets are ever written to the repo —
22
+ * the API token lives in the OS keychain / secret storage (or
23
+ * `CLOUDFLARE_API_TOKEN`), and the tunnel credential file stays in
24
+ * `~/.cloudflared/` exactly as cloudflared already does.
25
+ */
26
+ import { execFileSync, spawn } from "node:child_process";
27
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
28
+ import { homedir, hostname } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+ import { createInterface } from "node:readline/promises";
31
+ import { c } from "../cli/colors.js";
32
+ import { loadConfig } from "../core/config.js";
33
+ import { resolveBinary } from "../core/detect.js";
34
+ import { ACCESS_POLICY_NAME, DEFAULT_TUNNEL_NAME, addEmail, buildAccessAppBody, buildAccessPolicyBody, hostnameWarning, inferHostname, isValidAppName, isValidBaseDomain, isValidEmail, parseEmailList, readTunnelConfig, removeEmail, renderCloudflaredConfig, writeTunnelConfig, } from "../core/tunnel.js";
35
+ import { annotateStale, readRegistry, reconcileIdentity, registryPath, removeApp, syncFromRepo, unionApps, upsertApp, withRegistryLock, writeRegistry, } from "../core/tunnel-registry.js";
36
+ import { originPort, portListening } from "../core/net-probe.js";
37
+ const CF_API = "https://api.cloudflare.com/client/v4";
38
+ function fail(msg) {
39
+ console.error(c.red(" " + msg));
40
+ const err = new Error(msg);
41
+ err.repoosTunnel = true;
42
+ throw err;
43
+ }
44
+ function parseArgs(args) {
45
+ const positionals = [];
46
+ const flags = new Map();
47
+ for (let i = 0; i < args.length; i++) {
48
+ const a = args[i];
49
+ if (a === "--") {
50
+ positionals.push(...args.slice(i + 1));
51
+ break;
52
+ }
53
+ if (a.startsWith("--")) {
54
+ const eq = a.indexOf("=");
55
+ if (eq !== -1) {
56
+ flags.set(a.slice(2, eq), a.slice(eq + 1));
57
+ }
58
+ else {
59
+ flags.set(a.slice(2), args[++i]);
60
+ }
61
+ }
62
+ else {
63
+ positionals.push(a);
64
+ }
65
+ }
66
+ return { positionals, flags };
67
+ }
68
+ // ── Interactive input ────────────────────────────────────────────────────────
69
+ async function prompt(query) {
70
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
71
+ try {
72
+ return (await rl.question(query)).trim();
73
+ }
74
+ finally {
75
+ rl.close();
76
+ }
77
+ }
78
+ async function confirm(query, defaultYes = true) {
79
+ const suffix = defaultYes ? " [Y/n]" : " [y/N]";
80
+ const answer = (await prompt(query + suffix)).toLowerCase();
81
+ if (!answer)
82
+ return defaultYes;
83
+ return answer === "y" || answer === "yes";
84
+ }
85
+ /** Run a command attached to the user's terminal (foreground, interactive). */
86
+ function runInteractive(bin, args) {
87
+ return new Promise((resolve, reject) => {
88
+ const child = spawn(bin, args, { stdio: "inherit" });
89
+ child.on("error", (err) => reject(err));
90
+ child.on("close", (code) => resolve(code ?? 1));
91
+ });
92
+ }
93
+ // ── cloudflared discovery ────────────────────────────────────────────────────
94
+ function cloudflaredHomeDir() {
95
+ return join(homedir(), ".cloudflared");
96
+ }
97
+ /**
98
+ * Default tunnel name for a fresh setup: `repoos-<machine hostname>`. A tunnel
99
+ * is per-machine (one connector per box), so a machine-derived name is both
100
+ * legible in the Cloudflare dashboard and makes every repo on the same machine
101
+ * converge on one shared tunnel. Falls back to the legacy `repoos-local` when
102
+ * the hostname sanitizes to nothing usable.
103
+ */
104
+ function defaultTunnelName() {
105
+ const host = hostname()
106
+ .split(".")[0]
107
+ .toLowerCase()
108
+ .replace(/[^a-z0-9-]+/g, "-")
109
+ .replace(/^-+|-+$/g, "")
110
+ .slice(0, 40)
111
+ .replace(/-+$/g, "");
112
+ return host ? `repoos-${host}` : DEFAULT_TUNNEL_NAME;
113
+ }
114
+ function repoosConfigDir() {
115
+ return join(homedir(), ".config", "repoos");
116
+ }
117
+ function cloudflaredBin() {
118
+ const bin = resolveBinary("cloudflared", process.env.PATH ?? "");
119
+ if (bin)
120
+ return bin;
121
+ return fail("cloudflared is not installed or not on PATH.\n" +
122
+ " Install it (`brew install cloudflared` on macOS, or see\n" +
123
+ " https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/)\n" +
124
+ " then run `repoos tunnel setup`.");
125
+ }
126
+ /** `cloudflared tunnel list`, tolerant of JSON and legacy text output. */
127
+ function cloudflaredList(bin) {
128
+ try {
129
+ const out = execFileSync(bin, ["tunnel", "list", "--output", "json"], {
130
+ encoding: "utf8",
131
+ timeout: 30_000,
132
+ });
133
+ const data = JSON.parse(out);
134
+ if (Array.isArray(data)) {
135
+ return data.map((t) => ({
136
+ id: String(t.id ?? ""),
137
+ name: String(t.name ?? ""),
138
+ accountTag: t.account_tag ? String(t.account_tag) : undefined,
139
+ }));
140
+ }
141
+ }
142
+ catch {
143
+ // fall through to legacy text parsing
144
+ }
145
+ try {
146
+ const out = execFileSync(bin, ["tunnel", "list"], {
147
+ encoding: "utf8",
148
+ timeout: 30_000,
149
+ });
150
+ const rows = [];
151
+ for (const line of out.split("\n")) {
152
+ const m = line.match(/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\s+(\S+)/);
153
+ if (m)
154
+ rows.push({ id: m[1], name: m[2] });
155
+ }
156
+ return rows;
157
+ }
158
+ catch {
159
+ return [];
160
+ }
161
+ }
162
+ function tunnelCredentialsPath(tunnelId) {
163
+ return join(cloudflaredHomeDir(), `${tunnelId}.json`);
164
+ }
165
+ /**
166
+ * Regenerate cloudflared's ingress YAML from the machine-wide app set (the
167
+ * UNION of every repo's apps on this box, not just this repo's) into a
168
+ * RepoOS-owned location (`~/.config/repoos/cloudflared.yml`) used by
169
+ * `repoos tunnel start`.
170
+ */
171
+ function writeDerivedConfig(tunnel, apps) {
172
+ const path = join(repoosConfigDir(), "cloudflared.yml");
173
+ mkdirSync(dirname(path), { recursive: true });
174
+ writeFileSync(path, renderCloudflaredConfig({ ...tunnel, apps }, tunnelCredentialsPath(tunnel.tunnelId)), { encoding: "utf8", mode: 0o600 });
175
+ return path;
176
+ }
177
+ /**
178
+ * Write the derived config to `~/.cloudflared/config.yml` — where
179
+ * `cloudflared service install` expects it. Rendered from the machine-wide
180
+ * app set (the union of every repo's apps on this box), so installing the
181
+ * service from any one repo serves every app published by any repo. An
182
+ * existing hand-maintained file that isn't RepoOS-generated is backed up
183
+ * once before being replaced.
184
+ */
185
+ function writeUserCloudflaredConfig(tunnel, apps) {
186
+ const dir = cloudflaredHomeDir();
187
+ mkdirSync(dir, { recursive: true });
188
+ const path = join(dir, "config.yml");
189
+ if (existsSync(path)) {
190
+ const existing = readFileSync(path, "utf8");
191
+ if (!existing.includes("repoos.toml [tunnel]")) {
192
+ const backup = `${path}.repoos-backup`;
193
+ if (!existsSync(backup))
194
+ renameSync(path, backup);
195
+ }
196
+ }
197
+ writeFileSync(path, renderCloudflaredConfig({ ...tunnel, apps }, tunnelCredentialsPath(tunnel.tunnelId)), { encoding: "utf8", mode: 0o600 });
198
+ return path;
199
+ }
200
+ /**
201
+ * Run `mutate` against the machine registry inside an exclusive lock: reads
202
+ * it fresh, reconciles this repo's tunnel identity against it (failing
203
+ * loudly on a conflicting `tunnelId` rather than silently picking one),
204
+ * syncs this repo's own apps in from repoos.toml (seed new / update changed
205
+ * / drop ones this repo no longer declares — see `syncFromRepo`), lets
206
+ * `mutate` make its own change against the same in-memory registry, then
207
+ * persists the result. Every mutating tunnel subcommand (`create`,
208
+ * `destroy`, `install`, `start`, `rename`, `allow`, `deny`) goes through this
209
+ * so the registry never drifts from repoos.toml, and the whole
210
+ * read-reconcile-write cycle is serialized against any other `repoos tunnel`
211
+ * process on the machine racing the same file.
212
+ */
213
+ function mutateRegistry(root, tunnel, mutate = () => { }) {
214
+ return withRegistryLock(registryPath(), () => {
215
+ const registry = readRegistry();
216
+ const conflict = reconcileIdentity(registry, tunnel);
217
+ if (conflict)
218
+ fail(conflict);
219
+ const seeded = syncFromRepo(registry, tunnel, root);
220
+ if (seeded.length) {
221
+ console.log(c.dim(` · seeded machine tunnel registry with ${seeded.join(", ")} → ${registryPath()}`));
222
+ }
223
+ mutate(registry);
224
+ writeRegistry(registry);
225
+ return registry;
226
+ });
227
+ }
228
+ /**
229
+ * Read-only merged view of the registry for DISPLAY commands (`list`,
230
+ * `status`): same reconcile + sync as {@link mutateRegistry}, but never
231
+ * persists, never locks (nothing is written), and never fails on an
232
+ * identity conflict — it reports the conflict instead so the command can
233
+ * show it.
234
+ */
235
+ function readRegistryForDisplay(root, tunnel) {
236
+ const registry = readRegistry();
237
+ const conflict = reconcileIdentity(registry, tunnel);
238
+ syncFromRepo(registry, tunnel, root);
239
+ return { registry, conflict };
240
+ }
241
+ async function installCloudflared() {
242
+ if (process.platform === "darwin") {
243
+ console.log(c.dim(" · installing cloudflared via Homebrew …"));
244
+ try {
245
+ return (await runInteractive("brew", ["install", "cloudflared"])) === 0;
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ }
251
+ if (process.platform === "linux") {
252
+ const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "amd64" : null;
253
+ if (!arch)
254
+ return false;
255
+ const dir = join(homedir(), ".local", "bin");
256
+ mkdirSync(dir, { recursive: true });
257
+ const target = join(dir, "cloudflared");
258
+ const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}`;
259
+ console.log(c.dim(" · downloading cloudflared (GitHub releases) …"));
260
+ try {
261
+ execFileSync("curl", ["-fsSL", url, "-o", target], { stdio: "inherit", timeout: 120_000 });
262
+ chmodSync(target, 0o755);
263
+ return true;
264
+ }
265
+ catch {
266
+ return false;
267
+ }
268
+ }
269
+ return false;
270
+ }
271
+ // ── Cloudflare API token (keychain / secret storage — never in the repo) ────
272
+ function tokenStoreCmds() {
273
+ if (process.platform === "darwin") {
274
+ return {
275
+ get: [
276
+ "security",
277
+ "find-generic-password",
278
+ "-a",
279
+ "repoos",
280
+ "-s",
281
+ "repoos:cloudflare-token",
282
+ "-w",
283
+ ],
284
+ set: (token) => [
285
+ "security",
286
+ "add-generic-password",
287
+ "-U",
288
+ "-a",
289
+ "repoos",
290
+ "-s",
291
+ "repoos:cloudflare-token",
292
+ "-w",
293
+ token,
294
+ ],
295
+ };
296
+ }
297
+ if (process.platform === "linux") {
298
+ return {
299
+ get: ["secret-tool", "lookup", "service", "repoos:cloudflare-token", "user", "repoos"],
300
+ set: () => [
301
+ "secret-tool",
302
+ "store",
303
+ "--label=RepoOS Cloudflare API token",
304
+ "service",
305
+ "repoos:cloudflare-token",
306
+ "user",
307
+ "repoos",
308
+ ],
309
+ };
310
+ }
311
+ return null;
312
+ }
313
+ function getStoredToken() {
314
+ const cmds = tokenStoreCmds();
315
+ if (!cmds)
316
+ return null;
317
+ try {
318
+ const out = execFileSync(cmds.get[0], cmds.get.slice(1), { encoding: "utf8", timeout: 10_000 });
319
+ return out.trim() || null;
320
+ }
321
+ catch {
322
+ return null;
323
+ }
324
+ }
325
+ function storeToken(token) {
326
+ const cmds = tokenStoreCmds();
327
+ if (!cmds)
328
+ return false;
329
+ try {
330
+ execFileSync(cmds.set(token)[0], cmds.set(token).slice(1), {
331
+ input: token,
332
+ encoding: "utf8",
333
+ timeout: 10_000,
334
+ });
335
+ return true;
336
+ }
337
+ catch {
338
+ return false;
339
+ }
340
+ }
341
+ async function resolveApiToken() {
342
+ if (process.env.CLOUDFLARE_API_TOKEN)
343
+ return process.env.CLOUDFLARE_API_TOKEN;
344
+ const stored = getStoredToken();
345
+ if (stored)
346
+ return stored;
347
+ const entered = await prompt(" Cloudflare API token (needs `Access: Apps and Policies` + `DNS` + `Cloudflare Tunnel` edit permissions, blank to skip): ");
348
+ if (entered)
349
+ storeToken(entered);
350
+ return entered || null;
351
+ }
352
+ // ── Cloudflare API ───────────────────────────────────────────────────────────
353
+ async function cfFetch(token, path, method = "GET", body) {
354
+ let res;
355
+ try {
356
+ const init = {
357
+ method,
358
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
359
+ signal: AbortSignal.timeout(15_000),
360
+ };
361
+ if (body !== undefined)
362
+ init.body = JSON.stringify(body);
363
+ res = await fetch(CF_API + path, init);
364
+ }
365
+ catch (e) {
366
+ throw new Error("Cloudflare API unreachable: " + e.message);
367
+ }
368
+ const text = await res.text();
369
+ let json;
370
+ try {
371
+ json = JSON.parse(text);
372
+ }
373
+ catch {
374
+ json = { success: false, errors: [{ message: `HTTP ${res.status}: ${text.slice(0, 160)}` }] };
375
+ }
376
+ if (json.success !== true) {
377
+ const errors = Array.isArray(json.errors) ? json.errors : [];
378
+ const detail = errors
379
+ .map((e) => e.message ?? "")
380
+ .filter(Boolean)
381
+ .join("; ");
382
+ throw new Error(`Cloudflare API error (${res.status}): ${detail || "request failed"}`);
383
+ }
384
+ return json;
385
+ }
386
+ async function resolveAccountId(token, bin) {
387
+ if (bin) {
388
+ for (const row of cloudflaredList(bin)) {
389
+ if (row.accountTag)
390
+ return row.accountTag;
391
+ }
392
+ }
393
+ try {
394
+ const json = await cfFetch(token, "/accounts?per_page=50", "GET");
395
+ const result = json.result;
396
+ if (Array.isArray(result) && result.length) {
397
+ return String(result[0].id ?? "");
398
+ }
399
+ }
400
+ catch {
401
+ // token may not have account:read — caller can prompt for the account id
402
+ }
403
+ return null;
404
+ }
405
+ async function findAccessApp(token, accountId, hostname) {
406
+ const json = await cfFetch(token, `/accounts/${accountId}/access/apps?per_page=100`, "GET");
407
+ const apps = Array.isArray(json.result)
408
+ ? json.result
409
+ : [];
410
+ return apps.find((a) => a.domain === hostname) ?? null;
411
+ }
412
+ async function findAccessPolicy(token, accountId, appId) {
413
+ const json = await cfFetch(token, `/accounts/${accountId}/access/apps/${appId}/policies?per_page=100`, "GET");
414
+ const policies = Array.isArray(json.result)
415
+ ? json.result
416
+ : [];
417
+ return policies.find((p) => p.name === ACCESS_POLICY_NAME) ?? null;
418
+ }
419
+ /**
420
+ * Reconcile the Cloudflare Access application + allowlist policy for a
421
+ * hostname: create or reuse the self-hosted app, then create or update the
422
+ * single "repoos allowlist" policy with exactly the configured emails.
423
+ */
424
+ async function reconcileAccessPolicy(token, accountId, hostname, emails) {
425
+ let app = await findAccessApp(token, accountId, hostname);
426
+ if (!app) {
427
+ const created = await cfFetch(token, `/accounts/${accountId}/access/apps`, "POST", buildAccessAppBody(hostname));
428
+ app = created.result;
429
+ }
430
+ const appId = String(app.id ?? "");
431
+ if (!appId)
432
+ throw new Error("created Access app has no id");
433
+ const body = buildAccessPolicyBody(emails);
434
+ const existing = await findAccessPolicy(token, accountId, appId);
435
+ if (existing && existing.id) {
436
+ await cfFetch(token, `/accounts/${accountId}/access/apps/${appId}/policies/${existing.id}`, "PUT", body);
437
+ }
438
+ else {
439
+ await cfFetch(token, `/accounts/${accountId}/access/apps/${appId}/policies`, "POST", body);
440
+ }
441
+ }
442
+ /** Resolve the API token + account id required to manage Access for an app. */
443
+ async function accessClient() {
444
+ const token = await resolveApiToken();
445
+ if (!token) {
446
+ fail("No Cloudflare API token found. Set CLOUDFLARE_API_TOKEN or store one via `repoos tunnel setup` — it is required to create the Access policy that protects your app from public access.");
447
+ }
448
+ let bin = null;
449
+ try {
450
+ bin = cloudflaredBin();
451
+ }
452
+ catch {
453
+ bin = null;
454
+ }
455
+ let accountId = await resolveAccountId(token, bin);
456
+ if (!accountId) {
457
+ const entered = await prompt(" Cloudflare account ID (not auto-detectable with this token): ");
458
+ if (entered)
459
+ accountId = entered;
460
+ }
461
+ if (!accountId)
462
+ fail("Could not determine your Cloudflare account ID — check the API token permissions.");
463
+ return { token, accountId };
464
+ }
465
+ // ── Subcommands ──────────────────────────────────────────────────────────────
466
+ async function cmdTunnelSetup(_args) {
467
+ let bin = resolveBinary("cloudflared", process.env.PATH ?? "");
468
+ if (!bin) {
469
+ console.log(c.bold(c.cyan("\n RepoOS Tunnel · cloudflared is not installed")));
470
+ if (await confirm(" Install cloudflared now?")) {
471
+ const ok = await installCloudflared();
472
+ if (!ok) {
473
+ fail("cloudflared install failed — install it manually (see the downloads page) and re-run `repoos tunnel setup`.");
474
+ }
475
+ bin = resolveBinary("cloudflared", process.env.PATH ?? "");
476
+ }
477
+ else {
478
+ console.log(c.dim(" Install it yourself, then re-run `repoos tunnel setup`."));
479
+ return;
480
+ }
481
+ if (!bin) {
482
+ fail("cloudflared is not on PATH yet — open a new shell and re-run `repoos tunnel setup`.");
483
+ }
484
+ }
485
+ const cfg = loadConfig();
486
+ // 1. Authenticate cloudflared (idempotent — cert.pem persists).
487
+ const certPem = join(cloudflaredHomeDir(), "cert.pem");
488
+ if (existsSync(certPem)) {
489
+ console.log(c.dim(" ✔ cloudflared is already logged in") + c.dim(` (${certPem})`));
490
+ }
491
+ else {
492
+ console.log(c.bold("\n Sign in to Cloudflare: ") +
493
+ c.dim("a browser window will open — authorize the account and zones you want to publish under."));
494
+ let code;
495
+ try {
496
+ code = await runInteractive(bin, ["tunnel", "login"]);
497
+ }
498
+ catch (e) {
499
+ fail("failed to launch `cloudflared tunnel login`: " + e.message);
500
+ }
501
+ if (code !== 0)
502
+ fail("`cloudflared tunnel login` did not complete — re-run `repoos tunnel setup`.");
503
+ }
504
+ // 2. Cloudflare API token (for Access + DNS reconciliation) — stored in the
505
+ // OS keychain / secret storage, never in the repo.
506
+ const existingToken = await resolveApiToken();
507
+ if (existingToken) {
508
+ console.log(c.dim(" ✔ Cloudflare API token " +
509
+ (process.env.CLOUDFLARE_API_TOKEN
510
+ ? "(from CLOUDFLARE_API_TOKEN)"
511
+ : "(stored in keychain)")));
512
+ }
513
+ // 3. Create or reuse one tunnel per machine.
514
+ const tunnel = readTunnelConfig(cfg.root);
515
+ const tunnels = cloudflaredList(bin);
516
+ // Brand-new setup (no tunnel recorded in this repo yet): default to a
517
+ // machine-identifiable name — `repoos-<hostname>` — instead of the legacy
518
+ // generic `repoos-local`, so it's recognizable in the Cloudflare dashboard
519
+ // and every repo on this machine converges on the same shared tunnel. If a
520
+ // `repoos-local` tunnel already exists on the account, keep using it rather
521
+ // than stranding it beside a new name. An existing setup (tunnelId already
522
+ // recorded) always keeps whatever name it has.
523
+ if (!tunnel.tunnelId && (!tunnel.name || tunnel.name === DEFAULT_TUNNEL_NAME)) {
524
+ const hasLegacy = tunnels.some((t) => t.name === DEFAULT_TUNNEL_NAME);
525
+ tunnel.name = hasLegacy ? DEFAULT_TUNNEL_NAME : defaultTunnelName();
526
+ }
527
+ let tunnelId = tunnel.tunnelId;
528
+ const byName = tunnels.find((t) => t.name === tunnel.name);
529
+ const byId = tunnelId ? tunnels.some((t) => t.id === tunnelId) : false;
530
+ if (byName) {
531
+ tunnelId = byName.id;
532
+ console.log(c.dim(" ✔ reusing existing tunnel ") + c.cyan(tunnel.name) + c.dim(` (${byName.id})`));
533
+ }
534
+ else if (byId) {
535
+ console.log(c.dim(" ✔ tunnel already registered ") + c.cyan(tunnel.name) + c.dim(` (${tunnelId})`));
536
+ }
537
+ else {
538
+ console.log(c.bold(`\n Creating tunnel `) + c.cyan(tunnel.name) + c.dim(" …"));
539
+ let code;
540
+ try {
541
+ code = await runInteractive(bin, ["tunnel", "create", tunnel.name]);
542
+ }
543
+ catch (e) {
544
+ fail("failed to run `cloudflared tunnel create`: " + e.message);
545
+ }
546
+ if (code !== 0)
547
+ fail("`cloudflared tunnel create` failed.");
548
+ const created = cloudflaredList(bin).find((t) => t.name === tunnel.name);
549
+ if (!created)
550
+ fail("Tunnel created but not found in `cloudflared tunnel list`.");
551
+ tunnelId = created.id;
552
+ }
553
+ // 4. Base domain for hostname inference.
554
+ let domain = tunnel.domain;
555
+ if (!domain) {
556
+ for (;;) {
557
+ const entered = await prompt(" Base domain for publishing (e.g. repoos.org, blank to skip): ");
558
+ if (!entered || isValidBaseDomain(entered)) {
559
+ domain = entered;
560
+ break;
561
+ }
562
+ console.log(c.yellow(` "${entered}" doesn't look like a domain (need at least one dot, e.g. repoos.org) — try again, or leave blank to skip.`));
563
+ }
564
+ }
565
+ tunnel.name = tunnel.name || DEFAULT_TUNNEL_NAME;
566
+ tunnel.domain = domain;
567
+ tunnel.tunnelId = tunnelId;
568
+ writeTunnelConfig(cfg.root, tunnel);
569
+ console.log("\n " + c.green("✔ Tunnel configured."));
570
+ console.log(c.dim(" tunnel: ") + c.cyan(tunnel.name) + c.dim(` (${tunnelId})`));
571
+ if (tunnel.domain)
572
+ console.log(c.dim(" domain: ") + c.cyan(tunnel.domain));
573
+ console.log(c.dim(" publish: ") +
574
+ c.cyan("repoos tunnel create <name> --port <port> --allow alice@example.com"));
575
+ }
576
+ async function cmdTunnelCreate(args) {
577
+ const { positionals, flags } = parseArgs(args);
578
+ const name = positionals[0];
579
+ if (!name) {
580
+ fail("Usage: repoos tunnel create <name> --port <port> [--domain <hostname>] [--allow <emails>] [--no-access]");
581
+ }
582
+ if (!isValidAppName(name))
583
+ fail(`Invalid app name "${name}" — use letters, digits, hyphens and underscores only.`);
584
+ const cfg = loadConfig();
585
+ const tunnel = readTunnelConfig(cfg.root);
586
+ if (!tunnel.tunnelId)
587
+ fail("Tunnel not set up yet — run `repoos tunnel setup` first.");
588
+ // Peek the registry (read-only — the real seed/sync/write happens later,
589
+ // right before this app is actually persisted) so both a plain name clash
590
+ // and a pre-existing cross-repo collision fail fast, before any DNS/Access
591
+ // API calls, with a message that tells the truth about which one it is.
592
+ const registryPeek = readRegistry();
593
+ const earlyConflict = reconcileIdentity(registryPeek, tunnel);
594
+ if (earlyConflict)
595
+ fail(earlyConflict);
596
+ const elsewhere = registryPeek.apps[name];
597
+ const collision = !!elsewhere && !!elsewhere.ownerRoot && elsewhere.ownerRoot !== cfg.root;
598
+ if (tunnel.apps[name]) {
599
+ if (collision) {
600
+ fail(`App "${name}" exists in this repo's repoos.toml AND is already published by a ` +
601
+ `different repo on this machine (${elsewhere.ownerRoot}) — app names are shared ` +
602
+ `machine-wide. This looks like a naming collision from before the machine registry ` +
603
+ `existed; rename one of them (destroy and re-create it under a new name, here or there).`);
604
+ }
605
+ fail(`App "${name}" already exists — use \`repoos tunnel allow/deny\` to manage its allowlist.`);
606
+ }
607
+ if (collision) {
608
+ fail(`App "${name}" is already published by another repo on this machine (${elsewhere.ownerRoot}) — app names are shared machine-wide. Pick a different name.`);
609
+ }
610
+ const port = flags.get("port");
611
+ if (!port || !/^\d+$/.test(port))
612
+ fail("--port is required and must be a number.");
613
+ const portNum = Number(port);
614
+ if (portNum < 1 || portNum > 65535)
615
+ fail("--port must be between 1 and 65535.");
616
+ let hostname = flags.get("domain") ?? "";
617
+ if (!hostname) {
618
+ if (!tunnel.domain) {
619
+ fail("No base domain configured and no --domain given — run `repoos tunnel setup` or pass --domain.");
620
+ }
621
+ hostname = inferHostname(name, tunnel.domain);
622
+ }
623
+ if (hostname.includes("://") || hostname.includes("/"))
624
+ fail(`Invalid hostname "${hostname}".`);
625
+ // --no-access skips Cloudflare Access entirely: the app is fully public at
626
+ // Cloudflare's edge, relying solely on RepoOS's own native auth to reject
627
+ // unauthenticated requests. That's only safe if native auth is actually on
628
+ // — refuse otherwise rather than silently publish an unprotected app.
629
+ const noAccess = flags.has("no-access");
630
+ if (noAccess && flags.has("allow")) {
631
+ fail("--no-access and --allow are mutually exclusive — --no-access means there is no Access allowlist to configure.");
632
+ }
633
+ if (noAccess && cfg.auth?.enabled !== true) {
634
+ fail("--no-access requires RepoOS native auth to be on first (auth.enabled = true in repoos.toml) — " +
635
+ "without it OR Cloudflare Access, the app would be fully public with no login at all. See docs/native-auth.md.");
636
+ }
637
+ const emails = noAccess ? [] : parseEmailList(flags.get("allow"));
638
+ for (const email of emails) {
639
+ if (!isValidEmail(email))
640
+ fail(`Invalid email address "${email}".`);
641
+ }
642
+ const warn = hostnameWarning(hostname, tunnel.domain);
643
+ if (warn)
644
+ console.log(c.yellow(" ⚠ " + warn));
645
+ const bin = cloudflaredBin();
646
+ const creds = tunnelCredentialsPath(tunnel.tunnelId);
647
+ if (!existsSync(creds)) {
648
+ const known = cloudflaredList(bin).some((t) => t.id === tunnel.tunnelId);
649
+ if (!known)
650
+ fail(`Tunnel ${tunnel.name} is not in this Cloudflare account — re-run \`repoos tunnel setup\`.`);
651
+ }
652
+ // Resolve Access credentials BEFORE mutating config so a missing token can't
653
+ // leave a half-configured (unprotected) app behind. Skipped entirely for
654
+ // --no-access, which never touches Access at all.
655
+ const accessCreds = noAccess ? null : await accessClient();
656
+ tunnel.apps[name] = {
657
+ hostname,
658
+ // 127.0.0.1, not "localhost": when `repoos serve` binds 0.0.0.0 (which it
659
+ // does whenever Tailscale is detected) it listens on IPv4 only, but
660
+ // "localhost" resolves to ::1 first on a dual-stack host — cloudflared then
661
+ // dials [::1]:<port> and gets connection-refused even though the origin is
662
+ // up. An explicit IPv4 loopback always reaches a 0.0.0.0/127.0.0.1 bind.
663
+ service: `http://127.0.0.1:${portNum}`,
664
+ access: emails,
665
+ noAccess,
666
+ };
667
+ writeTunnelConfig(cfg.root, tunnel);
668
+ const registry = mutateRegistry(cfg.root, tunnel, (registry) => {
669
+ upsertApp(registry, name, tunnel.apps[name], cfg.root);
670
+ });
671
+ console.log(c.green(" ✔ configured ") +
672
+ c.cyan(name) +
673
+ c.dim(` → `) +
674
+ hostname +
675
+ c.dim(` → http://localhost:${portNum}`));
676
+ try {
677
+ console.log(c.dim(" · routing DNS ") + hostname + c.dim(" …"));
678
+ execFileSync(bin, ["tunnel", "route", "dns", "--overwrite-dns", tunnel.name, hostname], {
679
+ stdio: "inherit",
680
+ timeout: 60_000,
681
+ });
682
+ }
683
+ catch {
684
+ console.log(c.yellow(" ⚠ DNS routing failed — authorize the zone in `cloudflared tunnel login`, then run:") +
685
+ "\n " +
686
+ c.cyan(`cloudflared tunnel route dns --overwrite-dns ${tunnel.name} ${hostname}`));
687
+ }
688
+ const derived = writeDerivedConfig(tunnel, unionApps(registry));
689
+ console.log(c.dim(" · wrote ingress config → ") + derived);
690
+ if (accessCreds) {
691
+ await reconcileAccessPolicy(accessCreds.token, accessCreds.accountId, hostname, emails);
692
+ console.log(c.green(" ✔ Access policy updated — ") +
693
+ (emails.length ? emails.join(", ") : "deny all (no emails yet)"));
694
+ }
695
+ else {
696
+ console.log(c.yellow(" ⚠ No Cloudflare Access policy — this app is fully public at the edge. RepoOS native auth is its only gate."));
697
+ }
698
+ console.log("\n " + c.green("✔ App published."));
699
+ console.log(c.dim(" URL: ") + c.cyan("https://" + hostname));
700
+ if (noAccess) {
701
+ console.log(c.dim(" access: ") + c.yellow("none (--no-access) — native auth only"));
702
+ }
703
+ else if (!emails.length) {
704
+ console.log(c.dim(" allow: ") +
705
+ c.yellow("nobody yet — `repoos tunnel allow " + name + " <email>`") +
706
+ c.dim(" to let someone in"));
707
+ }
708
+ else {
709
+ console.log(c.dim(" allow: ") + emails.join(", "));
710
+ }
711
+ console.log(c.dim(" run: ") +
712
+ c.cyan("repoos tunnel start") +
713
+ c.dim(" · server: ") +
714
+ c.cyan("repoos tunnel install"));
715
+ }
716
+ /** Delete an app's Cloudflare Access app + policy, if it has one. Shared by both `destroy` paths. */
717
+ async function deleteAccessAppFor(app) {
718
+ if (app.noAccess)
719
+ return;
720
+ try {
721
+ const { token, accountId } = await accessClient();
722
+ const existing = await findAccessApp(token, accountId, app.hostname);
723
+ if (existing?.id) {
724
+ await cfFetch(token, `/accounts/${accountId}/access/apps/${existing.id}`, "DELETE");
725
+ console.log(c.green(" ✔ deleted Cloudflare Access app for ") + c.cyan(app.hostname));
726
+ }
727
+ else {
728
+ console.log(c.dim(" · no Cloudflare Access app found for ") +
729
+ app.hostname +
730
+ c.dim(" — nothing to delete there."));
731
+ }
732
+ }
733
+ catch (e) {
734
+ console.log(c.yellow(` ⚠ Failed to delete the Access app: ${e.message}`));
735
+ console.log(c.dim(" Continuing to remove it from repoos.toml anyway — clean up the stale Access app in the Cloudflare dashboard if needed."));
736
+ }
737
+ }
738
+ /**
739
+ * `destroy` for a name that isn't in THIS repo's own repoos.toml at all: the
740
+ * only legitimate reason to reach here is a registry entry whose owning
741
+ * checkout has been deleted (stale) — the normal path needs that repo's own
742
+ * toml to remove the app from, which no longer exists. Without this, a
743
+ * stale entry could only ever be cleared by hand-editing the registry file,
744
+ * despite its own header saying not to. A checkout that's still on disk is
745
+ * never touched from here — that repo is the only place its app can be
746
+ * destroyed from.
747
+ */
748
+ async function destroyStaleRegistryApp(root, name) {
749
+ const registry = readRegistry();
750
+ const entry = registry.apps[name];
751
+ if (!entry)
752
+ fail(`No app named "${name}" — see \`repoos tunnel list\`.`);
753
+ if (existsSync(entry.ownerRoot)) {
754
+ fail(`"${name}" isn't in this repo's repoos.toml — it's owned by another repo checkout still ` +
755
+ `on disk (${entry.ownerRoot}). Run \`repoos tunnel destroy ${name}\` from there.`);
756
+ }
757
+ const localTunnel = readTunnelConfig(root);
758
+ const tunnelId = localTunnel.tunnelId || registry.tunnelId;
759
+ if (!tunnelId)
760
+ fail("Tunnel not set up yet — run `repoos tunnel setup` first.");
761
+ if (!(await confirm(` Destroy stale registry entry "${name}" (${entry.hostname})? Its owning repo checkout ` +
762
+ `(${entry.ownerRoot}) no longer exists on disk, so this only removes it from the ` +
763
+ `machine registry — there is no repoos.toml left to remove it from.`, false))) {
764
+ console.log(c.dim(" cancelled."));
765
+ return;
766
+ }
767
+ await deleteAccessAppFor(entry);
768
+ const registryAfter = withRegistryLock(registryPath(), () => {
769
+ const reg = readRegistry();
770
+ removeApp(reg, name);
771
+ writeRegistry(reg);
772
+ return reg;
773
+ });
774
+ const renderTunnel = {
775
+ ...localTunnel,
776
+ tunnelId,
777
+ name: localTunnel.name || registryAfter.tunnelName || DEFAULT_TUNNEL_NAME,
778
+ domain: localTunnel.domain || registryAfter.domain,
779
+ };
780
+ const derived = writeDerivedConfig(renderTunnel, unionApps(registryAfter));
781
+ console.log(c.green(` ✔ removed stale "${name}" from the machine tunnel registry`) +
782
+ c.dim(" · ingress config rewritten → " + derived));
783
+ console.log(c.dim(" DNS record for ") +
784
+ entry.hostname +
785
+ c.dim(" was left as-is — delete it in the Cloudflare dashboard if you don't plan to recreate this app."));
786
+ }
787
+ /**
788
+ * Remove a published app: deletes its Cloudflare Access app + policy (if it
789
+ * has one), drops it from repoos.toml, and rewrites the ingress config. The
790
+ * DNS CNAME is left alone — it's harmless to leave pointed at the tunnel
791
+ * (matches nothing once the ingress route is gone → falls through to the
792
+ * 404 catch-all), and re-creating the app under the same hostname later
793
+ * needs it to still exist anyway.
794
+ */
795
+ async function cmdTunnelDestroy(args) {
796
+ const [name] = args;
797
+ if (!name)
798
+ fail("Usage: repoos tunnel destroy <name>");
799
+ const cfg = loadConfig();
800
+ const tunnel = readTunnelConfig(cfg.root);
801
+ const app = tunnel.apps[name];
802
+ if (!app) {
803
+ await destroyStaleRegistryApp(cfg.root, name);
804
+ return;
805
+ }
806
+ if (!(await confirm(` Destroy "${name}" (${app.hostname})? This removes its Cloudflare Access policy and RepoOS's record of it.`, false))) {
807
+ console.log(c.dim(" cancelled."));
808
+ return;
809
+ }
810
+ await deleteAccessAppFor(app);
811
+ delete tunnel.apps[name];
812
+ writeTunnelConfig(cfg.root, tunnel);
813
+ const registry = mutateRegistry(cfg.root, tunnel, (registry) => removeApp(registry, name));
814
+ const derived = writeDerivedConfig(tunnel, unionApps(registry));
815
+ console.log(c.green(` ✔ removed "${name}" from repoos.toml and the machine tunnel registry`) +
816
+ c.dim(" · ingress config rewritten → " + derived));
817
+ console.log(c.dim(" DNS record for ") +
818
+ app.hostname +
819
+ c.dim(" was left as-is — delete it in the Cloudflare dashboard if you don't plan to recreate this app."));
820
+ console.log(c.dim(" Restart `repoos tunnel start`/reinstall the service to pick up the ingress change."));
821
+ }
822
+ /**
823
+ * Rename the machine's tunnel (e.g. the legacy `repoos-local` → `repoos-bee`).
824
+ *
825
+ * There is no `cloudflared tunnel rename` subcommand (never has been), so this
826
+ * goes straight to the Cloudflare API — `PATCH /accounts/:id/cfd_tunnel/:uuid`
827
+ * with the new name — then updates `[tunnel].name` in repoos.toml and the
828
+ * machine registry.
829
+ *
830
+ * Nothing else needs to move: the running connector routes by UUID, the
831
+ * `<uuid>.cfargotunnel.com` DNS targets are UUID-based, and Access policies key
832
+ * off hostname. So the rename is a pure label change — no restart, no DNS
833
+ * re-pointing, no traffic interruption.
834
+ */
835
+ async function cmdTunnelRename(args) {
836
+ const [newName] = args;
837
+ if (!newName)
838
+ fail("Usage: repoos tunnel rename <new-name>");
839
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(newName)) {
840
+ fail(`Invalid tunnel name "${newName}" — start with a letter or digit; letters, digits, dots, hyphens and underscores only.`);
841
+ }
842
+ const cfg = loadConfig();
843
+ const tunnel = readTunnelConfig(cfg.root);
844
+ if (!tunnel.tunnelId)
845
+ fail("Tunnel not set up yet — run `repoos tunnel setup` first.");
846
+ if (tunnel.name === newName) {
847
+ console.log(c.dim(` Tunnel is already named "${newName}" — nothing to do.`));
848
+ return;
849
+ }
850
+ // Fail fast before touching Cloudflare — the persisted repoos.toml + registry
851
+ // update happens after the API call, below, in one locked read-modify-write.
852
+ const earlyConflict = reconcileIdentity(readRegistry(), tunnel);
853
+ if (earlyConflict)
854
+ fail(earlyConflict);
855
+ const oldName = tunnel.name || "(unnamed)";
856
+ const { token, accountId } = await accessClient();
857
+ try {
858
+ await cfFetch(token, `/accounts/${accountId}/cfd_tunnel/${tunnel.tunnelId}`, "PATCH", {
859
+ name: newName,
860
+ });
861
+ }
862
+ catch (e) {
863
+ fail(`Cloudflare rejected the rename: ${e.message}\n` +
864
+ ` The API token needs Account / Cloudflare Tunnel / Edit.`);
865
+ }
866
+ tunnel.name = newName;
867
+ writeTunnelConfig(cfg.root, tunnel);
868
+ const registry = mutateRegistry(cfg.root, tunnel, (registry) => {
869
+ registry.tunnelName = newName;
870
+ });
871
+ writeDerivedConfig(tunnel, unionApps(registry));
872
+ console.log(c.green(" ✔ renamed ") + c.cyan(oldName) + c.dim(" → ") + c.cyan(newName));
873
+ console.log(" " +
874
+ c.green("✔ Tunnel renamed.") +
875
+ c.dim(" The connector routes by UUID (unchanged), so no restart is needed and traffic is not interrupted."));
876
+ }
877
+ async function cmdTunnelAllow(args) {
878
+ await mutateAllowlist("allow", args);
879
+ }
880
+ async function cmdTunnelDeny(args) {
881
+ await mutateAllowlist("deny", args);
882
+ }
883
+ async function mutateAllowlist(op, args) {
884
+ const [name, email] = args;
885
+ if (!name || !email)
886
+ fail(`Usage: repoos tunnel ${op} <name> <email>`);
887
+ if (!isValidEmail(email))
888
+ fail(`Invalid email address "${email}".`);
889
+ const cfg = loadConfig();
890
+ const tunnel = readTunnelConfig(cfg.root);
891
+ const app = tunnel.apps[name];
892
+ if (!app)
893
+ fail(`No app named "${name}" — see \`repoos tunnel list\`.`);
894
+ if (app.noAccess) {
895
+ fail(`"${name}" was created with --no-access — there is no Access allowlist to manage. It relies on RepoOS native auth only.`);
896
+ }
897
+ const next = op === "allow" ? addEmail(app.access, email) : removeEmail(app.access, email);
898
+ if (next.length === app.access.length) {
899
+ console.log(c.dim(` ${email} is already ${op === "allow" ? "allowed" : "not on the allowlist"} for ${name} — no change.`));
900
+ return;
901
+ }
902
+ app.access = next;
903
+ writeTunnelConfig(cfg.root, tunnel);
904
+ // Same conflict gate + seed/sync as every other mutating subcommand — this
905
+ // used to bypass the conflict check entirely and silently drop the change
906
+ // on an unseeded registry instead of keeping it in sync with repoos.toml.
907
+ mutateRegistry(cfg.root, tunnel);
908
+ console.log(c.green(` ✔ ${op === "allow" ? "allowed" : "denied"} `) +
909
+ c.cyan(email) +
910
+ c.dim(` on ${name} (${next.length} on allowlist)`));
911
+ const { token, accountId } = await accessClient();
912
+ await reconcileAccessPolicy(token, accountId, app.hostname, app.access);
913
+ console.log(c.green(" ✔ Access policy reconciled for ") + c.cyan(app.hostname));
914
+ }
915
+ async function cmdTunnelStart(_args) {
916
+ const cfg = loadConfig();
917
+ const tunnel = readTunnelConfig(cfg.root);
918
+ if (!tunnel.tunnelId)
919
+ fail("Tunnel not set up yet — run `repoos tunnel setup` first.");
920
+ const registry = mutateRegistry(cfg.root, tunnel);
921
+ const apps = unionApps(registry);
922
+ if (Object.keys(apps).length === 0) {
923
+ console.log(c.yellow(" ⚠ no apps configured on this machine — the tunnel will serve 404s until you `repoos tunnel create` one."));
924
+ }
925
+ const bin = cloudflaredBin();
926
+ const configPath = writeDerivedConfig(tunnel, apps);
927
+ console.log(c.dim(" · running cloudflared in the foreground (Ctrl-C to stop)…\n"));
928
+ let code;
929
+ try {
930
+ code = await runInteractive(bin, ["tunnel", "--config", configPath, "run", tunnel.tunnelId]);
931
+ }
932
+ catch (e) {
933
+ fail("failed to start cloudflared: " + e.message);
934
+ }
935
+ process.exitCode = code;
936
+ }
937
+ async function cmdTunnelInstall(_args) {
938
+ const cfg = loadConfig();
939
+ const tunnel = readTunnelConfig(cfg.root);
940
+ if (!tunnel.tunnelId)
941
+ fail("Tunnel not set up yet — run `repoos tunnel setup` first.");
942
+ const registry = mutateRegistry(cfg.root, tunnel);
943
+ const apps = unionApps(registry);
944
+ if (Object.keys(apps).length === 0) {
945
+ console.log(c.yellow(" ⚠ no apps configured yet on this machine — the service will start but serve 404s. `repoos tunnel create` one first."));
946
+ }
947
+ const bin = cloudflaredBin();
948
+ writeDerivedConfig(tunnel, apps);
949
+ const userConfig = writeUserCloudflaredConfig(tunnel, apps);
950
+ if (process.platform === "darwin") {
951
+ let code;
952
+ try {
953
+ code = await runInteractive(bin, ["service", "install"]);
954
+ }
955
+ catch (e) {
956
+ fail("failed to run `cloudflared service install`: " + e.message);
957
+ }
958
+ if (code !== 0)
959
+ fail("`cloudflared service install` failed — see output above.");
960
+ try {
961
+ execFileSync("launchctl", ["start", "com.cloudflare.cloudflared"], {
962
+ stdio: "inherit",
963
+ timeout: 15_000,
964
+ });
965
+ }
966
+ catch {
967
+ // already started by the installer
968
+ }
969
+ console.log(c.green(" ✔ installed as a launch agent — the tunnel starts when you log in."));
970
+ }
971
+ else if (process.platform === "linux") {
972
+ const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
973
+ const run = (argv) => isRoot ? runInteractive(argv[0], argv.slice(1)) : runInteractive("sudo", argv);
974
+ // `cloudflared service install` aborts on any prior install — a leftover
975
+ // /etc/systemd/system/cloudflared.service unit ("service is already
976
+ // installed"), or, because its own Linux installer copies the config into
977
+ // /etc/cloudflared, a stale /etc/cloudflared/config.yml alongside
978
+ // ~/.cloudflared/config.yml ("possible conflicting configuration"). So the
979
+ // first `repoos tunnel install` worked and every re-run failed on what the
980
+ // first one left behind. Tear down any prior install first — best-effort,
981
+ // a missing one is fine — so re-running is idempotent.
982
+ if (existsSync("/etc/systemd/system/cloudflared.service") ||
983
+ existsSync("/etc/cloudflared/config.yml")) {
984
+ console.log(c.dim(" · clearing a previous cloudflared service install…"));
985
+ await run([bin, "service", "uninstall"]).catch(() => 0);
986
+ await run(["rm", "-rf", "/etc/cloudflared"]).catch(() => 0);
987
+ await run(["systemctl", "daemon-reload"]).catch(() => 0);
988
+ }
989
+ console.log(c.dim(" · installing cloudflared as a systemd service (may prompt for your password)…"));
990
+ let code;
991
+ try {
992
+ code = await run([bin, "--config", userConfig, "service", "install"]);
993
+ }
994
+ catch (e) {
995
+ fail("failed to run cloudflared service install: " + e.message);
996
+ }
997
+ if (code !== 0) {
998
+ fail(`\`cloudflared service install\` failed. Try it manually:\n sudo cloudflared --config ${userConfig} service install`);
999
+ }
1000
+ try {
1001
+ execFileSync("systemctl", ["start", "cloudflared"], { stdio: "inherit", timeout: 15_000 });
1002
+ }
1003
+ catch {
1004
+ // installer already started it
1005
+ }
1006
+ console.log(c.green(" ✔ installed as a systemd service — the tunnel survives reboots."));
1007
+ }
1008
+ else {
1009
+ fail("Persistent service install is only supported on macOS and Linux.");
1010
+ }
1011
+ }
1012
+ async function cmdTunnelStop(_args) {
1013
+ const bin = resolveBinary("cloudflared", process.env.PATH ?? "");
1014
+ let stoppedService = false;
1015
+ if (process.platform === "darwin") {
1016
+ try {
1017
+ execFileSync("launchctl", ["stop", "com.cloudflare.cloudflared"], {
1018
+ stdio: "inherit",
1019
+ timeout: 15_000,
1020
+ });
1021
+ stoppedService = true;
1022
+ }
1023
+ catch {
1024
+ console.log(c.yellow(" ⚠ launchctl stop failed — try `sudo launchctl stop com.cloudflare.cloudflared`"));
1025
+ }
1026
+ }
1027
+ else if (process.platform === "linux") {
1028
+ try {
1029
+ execFileSync("systemctl", ["stop", "cloudflared"], { stdio: "inherit", timeout: 15_000 });
1030
+ stoppedService = true;
1031
+ }
1032
+ catch {
1033
+ console.log(c.yellow(" ⚠ systemctl stop failed — try `sudo systemctl stop cloudflared`"));
1034
+ }
1035
+ }
1036
+ if (bin) {
1037
+ try {
1038
+ execFileSync("pkill", ["-f", "cloudflared.*tunnel.*run"], { timeout: 5_000 });
1039
+ stoppedService = true;
1040
+ }
1041
+ catch {
1042
+ // no foreground process was running
1043
+ }
1044
+ }
1045
+ console.log(stoppedService ? c.green(" ✔ tunnel stopped.") : c.dim(" No running tunnel process to stop."));
1046
+ }
1047
+ /**
1048
+ * `repoos tunnel list` is a MACHINE view, not a per-repo one: it shows every
1049
+ * app any repo on this box has published (via the registry), each annotated
1050
+ * with which repo owns it and whether that owner checkout is still on disk.
1051
+ */
1052
+ function cmdTunnelList(_args) {
1053
+ const cfg = loadConfig();
1054
+ const tunnel = readTunnelConfig(cfg.root);
1055
+ const { registry, conflict } = readRegistryForDisplay(cfg.root, tunnel);
1056
+ const tunnelId = registry.tunnelId || tunnel.tunnelId;
1057
+ if (!tunnelId) {
1058
+ console.log(c.dim(" No tunnel configured yet — run `repoos tunnel setup`."));
1059
+ return;
1060
+ }
1061
+ console.log(c.bold("\n " + c.cyan(registry.tunnelName || tunnel.name)) + c.dim(` (${tunnelId})`));
1062
+ const domain = registry.domain || tunnel.domain;
1063
+ if (domain)
1064
+ console.log(c.dim(" base domain: ") + domain);
1065
+ if (conflict)
1066
+ console.log(c.yellow(" ⚠ " + conflict));
1067
+ const rows = annotateStale(registry);
1068
+ if (!rows.length) {
1069
+ console.log(c.dim(" No apps published yet on this machine — `repoos tunnel create <name> --port 3000 --allow alice@example.com`\n"));
1070
+ return;
1071
+ }
1072
+ for (const { name, app, stale } of rows) {
1073
+ console.log("\n " + c.cyan(name) + (app.ownerRoot === cfg.root ? c.dim(" (this repo)") : ""));
1074
+ console.log(" " + c.dim("hostname: ") + app.hostname);
1075
+ console.log(" " + c.dim("service: ") + app.service);
1076
+ console.log(" " +
1077
+ c.dim("owner: ") +
1078
+ (app.ownerRoot || c.dim("(unknown)")) +
1079
+ (stale ? c.yellow(" [stale — checkout missing]") : ""));
1080
+ console.log(" " +
1081
+ c.dim("allow: ") +
1082
+ (app.noAccess
1083
+ ? c.yellow("none (--no-access) — native auth only")
1084
+ : app.access.length
1085
+ ? app.access.join(", ")
1086
+ : c.yellow("(none — deny all)")));
1087
+ }
1088
+ console.log("");
1089
+ }
1090
+ async function cmdTunnelStatus(_args) {
1091
+ const cfg = loadConfig();
1092
+ const tunnel = readTunnelConfig(cfg.root);
1093
+ const { registry, conflict } = readRegistryForDisplay(cfg.root, tunnel);
1094
+ const bin = resolveBinary("cloudflared", process.env.PATH ?? "");
1095
+ const rows = [];
1096
+ rows.push(["cloudflared", bin ? c.green("installed") : c.red("not installed")]);
1097
+ const tunnelId = registry.tunnelId || tunnel.tunnelId;
1098
+ if (!tunnelId) {
1099
+ rows.push(["tunnel", c.yellow("not configured — run `repoos tunnel setup`")]);
1100
+ }
1101
+ else {
1102
+ rows.push(["tunnel", c.cyan(registry.tunnelName || tunnel.name) + c.dim(` (${tunnelId})`)]);
1103
+ if (conflict)
1104
+ rows.push(["tunnel conflict", c.red(conflict)]);
1105
+ let serviceInstalled = false;
1106
+ let serviceActive = false;
1107
+ if (process.platform === "darwin") {
1108
+ try {
1109
+ const out = execFileSync("launchctl", ["list"], { encoding: "utf8", timeout: 10_000 });
1110
+ const line = out.split("\n").find((l) => l.includes("com.cloudflare.cloudflared"));
1111
+ if (line) {
1112
+ serviceInstalled = true;
1113
+ serviceActive = /^\s*\d+\s+/.test(line); // PID column is a number when running
1114
+ }
1115
+ }
1116
+ catch {
1117
+ // launchctl unavailable
1118
+ }
1119
+ }
1120
+ else if (process.platform === "linux") {
1121
+ try {
1122
+ const out = execFileSync("systemctl", ["is-active", "cloudflared"], {
1123
+ encoding: "utf8",
1124
+ timeout: 10_000,
1125
+ }).trim();
1126
+ serviceInstalled = out !== "inactive" && out !== "unknown" && out !== "";
1127
+ serviceActive = out === "active";
1128
+ }
1129
+ catch {
1130
+ // systemd unavailable
1131
+ }
1132
+ }
1133
+ let devRunning = false;
1134
+ try {
1135
+ const out = execFileSync("pgrep", ["-f", "cloudflared.*tunnel.*run"], {
1136
+ encoding: "utf8",
1137
+ timeout: 10_000,
1138
+ });
1139
+ devRunning = out.trim() !== "";
1140
+ }
1141
+ catch {
1142
+ // no dev process
1143
+ }
1144
+ rows.push([
1145
+ "service",
1146
+ serviceInstalled
1147
+ ? serviceActive
1148
+ ? "installed + running"
1149
+ : "installed (stopped)"
1150
+ : "not installed",
1151
+ ]);
1152
+ rows.push(["running", devRunning || serviceActive ? c.green("yes") : c.red("no")]);
1153
+ const appRows = annotateStale(registry);
1154
+ if (appRows.length) {
1155
+ console.log(c.dim(" per-app health (machine-wide)"));
1156
+ // Each app needs an origin-port probe (up to 800ms) and a hostname
1157
+ // fetch (up to 8s) — for several apps, doing those serially one app at
1158
+ // a time can take tens of seconds. Run every app's pair of probes
1159
+ // concurrently instead; `Promise.all` preserves `appRows`' order.
1160
+ const healthRows = await Promise.all(appRows.map(async ({ name, app, stale }) => {
1161
+ const port = originPort(app.service);
1162
+ const [originUp, status] = await Promise.all([
1163
+ port !== null ? portListening(port) : Promise.resolve(null),
1164
+ probeApp(app.hostname),
1165
+ ]);
1166
+ const originTag = originUp === null ? "" : originUp ? c.green(" · origin up") : c.red(" · origin down");
1167
+ const ownerTag = app.ownerRoot === cfg.root ? "this repo" : app.ownerRoot || "unknown repo";
1168
+ const staleTag = stale ? c.yellow(" · stale, checkout missing") : "";
1169
+ return [
1170
+ " " + name,
1171
+ `${app.hostname} → ` + status + originTag + staleTag + c.dim(` [${ownerTag}]`),
1172
+ ];
1173
+ }));
1174
+ rows.push(...healthRows);
1175
+ }
1176
+ }
1177
+ const width = Math.max(...rows.map(([k]) => k.length));
1178
+ console.log("\n " + c.bold("Tunnel status"));
1179
+ for (const [k, v] of rows) {
1180
+ console.log(" " + c.dim(k.padEnd(width)) + " " + v);
1181
+ }
1182
+ console.log("");
1183
+ }
1184
+ async function probeApp(hostname) {
1185
+ try {
1186
+ const res = await fetch("https://" + hostname, {
1187
+ method: "HEAD",
1188
+ redirect: "manual",
1189
+ signal: AbortSignal.timeout(8_000),
1190
+ });
1191
+ if (res.status === 301 || res.status === 302 || res.status === 303)
1192
+ return c.yellow(`reachable (redirects to Access)`);
1193
+ if (res.status >= 200 && res.status < 400)
1194
+ return c.green(`healthy (${res.status})`);
1195
+ if ([502, 504, 522, 523].includes(res.status))
1196
+ return c.red(`origin down (${res.status})`);
1197
+ return c.yellow(`responds ${res.status}`);
1198
+ }
1199
+ catch {
1200
+ return c.red("unreachable");
1201
+ }
1202
+ }
1203
+ function tunnelHelp() {
1204
+ console.log(`
1205
+ ${c.bold(c.cyan("repoos tunnel"))} — publish local apps behind Cloudflare Tunnel + Zero Trust
1206
+
1207
+ A machine runs ONE tunnel shared by every RepoOS repo on it. ${c.cyan("create")}/${c.cyan("destroy")}
1208
+ write to both this repo's repoos.toml and the machine-wide app registry
1209
+ (${c.dim("~/.config/repoos/tunnel-apps.toml")}); ${c.cyan("install")}/${c.cyan("start")}, ${c.cyan("list")} and ${c.cyan("status")} always
1210
+ reflect every app published by any repo, not just this one — see docs/tunnel-registry.md.
1211
+
1212
+ ${c.bold("USAGE")}
1213
+ repoos tunnel <subcommand> [args]
1214
+
1215
+ ${c.bold("SUBCOMMANDS")}
1216
+ ${c.cyan("setup")} One-time machine setup: install/check cloudflared, log in, create the tunnel, store the API token
1217
+ ${c.cyan("create")} <name> Publish a local app ${c.dim('flags: --port N --domain H --allow "a@x,b@y" | --no-access (requires auth.enabled)')}
1218
+ ${c.cyan("destroy")} <name> Remove a published app (deletes its Access policy, drops it from repoos.toml + the machine registry)
1219
+ ${c.cyan("rename")} <new-name> Rename this machine's tunnel in Cloudflare (e.g. repoos-local → repoos-bee) — label only, no restart
1220
+ ${c.cyan("allow")} <name> <email> Add an email to an app's allowlist
1221
+ ${c.cyan("deny")} <name> <email> Remove an email from an app's allowlist
1222
+ ${c.cyan("start")} Run cloudflared in the foreground (dev), serving every app on the machine
1223
+ ${c.cyan("install")} Install cloudflared as a persistent service (launchd/systemd), serving every app on the machine
1224
+ ${c.cyan("stop")} Stop the running tunnel (service or dev process)
1225
+ ${c.cyan("list")} Show every app published on this machine (any repo), hostnames, services, owners and allowlists
1226
+ ${c.cyan("status")} Tunnel install/running state + per-app health for every app on the machine
1227
+
1228
+ ${c.bold("EXAMPLES")}
1229
+ ${c.dim("$")} repoos tunnel setup
1230
+ ${c.dim("$")} repoos tunnel create dashboard --port 3000 --allow alice@example.com,bob@example.com
1231
+ ${c.dim("$")} repoos tunnel allow dashboard carol@example.com
1232
+ ${c.dim("$")} repoos tunnel start
1233
+ `);
1234
+ }
1235
+ // ── Dispatch ────────────────────────────────────────────────────────────────
1236
+ export async function cmdTunnel(args) {
1237
+ const [sub, ...rest] = args;
1238
+ const handlers = {
1239
+ setup: cmdTunnelSetup,
1240
+ create: cmdTunnelCreate,
1241
+ destroy: cmdTunnelDestroy,
1242
+ rename: cmdTunnelRename,
1243
+ allow: cmdTunnelAllow,
1244
+ deny: cmdTunnelDeny,
1245
+ start: cmdTunnelStart,
1246
+ install: cmdTunnelInstall,
1247
+ stop: cmdTunnelStop,
1248
+ list: cmdTunnelList,
1249
+ status: cmdTunnelStatus,
1250
+ };
1251
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
1252
+ tunnelHelp();
1253
+ return;
1254
+ }
1255
+ const fn = handlers[sub];
1256
+ if (!fn) {
1257
+ console.error(c.red(` Unknown tunnel subcommand: ${sub}`));
1258
+ tunnelHelp();
1259
+ process.exitCode = 1;
1260
+ return;
1261
+ }
1262
+ try {
1263
+ await fn(rest);
1264
+ }
1265
+ catch (e) {
1266
+ const err = e;
1267
+ if (!err.repoosTunnel)
1268
+ console.error(c.red(" " + err.message));
1269
+ process.exitCode = 1;
1270
+ }
1271
+ }