@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.
- package/LICENSE.md +110 -0
- package/README.md +272 -0
- package/dist/.build-info.json +4 -0
- package/dist/.build-stamp.json +3 -0
- package/dist/cli/colors.d.ts +13 -0
- package/dist/cli/colors.js +37 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +221 -0
- package/dist/commands/check.d.ts +170 -0
- package/dist/commands/check.js +1227 -0
- package/dist/commands/docs.d.ts +2 -0
- package/dist/commands/docs.js +70 -0
- package/dist/commands/gc.d.ts +1 -0
- package/dist/commands/gc.js +57 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +699 -0
- package/dist/commands/screenshots.d.ts +7 -0
- package/dist/commands/screenshots.js +77 -0
- package/dist/commands/serve.d.ts +55 -0
- package/dist/commands/serve.js +204 -0
- package/dist/commands/status.d.ts +140 -0
- package/dist/commands/status.js +569 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +59 -0
- package/dist/commands/tasks.d.ts +47 -0
- package/dist/commands/tasks.js +334 -0
- package/dist/commands/tunnel.d.ts +1 -0
- package/dist/commands/tunnel.js +1271 -0
- package/dist/commands/ui-harness.d.ts +53 -0
- package/dist/commands/ui-harness.js +62 -0
- package/dist/commands/ui-smoke.d.ts +6 -0
- package/dist/commands/ui-smoke.js +220 -0
- package/dist/commands/upgrade.d.ts +15 -0
- package/dist/commands/upgrade.js +188 -0
- package/dist/core/auth-store.d.ts +81 -0
- package/dist/core/auth-store.js +530 -0
- package/dist/core/auth.d.ts +75 -0
- package/dist/core/auth.js +124 -0
- package/dist/core/bootstrap.d.ts +41 -0
- package/dist/core/bootstrap.js +236 -0
- package/dist/core/build.d.ts +92 -0
- package/dist/core/build.js +249 -0
- package/dist/core/canary.d.ts +10 -0
- package/dist/core/canary.js +14 -0
- package/dist/core/check-failure-summary.d.ts +10 -0
- package/dist/core/check-failure-summary.js +34 -0
- package/dist/core/config.d.ts +160 -0
- package/dist/core/config.js +1288 -0
- package/dist/core/context-pack.d.ts +74 -0
- package/dist/core/context-pack.js +778 -0
- package/dist/core/db.d.ts +236 -0
- package/dist/core/db.js +747 -0
- package/dist/core/detect.d.ts +69 -0
- package/dist/core/detect.js +251 -0
- package/dist/core/docs.d.ts +76 -0
- package/dist/core/docs.js +225 -0
- package/dist/core/frontmatter.d.ts +29 -0
- package/dist/core/frontmatter.js +215 -0
- package/dist/core/git.d.ts +505 -0
- package/dist/core/git.js +1453 -0
- package/dist/core/indexer.d.ts +33 -0
- package/dist/core/indexer.js +282 -0
- package/dist/core/input.d.ts +66 -0
- package/dist/core/input.js +222 -0
- package/dist/core/logger.d.ts +70 -0
- package/dist/core/logger.js +138 -0
- package/dist/core/models.d.ts +45 -0
- package/dist/core/models.js +291 -0
- package/dist/core/net-probe.d.ts +4 -0
- package/dist/core/net-probe.js +20 -0
- package/dist/core/providers/deepinfra.d.ts +23 -0
- package/dist/core/providers/deepinfra.js +44 -0
- package/dist/core/providers/index.d.ts +24 -0
- package/dist/core/providers/index.js +59 -0
- package/dist/core/providers/openrouter.d.ts +28 -0
- package/dist/core/providers/openrouter.js +82 -0
- package/dist/core/providers/spend.d.ts +86 -0
- package/dist/core/providers/spend.js +286 -0
- package/dist/core/providers/types.d.ts +56 -0
- package/dist/core/providers/types.js +39 -0
- package/dist/core/repoos.d.ts +59 -0
- package/dist/core/repoos.js +221 -0
- package/dist/core/runtime.d.ts +35 -0
- package/dist/core/runtime.js +167 -0
- package/dist/core/skills-registry.d.ts +39 -0
- package/dist/core/skills-registry.js +148 -0
- package/dist/core/skills-registry.test.d.ts +1 -0
- package/dist/core/skills-registry.test.js +52 -0
- package/dist/core/sysmem.d.ts +7 -0
- package/dist/core/sysmem.js +55 -0
- package/dist/core/tailscale.d.ts +2 -0
- package/dist/core/tailscale.js +19 -0
- package/dist/core/task.d.ts +53 -0
- package/dist/core/task.js +310 -0
- package/dist/core/toml-line.d.ts +20 -0
- package/dist/core/toml-line.js +52 -0
- package/dist/core/toml-validate.d.ts +44 -0
- package/dist/core/toml-validate.js +335 -0
- package/dist/core/tunnel-assistant.d.ts +30 -0
- package/dist/core/tunnel-assistant.js +65 -0
- package/dist/core/tunnel-registry.d.ts +96 -0
- package/dist/core/tunnel-registry.js +267 -0
- package/dist/core/tunnel-registry.test.d.ts +1 -0
- package/dist/core/tunnel-registry.test.js +309 -0
- package/dist/core/tunnel.d.ts +103 -0
- package/dist/core/tunnel.js +320 -0
- package/dist/core/types.d.ts +811 -0
- package/dist/core/types.js +27 -0
- package/dist/core/worktree-gc.d.ts +57 -0
- package/dist/core/worktree-gc.js +194 -0
- package/dist/server/__tests__/integration-job.test.d.ts +1 -0
- package/dist/server/__tests__/integration-job.test.js +129 -0
- package/dist/server/agents.d.ts +1020 -0
- package/dist/server/agents.js +4070 -0
- package/dist/server/attachments.d.ts +51 -0
- package/dist/server/attachments.js +130 -0
- package/dist/server/auto-engineering.d.ts +45 -0
- package/dist/server/auto-engineering.js +275 -0
- package/dist/server/branchless-release.d.ts +22 -0
- package/dist/server/branchless-release.js +67 -0
- package/dist/server/built-in-agents.d.ts +305 -0
- package/dist/server/built-in-agents.js +1838 -0
- package/dist/server/cto-monitor.d.ts +35 -0
- package/dist/server/cto-monitor.js +281 -0
- package/dist/server/cto.d.ts +125 -0
- package/dist/server/cto.js +590 -0
- package/dist/server/deployments.d.ts +119 -0
- package/dist/server/deployments.js +325 -0
- package/dist/server/diff-snapshot.d.ts +20 -0
- package/dist/server/diff-snapshot.js +73 -0
- package/dist/server/done.d.ts +160 -0
- package/dist/server/done.js +488 -0
- package/dist/server/freeform.d.ts +55 -0
- package/dist/server/freeform.js +179 -0
- package/dist/server/handoff.d.ts +99 -0
- package/dist/server/handoff.js +575 -0
- package/dist/server/hetzner.d.ts +45 -0
- package/dist/server/hetzner.js +117 -0
- package/dist/server/icons.d.ts +8 -0
- package/dist/server/icons.js +195 -0
- package/dist/server/integration-job.d.ts +75 -0
- package/dist/server/integration-job.js +144 -0
- package/dist/server/integration-orchestrator.d.ts +211 -0
- package/dist/server/integration-orchestrator.js +1299 -0
- package/dist/server/integration-status.d.ts +48 -0
- package/dist/server/integration-status.js +71 -0
- package/dist/server/live-index.d.ts +295 -0
- package/dist/server/live-index.js +452 -0
- package/dist/server/model-test.d.ts +20 -0
- package/dist/server/model-test.js +100 -0
- package/dist/server/ntfy.d.ts +57 -0
- package/dist/server/ntfy.js +112 -0
- package/dist/server/pm-attachments.d.ts +35 -0
- package/dist/server/pm-attachments.js +181 -0
- package/dist/server/pm-runs.d.ts +58 -0
- package/dist/server/pm-runs.js +80 -0
- package/dist/server/preview.d.ts +174 -0
- package/dist/server/preview.js +641 -0
- package/dist/server/release.d.ts +88 -0
- package/dist/server/release.js +449 -0
- package/dist/server/reload.d.ts +194 -0
- package/dist/server/reload.js +586 -0
- package/dist/server/remote-validation.d.ts +132 -0
- package/dist/server/remote-validation.js +478 -0
- package/dist/server/repo-lock.d.ts +36 -0
- package/dist/server/repo-lock.js +141 -0
- package/dist/server/review-guard.d.ts +14 -0
- package/dist/server/review-guard.js +159 -0
- package/dist/server/review.d.ts +243 -0
- package/dist/server/review.js +1211 -0
- package/dist/server/routes/agents.d.ts +6 -0
- package/dist/server/routes/agents.js +28 -0
- package/dist/server/routes/auth.d.ts +60 -0
- package/dist/server/routes/auth.js +755 -0
- package/dist/server/routes/config.d.ts +20 -0
- package/dist/server/routes/config.js +347 -0
- package/dist/server/routes/debugger.d.ts +33 -0
- package/dist/server/routes/debugger.js +360 -0
- package/dist/server/routes/deployments.d.ts +3 -0
- package/dist/server/routes/deployments.js +16 -0
- package/dist/server/routes/docs.d.ts +8 -0
- package/dist/server/routes/docs.js +135 -0
- package/dist/server/routes/helpers.d.ts +21 -0
- package/dist/server/routes/helpers.js +171 -0
- package/dist/server/routes/index.d.ts +19 -0
- package/dist/server/routes/index.js +18 -0
- package/dist/server/routes/info.d.ts +17 -0
- package/dist/server/routes/info.js +177 -0
- package/dist/server/routes/inputs.d.ts +7 -0
- package/dist/server/routes/inputs.js +125 -0
- package/dist/server/routes/model-providers.d.ts +27 -0
- package/dist/server/routes/model-providers.js +94 -0
- package/dist/server/routes/models.d.ts +3 -0
- package/dist/server/routes/models.js +36 -0
- package/dist/server/routes/notify.d.ts +2 -0
- package/dist/server/routes/notify.js +17 -0
- package/dist/server/routes/playground.d.ts +29 -0
- package/dist/server/routes/playground.js +81 -0
- package/dist/server/routes/release.d.ts +20 -0
- package/dist/server/routes/release.js +86 -0
- package/dist/server/routes/router.d.ts +12 -0
- package/dist/server/routes/router.js +32 -0
- package/dist/server/routes/skill-registry.d.ts +5 -0
- package/dist/server/routes/skill-registry.js +51 -0
- package/dist/server/routes/tasks.d.ts +58 -0
- package/dist/server/routes/tasks.js +1442 -0
- package/dist/server/routes/transcribe.d.ts +2 -0
- package/dist/server/routes/transcribe.js +124 -0
- package/dist/server/routes/types.d.ts +68 -0
- package/dist/server/routes/types.js +1 -0
- package/dist/server/routes/ui.d.ts +5 -0
- package/dist/server/routes/ui.js +81 -0
- package/dist/server/routes/utils.d.ts +3 -0
- package/dist/server/routes/utils.js +23 -0
- package/dist/server/serve-reaper.d.ts +135 -0
- package/dist/server/serve-reaper.js +491 -0
- package/dist/server/server.d.ts +49 -0
- package/dist/server/server.js +2171 -0
- package/dist/server/supervisor.d.ts +89 -0
- package/dist/server/supervisor.js +231 -0
- package/dist/server/system.d.ts +198 -0
- package/dist/server/system.js +480 -0
- package/dist/server/task-check.d.ts +45 -0
- package/dist/server/task-check.js +71 -0
- package/dist/server/task-transitions.d.ts +51 -0
- package/dist/server/task-transitions.js +78 -0
- package/dist/server/task-watchdog.d.ts +158 -0
- package/dist/server/task-watchdog.js +681 -0
- package/dist/server/test-run.d.ts +28 -0
- package/dist/server/test-run.js +81 -0
- package/dist/server/watcher.d.ts +21 -0
- package/dist/server/watcher.js +194 -0
- package/dist/server/write.d.ts +94 -0
- package/dist/server/write.js +345 -0
- package/dist/ui/assets/AgentsView-CF4wRvHh.css +1 -0
- package/dist/ui/assets/AgentsView-D-myL-kA.js +1 -0
- package/dist/ui/assets/ContextView-D3TC-RSp.css +1 -0
- package/dist/ui/assets/ContextView-DsOqAdyE.js +4 -0
- package/dist/ui/assets/DashboardView-BOhsIJqO.js +1 -0
- package/dist/ui/assets/DashboardView-Ck9o0RP2.css +1 -0
- package/dist/ui/assets/DeploymentsView--iIUVfEH.css +1 -0
- package/dist/ui/assets/DeploymentsView-DXu_FYR2.js +1 -0
- package/dist/ui/assets/InputsView-24HVbVPk.js +1 -0
- package/dist/ui/assets/InputsView-BLb7GHtx.css +1 -0
- package/dist/ui/assets/LoginView-6QlGg6cx.css +1 -0
- package/dist/ui/assets/LoginView-BqupI0Lt.js +1 -0
- package/dist/ui/assets/ReleasesView-BEG1A0YH.css +1 -0
- package/dist/ui/assets/ReleasesView-CAgMuxpe.js +3 -0
- package/dist/ui/assets/SettingsView-CA-NYSMK.css +1 -0
- package/dist/ui/assets/SettingsView-O3iZSnjg.js +3 -0
- package/dist/ui/assets/WorkView-D8Tl6qwy.js +1 -0
- package/dist/ui/assets/WorkView-DMYL2s3W.css +1 -0
- package/dist/ui/assets/abnfDiagram-VCTEODGH-J5lkSnEE.js +1 -0
- package/dist/ui/assets/arc-BHZnmhhM.js +1 -0
- package/dist/ui/assets/architecture-7GRP2DOG-pLdFt-xS.js +1 -0
- package/dist/ui/assets/architectureDiagram-5GKGNRK7-C3k89uKX.js +36 -0
- package/dist/ui/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/assets/blockDiagram-I7D4REHJ-AWNoBzop.js +129 -0
- package/dist/ui/assets/button-lbikqSph.js +1 -0
- package/dist/ui/assets/c4Diagram-7LVT6UL2-DKG1D5gd.js +38 -0
- package/dist/ui/assets/channel-gA4qBv3A.js +1 -0
- package/dist/ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/assets/chunk-4HAMMTFA-D1YR8lF_.js +62 -0
- package/dist/ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/assets/chunk-75Z2AOVW-5GuBxfsL.js +2 -0
- package/dist/ui/assets/chunk-DU6HZSFF-DfdNNqwa.js +127 -0
- package/dist/ui/assets/chunk-F27PBJKO-BPZzinQ4.js +1 -0
- package/dist/ui/assets/chunk-FOHPRMQF-l2z4K2jD.js +161 -0
- package/dist/ui/assets/chunk-GMAD6QVW-DGWvSKj5.js +72 -0
- package/dist/ui/assets/chunk-GVQU2GXP-D6Aw75wJ.js +1 -0
- package/dist/ui/assets/chunk-IMKFNOWR-B8mMBKCW.js +231 -0
- package/dist/ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/assets/chunk-L3NEJ4N5-D5OJuXYE.js +1 -0
- package/dist/ui/assets/chunk-OSK3NFVY-B7r9w2UW.js +10 -0
- package/dist/ui/assets/chunk-P2QGCYS3-C74X4AQX.js +1 -0
- package/dist/ui/assets/chunk-POPQ4Y6H-C5_u1gUS.js +1 -0
- package/dist/ui/assets/chunk-PWAF6VOD-DfoTLtxn.js +1 -0
- package/dist/ui/assets/chunk-SHT3W25Y-D40m2eqz.js +168 -0
- package/dist/ui/assets/chunk-SVP7TREG-euxi0Kap.js +88 -0
- package/dist/ui/assets/chunk-TICWLB2K-fkdxngzN.js +206 -0
- package/dist/ui/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- package/dist/ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/assets/classDiagram-ZZMXUADV-BzW8bq2P.js +1 -0
- package/dist/ui/assets/classDiagram-v2-VYDZK3BY-BzW8bq2P.js +1 -0
- package/dist/ui/assets/cose-bilkent-JH36ORCC-BTcgMon1.js +1 -0
- package/dist/ui/assets/cynefin-OW5HDTMX-B5m_COsS.js +1 -0
- package/dist/ui/assets/cynefinDiagram-5FMLGOSQ-C-AG6CGe.js +62 -0
- package/dist/ui/assets/cytoscape.esm-FBntH-9t.js +321 -0
- package/dist/ui/assets/dagre-GXQ25YYZ-DDfDnp8L.js +4 -0
- package/dist/ui/assets/dagre-PrKaheQc.js +1 -0
- package/dist/ui/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/assets/diagram-S7CK7UJ4-DjQZAlEY.js +30 -0
- package/dist/ui/assets/diagram-UQ7AKVKN-DAZ9yp1B.js +41 -0
- package/dist/ui/assets/diagram-VSXAHHWV-BWepUKJD.js +3 -0
- package/dist/ui/assets/diagram-VX7I27RA-EcV1J81b.js +24 -0
- package/dist/ui/assets/diagram-Z3DM3KII-Dbz7XU_K.js +24 -0
- package/dist/ui/assets/dist-DTg6UBE_.js +1 -0
- package/dist/ui/assets/ebnfDiagram-PWID7BFC-BUyQw8Mm.js +1 -0
- package/dist/ui/assets/erDiagram-RLTQ6QDP-WVf8ukKt.js +99 -0
- package/dist/ui/assets/eventmodeling-NTZA5JFV-DHqyLOAn.js +1 -0
- package/dist/ui/assets/flowDiagram-HODETNUW-CYk_ze5K.js +1 -0
- package/dist/ui/assets/ganttDiagram-EL5Y4UJY-rB4MZuB-.js +292 -0
- package/dist/ui/assets/gitGraph-4MIJSDKK-BOTWM8dY.js +1 -0
- package/dist/ui/assets/gitGraphDiagram-WWUBYQGX-eYscuhtP.js +106 -0
- package/dist/ui/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/assets/index-BACBfnga.css +1 -0
- package/dist/ui/assets/index-BOQ-ust6.js +26 -0
- package/dist/ui/assets/info-A6RAGUB7-_67wOORe.js +1 -0
- package/dist/ui/assets/infoDiagram-27XIBGKW-XVXbddTA.js +2 -0
- package/dist/ui/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/assets/ishikawaDiagram-5VMMS53U-ClLNiEru.js +70 -0
- package/dist/ui/assets/journeyDiagram-3NMN7TZE-NRawjR6i.js +139 -0
- package/dist/ui/assets/kanban-definition-UXKFOSKX-BgSzBfIx.js +89 -0
- package/dist/ui/assets/katex-DolUETbr.js +257 -0
- package/dist/ui/assets/line-KAyfuul4.js +1 -0
- package/dist/ui/assets/linear-cCCPOpu7.js +1 -0
- package/dist/ui/assets/mermaid-parser.core-CgXZk1iN.js +7 -0
- package/dist/ui/assets/mermaid.core-BUUkChHW.js +44 -0
- package/dist/ui/assets/mindmap-definition-YA3MSWOX-Cpn7vW4r.js +96 -0
- package/dist/ui/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/assets/packet-AYTQ26CC-0lZwM-d6.js +1 -0
- package/dist/ui/assets/path-COt_16Va.js +1 -0
- package/dist/ui/assets/pegDiagram-XKGWAZYB-impy0inn.js +1 -0
- package/dist/ui/assets/pie-WAS4IAKB-CqfdGHYg.js +1 -0
- package/dist/ui/assets/pieDiagram-E7YTZNPT-CxjL4I1h.js +39 -0
- package/dist/ui/assets/quadrantDiagram-AXDQQJYC-Cjd1QMIe.js +7 -0
- package/dist/ui/assets/radar-RG4KPBEZ-CRkQIwiv.js +1 -0
- package/dist/ui/assets/railroad-74A4TZTK-i67XSWTB.js +1 -0
- package/dist/ui/assets/railroad-abnf-HS5TGJTU-Cv9m5aS3.js +1 -0
- package/dist/ui/assets/railroad-ebnf-LZEXJU2U-C5ZIn-Jh.js +1 -0
- package/dist/ui/assets/railroad-peg-WCYAUIDC-CGra9Oq-.js +1 -0
- package/dist/ui/assets/railroadDiagram-O6MQD6OU-_pK0qyaY.js +1 -0
- package/dist/ui/assets/releases-B0WNV-SY.js +1 -0
- package/dist/ui/assets/repoos-cto-square.webp +0 -0
- package/dist/ui/assets/repoos-helpful-pm-square.webp +0 -0
- package/dist/ui/assets/repoos-orchestrator-square.webp +0 -0
- package/dist/ui/assets/repoos-performance-agent-square.webp +0 -0
- package/dist/ui/assets/repoos-ross-from-friends-square.webp +0 -0
- package/dist/ui/assets/repoos-tech-debt-agent-square.webp +0 -0
- package/dist/ui/assets/requirementDiagram-BXWQKSXE-CRrFX-yw.js +84 -0
- package/dist/ui/assets/rough.esm-By172zw-.js +1 -0
- package/dist/ui/assets/sankeyDiagram-P5KCCOFB-X_xFRj9E.js +40 -0
- package/dist/ui/assets/sequenceDiagram-WJ2MYXX4-Byb49lKf.js +162 -0
- package/dist/ui/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- package/dist/ui/assets/src-BH-TyZbA.js +1 -0
- package/dist/ui/assets/stateDiagram-D77RDMKH-CzaPrqYF.js +1 -0
- package/dist/ui/assets/stateDiagram-v2-MP3YSRHH-BCXckz7K.js +1 -0
- package/dist/ui/assets/swimlanes-42K2YHIH-Cx_1lnqr.js +1 -0
- package/dist/ui/assets/swimlanesDiagram-VR7AAH4N-54qsKi6A.js +8 -0
- package/dist/ui/assets/timeline-definition-24CTP7MA-B1IbZPQ-.js +120 -0
- package/dist/ui/assets/treeView-Q6P3EWNA-DFWhVE3d.js +1 -0
- package/dist/ui/assets/treemap-WGGIJYW6-BmbYPThn.js +1 -0
- package/dist/ui/assets/vendor-ui-DPm7zLxi.js +3 -0
- package/dist/ui/assets/vendor-vue-CobgyEJp.js +1 -0
- package/dist/ui/assets/vennDiagram-4TSXK5OY-Ae2AtRpt.js +34 -0
- package/dist/ui/assets/wardley-WFR3VGLG-BtD4X4tL.js +1 -0
- package/dist/ui/assets/wardleyDiagram-VM6X3IG4-B3YN2fTd.js +78 -0
- package/dist/ui/assets/xychartDiagram-S5SC5T6Z-CIzJfMKA.js +7 -0
- package/dist/ui/favicon.svg +11 -0
- package/dist/ui/index.html +49 -0
- package/dist/ui/sw.js +48 -0
- 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
|
+
}
|