@thevinci/web 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -0
- package/bin/cli-entry.js +55 -0
- package/bin/cli-output.js +145 -0
- package/bin/cli.js +4887 -0
- package/bin/cli.test.js +64 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +528 -0
- package/dist/assets/JsonTreeView-CSm9OzXG.js +1 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/MarkdownRendererImpl-DensKOLc.js +6 -0
- package/dist/assets/MultiRunWindow-Bo7THayo.js +1 -0
- package/dist/assets/OnboardingScreen-BDqmzTVR.js +2 -0
- package/dist/assets/SettingsWindow-coz__Ykw.js +1 -0
- package/dist/assets/TerminalView-DrZ-i3Dr.js +1 -0
- package/dist/assets/ToolOutputDialog-Eglzslt3.js +16 -0
- package/dist/assets/es-4o9ciP61.js +15 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-DLTDToSP.css +1 -0
- package/dist/assets/index-DgiFEKGN.js +1 -0
- package/dist/assets/ko-B20imCHE.js +15 -0
- package/dist/assets/main-BV3KOtdA.css +1 -0
- package/dist/assets/main-CDKJj0sH.js +226 -0
- package/dist/assets/main-LC-PSNVM.js +2 -0
- package/dist/assets/miniChat-CQUiG_cr.js +2 -0
- package/dist/assets/modelPrefsAutoSave-Dm799vzR.js +6986 -0
- package/dist/assets/pl-DQJ7LSzj.js +15 -0
- package/dist/assets/pt-BR-OmjHUz9y.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-CARbeW0G.js +2 -0
- package/dist/assets/uk-BNFxOlO4.js +15 -0
- package/dist/assets/vendor--DBfsbEis.css +1 -0
- package/dist/assets/vendor-.bun-B9l0ZNi2.js +4094 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/wasmSttWorker-Dtlxac_K.js +1 -0
- package/dist/assets/wasmSttWorker-oo7Dm_jy.js +1806 -0
- package/dist/assets/worker-CbT6TVo7.js +155 -0
- package/dist/assets/zh-CN-C6T-Ac7F.js +15 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +528 -0
- package/dist/index.html +607 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.png +0 -0
- package/dist/logo-dark-512x512.svg +528 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.png +0 -0
- package/dist/logo-light-512x512.svg +528 -0
- package/dist/mini-chat.html +16 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +21 -0
- package/dist/sw.js +1 -0
- package/package.json +118 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +528 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +528 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.png +0 -0
- package/public/logo-dark-512x512.svg +528 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.png +0 -0
- package/public/logo-light-512x512.svg +528 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +21 -0
- package/server/TERMINAL_WS_PROTOCOL.md +48 -0
- package/server/index.d.ts +39 -0
- package/server/index.js +1311 -0
- package/server/lib/cloudflare-tunnel.js +650 -0
- package/server/lib/event-stream/DOCUMENTATION.md +61 -0
- package/server/lib/event-stream/directory-ws-bridge.js +185 -0
- package/server/lib/event-stream/global-hub.js +158 -0
- package/server/lib/event-stream/global-hub.test.js +140 -0
- package/server/lib/event-stream/global-ws-bridge.js +206 -0
- package/server/lib/event-stream/index.js +25 -0
- package/server/lib/event-stream/protocol.js +131 -0
- package/server/lib/event-stream/protocol.test.js +182 -0
- package/server/lib/event-stream/runtime.js +180 -0
- package/server/lib/event-stream/runtime.test.js +512 -0
- package/server/lib/event-stream/upstream-reader.js +226 -0
- package/server/lib/event-stream/upstream-reader.test.js +276 -0
- package/server/lib/fs/DOCUMENTATION.md +36 -0
- package/server/lib/fs/routes.js +1040 -0
- package/server/lib/fs/search.js +238 -0
- package/server/lib/git/DOCUMENTATION.md +152 -0
- package/server/lib/git/credentials.js +74 -0
- package/server/lib/git/identity-storage.js +112 -0
- package/server/lib/git/index.js +6 -0
- package/server/lib/git/routes.js +972 -0
- package/server/lib/git/service.js +3432 -0
- package/server/lib/git/service.test.js +39 -0
- package/server/lib/github/DOCUMENTATION.md +171 -0
- package/server/lib/github/auth.js +307 -0
- package/server/lib/github/device-flow.js +50 -0
- package/server/lib/github/index.js +24 -0
- package/server/lib/github/octokit.js +10 -0
- package/server/lib/github/pr-status.js +519 -0
- package/server/lib/github/repo/fork-detection.js +102 -0
- package/server/lib/github/repo/index.js +55 -0
- package/server/lib/github/routes.js +1560 -0
- package/server/lib/magic-prompts/routes.js +63 -0
- package/server/lib/magic-prompts/runtime.js +119 -0
- package/server/lib/notifications/DOCUMENTATION.md +122 -0
- package/server/lib/notifications/emitter-runtime.js +102 -0
- package/server/lib/notifications/index.js +4 -0
- package/server/lib/notifications/message.js +52 -0
- package/server/lib/notifications/message.test.js +34 -0
- package/server/lib/notifications/push-runtime.js +304 -0
- package/server/lib/notifications/routes.js +315 -0
- package/server/lib/notifications/runtime.js +566 -0
- package/server/lib/notifications/template-runtime.js +349 -0
- package/server/lib/notifications/template-runtime.test.js +26 -0
- package/server/lib/opencode/DOCUMENTATION.md +362 -0
- package/server/lib/opencode/agents.js +634 -0
- package/server/lib/opencode/auth-state-runtime.js +88 -0
- package/server/lib/opencode/auth.js +83 -0
- package/server/lib/opencode/bootstrap-runtime.js +131 -0
- package/server/lib/opencode/cli-entry-runtime.js +43 -0
- package/server/lib/opencode/cli-options.js +128 -0
- package/server/lib/opencode/commands.js +339 -0
- package/server/lib/opencode/config-entity-routes.js +370 -0
- package/server/lib/opencode/core-routes.js +500 -0
- package/server/lib/opencode/core-routes.test.js +26 -0
- package/server/lib/opencode/env-config.js +74 -0
- package/server/lib/opencode/env-keys.js +68 -0
- package/server/lib/opencode/env-runtime.js +1162 -0
- package/server/lib/opencode/env-runtime.test.js +116 -0
- package/server/lib/opencode/feature-routes-runtime.js +244 -0
- package/server/lib/opencode/hmr-state-runtime.js +85 -0
- package/server/lib/opencode/index.js +66 -0
- package/server/lib/opencode/lifecycle.js +1019 -0
- package/server/lib/opencode/lifecycle.test.js +240 -0
- package/server/lib/opencode/mcp.js +278 -0
- package/server/lib/opencode/network-runtime.js +104 -0
- package/server/lib/opencode/network-runtime.test.js +37 -0
- package/server/lib/opencode/opencode-resolution-runtime.js +71 -0
- package/server/lib/opencode/path-utils.js +100 -0
- package/server/lib/opencode/path-utils.test.js +71 -0
- package/server/lib/opencode/project-directory-runtime.js +124 -0
- package/server/lib/opencode/project-icon-routes.js +399 -0
- package/server/lib/opencode/project-icon-routes.test.js +107 -0
- package/server/lib/opencode/providers.js +96 -0
- package/server/lib/opencode/proxy.js +445 -0
- package/server/lib/opencode/pwa-manifest-routes.js +257 -0
- package/server/lib/opencode/pwa-manifest-routes.test.js +133 -0
- package/server/lib/opencode/routes.js +541 -0
- package/server/lib/opencode/server-startup-runtime.js +156 -0
- package/server/lib/opencode/server-utils-runtime.js +168 -0
- package/server/lib/opencode/server-utils-runtime.test.js +135 -0
- package/server/lib/opencode/session-runtime.js +356 -0
- package/server/lib/opencode/session-runtime.test.js +151 -0
- package/server/lib/opencode/settings-helpers.js +770 -0
- package/server/lib/opencode/settings-helpers.test.js +109 -0
- package/server/lib/opencode/settings-normalization-runtime.js +428 -0
- package/server/lib/opencode/settings-runtime.js +826 -0
- package/server/lib/opencode/settings-runtime.test.js +85 -0
- package/server/lib/opencode/shared.js +615 -0
- package/server/lib/opencode/shutdown-runtime.js +139 -0
- package/server/lib/opencode/shutdown-runtime.test.js +58 -0
- package/server/lib/opencode/skill-routes.js +701 -0
- package/server/lib/opencode/skills.js +548 -0
- package/server/lib/opencode/startup-pipeline-runtime.js +130 -0
- package/server/lib/opencode/static-routes-runtime.js +65 -0
- package/server/lib/opencode/theme-runtime.js +167 -0
- package/server/lib/opencode/tunnel-auth.js +591 -0
- package/server/lib/opencode/tunnel-wiring-runtime.js +94 -0
- package/server/lib/opencode/vinci-routes.js +76 -0
- package/server/lib/opencode/watcher.js +115 -0
- package/server/lib/opencode/watcher.test.js +239 -0
- package/server/lib/preview/proxy-runtime.js +1333 -0
- package/server/lib/preview/proxy-runtime.test.js +144 -0
- package/server/lib/projects/project-config.js +567 -0
- package/server/lib/projects/project-config.test.js +175 -0
- package/server/lib/projects/project-id.js +13 -0
- package/server/lib/quota/DOCUMENTATION.md +58 -0
- package/server/lib/quota/index.js +25 -0
- package/server/lib/quota/providers/claude.js +107 -0
- package/server/lib/quota/providers/codex.js +113 -0
- package/server/lib/quota/providers/copilot.js +165 -0
- package/server/lib/quota/providers/google/api.js +92 -0
- package/server/lib/quota/providers/google/auth.js +108 -0
- package/server/lib/quota/providers/google/index.js +124 -0
- package/server/lib/quota/providers/google/transforms.js +109 -0
- package/server/lib/quota/providers/index.js +168 -0
- package/server/lib/quota/providers/interface.js +55 -0
- package/server/lib/quota/providers/kimi.js +108 -0
- package/server/lib/quota/providers/minimax-cn-coding-plan.js +140 -0
- package/server/lib/quota/providers/minimax-coding-plan.js +139 -0
- package/server/lib/quota/providers/nanogpt.js +124 -0
- package/server/lib/quota/providers/ollama-cloud.js +112 -0
- package/server/lib/quota/providers/openai.js +91 -0
- package/server/lib/quota/providers/openrouter.js +92 -0
- package/server/lib/quota/providers/zai.js +91 -0
- package/server/lib/quota/providers/zhipuai-coding-plan.js +133 -0
- package/server/lib/quota/providers/zhipuai.js +114 -0
- package/server/lib/quota/routes.js +27 -0
- package/server/lib/quota/utils/auth.js +50 -0
- package/server/lib/quota/utils/formatters.js +85 -0
- package/server/lib/quota/utils/formatters.test.js +54 -0
- package/server/lib/quota/utils/index.js +10 -0
- package/server/lib/quota/utils/transformers.js +55 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +44 -0
- package/server/lib/scheduled-tasks/routes.js +235 -0
- package/server/lib/scheduled-tasks/runtime.js +773 -0
- package/server/lib/scheduled-tasks/runtime.test.js +100 -0
- package/server/lib/security/request-security.js +115 -0
- package/server/lib/session-folders/routes.js +63 -0
- package/server/lib/session-folders/routes.test.js +102 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
- package/server/lib/skills-catalog/cache.js +29 -0
- package/server/lib/skills-catalog/clawdhub/api.js +158 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +238 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
- package/server/lib/skills-catalog/curated-sources.js +21 -0
- package/server/lib/skills-catalog/git.js +77 -0
- package/server/lib/skills-catalog/index.js +42 -0
- package/server/lib/skills-catalog/install.js +294 -0
- package/server/lib/skills-catalog/scan.js +221 -0
- package/server/lib/skills-catalog/source.js +87 -0
- package/server/lib/terminal/DOCUMENTATION.md +76 -0
- package/server/lib/terminal/index.js +31 -0
- package/server/lib/terminal/output-replay-buffer.js +78 -0
- package/server/lib/terminal/output-replay-buffer.test.js +75 -0
- package/server/lib/terminal/runtime.js +850 -0
- package/server/lib/terminal/runtime.test.js +96 -0
- package/server/lib/terminal/terminal-ws-protocol.js +68 -0
- package/server/lib/terminal/terminal-ws-protocol.test.js +145 -0
- package/server/lib/text/DOCUMENTATION.md +35 -0
- package/server/lib/text/summarization.js +138 -0
- package/server/lib/text/summarization.test.js +34 -0
- package/server/lib/tts/DOCUMENTATION.md +146 -0
- package/server/lib/tts/base-url.js +62 -0
- package/server/lib/tts/capability-runtime.js +31 -0
- package/server/lib/tts/index.js +19 -0
- package/server/lib/tts/routes.js +261 -0
- package/server/lib/tts/routes.test.js +53 -0
- package/server/lib/tts/service.js +178 -0
- package/server/lib/tts/stt.js +75 -0
- package/server/lib/tunnels/DOCUMENTATION.md +18 -0
- package/server/lib/tunnels/index.js +166 -0
- package/server/lib/tunnels/managed-config.js +201 -0
- package/server/lib/tunnels/providers/cloudflare.js +260 -0
- package/server/lib/tunnels/registry.js +51 -0
- package/server/lib/tunnels/routes.js +605 -0
- package/server/lib/tunnels/types.js +219 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +38 -0
- package/server/lib/ui-auth/ui-auth.js +673 -0
- package/server/lib/ui-auth/ui-passkeys.js +545 -0
- package/server/opencode-proxy.test.js +151 -0
- package/server/proxy-headers.js +61 -0
- package/server/proxy-headers.test.js +58 -0
- package/server/sse-routes.test.js +152 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,4887 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import net from 'net';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import { spawn, spawnSync } from 'child_process';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
10
|
+
import { isModuleCliExecution } from './cli-entry.js';
|
|
11
|
+
import { cloudflareTunnelProviderCapabilities } from '../server/lib/tunnels/providers/cloudflare.js';
|
|
12
|
+
import {
|
|
13
|
+
intro as clackIntro, outro as clackOutro, log as clackLog,
|
|
14
|
+
box as clackBox, confirm as clackConfirm,
|
|
15
|
+
select as clackSelect, text as clackText, password as clackPassword, cancel as clackCancel,
|
|
16
|
+
isCancel as clackIsCancel,
|
|
17
|
+
isJsonMode,
|
|
18
|
+
isQuietMode,
|
|
19
|
+
shouldRenderHumanOutput,
|
|
20
|
+
canPrompt,
|
|
21
|
+
createSpinner,
|
|
22
|
+
createProgress,
|
|
23
|
+
printJson,
|
|
24
|
+
logStatus, formatProviderWithIcon as clackFormatProviderWithIcon,
|
|
25
|
+
} from './cli-output.js';
|
|
26
|
+
|
|
27
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
28
|
+
const __dirname = path.dirname(__filename);
|
|
29
|
+
|
|
30
|
+
const DEFAULT_PORT = 3900;
|
|
31
|
+
const DEFAULT_TAIL_LINES = 200;
|
|
32
|
+
const DAEMON_READY_TIMEOUT_MS = 30000;
|
|
33
|
+
const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
|
|
34
|
+
const LOG_ROTATE_KEEP = 5;
|
|
35
|
+
const TUNNEL_PROFILES_VERSION = 1;
|
|
36
|
+
const TUNNEL_PROFILES_FILE_NAME = 'tunnel-profiles.json';
|
|
37
|
+
const LEGACY_CLOUDFLARE_MANAGED_REMOTE_FILE_NAME = 'cloudflare-managed-remote-tunnels.json';
|
|
38
|
+
const TUNNEL_CLI_STATE_FILE_NAME = 'tunnel-cli-state.json';
|
|
39
|
+
const TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS = 30 * 60 * 1000;
|
|
40
|
+
const TUNNEL_BOOTSTRAP_TTL_MIN_MS = 60 * 1000;
|
|
41
|
+
const TUNNEL_BOOTSTRAP_TTL_MAX_MS = 24 * 60 * 60 * 1000;
|
|
42
|
+
const TUNNEL_SESSION_TTL_DEFAULT_MS = 8 * 60 * 60 * 1000;
|
|
43
|
+
const TUNNEL_SESSION_TTL_MIN_MS = 5 * 60 * 1000;
|
|
44
|
+
const TUNNEL_SESSION_TTL_MAX_MS = 30 * 24 * 60 * 60 * 1000;
|
|
45
|
+
const CONNECT_TTL_PICKER_OPTIONS = [
|
|
46
|
+
{ value: String(3 * 60 * 1000), label: '3m' },
|
|
47
|
+
{ value: String(TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS), label: '30m' },
|
|
48
|
+
{ value: String(2 * 60 * 60 * 1000), label: '2h' },
|
|
49
|
+
{ value: String(8 * 60 * 60 * 1000), label: '8h' },
|
|
50
|
+
{ value: String(24 * 60 * 60 * 1000), label: '24h' },
|
|
51
|
+
{ value: '__custom__', label: 'Custom' },
|
|
52
|
+
];
|
|
53
|
+
const SESSION_TTL_PICKER_OPTIONS = [
|
|
54
|
+
{ value: String(60 * 60 * 1000), label: '1h' },
|
|
55
|
+
{ value: String(TUNNEL_SESSION_TTL_DEFAULT_MS), label: '8h' },
|
|
56
|
+
{ value: String(12 * 60 * 60 * 1000), label: '12h' },
|
|
57
|
+
{ value: String(24 * 60 * 60 * 1000), label: '24h' },
|
|
58
|
+
{ value: String(7 * 24 * 60 * 60 * 1000), label: '1w' },
|
|
59
|
+
{ value: String(30 * 24 * 60 * 60 * 1000), label: '30d' },
|
|
60
|
+
{ value: '__custom__', label: 'Custom' },
|
|
61
|
+
];
|
|
62
|
+
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
63
|
+
const DEFAULT_TUNNEL_PROVIDER_CAPABILITIES = [cloudflareTunnelProviderCapabilities];
|
|
64
|
+
|
|
65
|
+
let onCancelCleanup = null;
|
|
66
|
+
let activeCommandOptions = null;
|
|
67
|
+
let foregroundServerActive = false;
|
|
68
|
+
let foregroundShutdown = null;
|
|
69
|
+
|
|
70
|
+
function setCancelCleanup(handler) {
|
|
71
|
+
onCancelCleanup = typeof handler === 'function' ? handler : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const HAS_PLAIN_FLAG = process.argv.includes('--plain');
|
|
75
|
+
const STYLE_ENABLED = process.stdout.isTTY && process.env.NO_COLOR !== '1' && !HAS_PLAIN_FLAG;
|
|
76
|
+
const ANSI = {
|
|
77
|
+
bold: '\x1b[1m',
|
|
78
|
+
unbold: '\x1b[22m',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Browser-unsafe ports (Fetch/Chromium restricted ports).
|
|
82
|
+
const UNSAFE_BROWSER_PORTS = new Set([
|
|
83
|
+
0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69,
|
|
84
|
+
77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119,
|
|
85
|
+
123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515,
|
|
86
|
+
526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990,
|
|
87
|
+
993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566,
|
|
88
|
+
6665, 6666, 6667, 6668, 6669, 6697, 10080,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
const EXIT_CODE = {
|
|
92
|
+
SUCCESS: 0,
|
|
93
|
+
GENERAL_ERROR: 1,
|
|
94
|
+
USAGE_ERROR: 2,
|
|
95
|
+
MISSING_DEPENDENCY: 3,
|
|
96
|
+
AUTH_CONFIG_ERROR: 4,
|
|
97
|
+
NETWORK_RUNTIME_ERROR: 5,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
class TunnelCliError extends Error {
|
|
101
|
+
constructor(message, exitCode = EXIT_CODE.GENERAL_ERROR) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.name = 'TunnelCliError';
|
|
104
|
+
this.exitCode = exitCode;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
function boldText(text) {
|
|
111
|
+
if (!STYLE_ENABLED) return text;
|
|
112
|
+
return `${ANSI.bold}${text}${ANSI.unbold}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getDefaultCloudflaredConfigPath() {
|
|
116
|
+
return path.join(os.homedir(), '.cloudflared', 'config.yml');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isReadableRegularFile(filePath) {
|
|
120
|
+
if (typeof filePath !== 'string' || filePath.trim().length === 0) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const stat = fs.statSync(filePath);
|
|
125
|
+
if (!stat.isFile()) return false;
|
|
126
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isUnsafeBrowserPort(port) {
|
|
134
|
+
return Number.isFinite(port) && UNSAFE_BROWSER_PORTS.has(Math.trunc(port));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveApiHost() {
|
|
138
|
+
const configured = typeof process.env.VINCI_HOST === 'string'
|
|
139
|
+
? process.env.VINCI_HOST.trim()
|
|
140
|
+
: '';
|
|
141
|
+
|
|
142
|
+
if (!configured) {
|
|
143
|
+
return '127.0.0.1';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Wildcard bind hosts are not valid destination hosts.
|
|
147
|
+
if (configured === '0.0.0.0') {
|
|
148
|
+
return '127.0.0.1';
|
|
149
|
+
}
|
|
150
|
+
if (configured === '::' || configured === '[::]') {
|
|
151
|
+
return '::1';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Strip brackets if user provided [::1]
|
|
155
|
+
if (configured.startsWith('[') && configured.endsWith(']')) {
|
|
156
|
+
return configured.slice(1, -1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return configured;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function formatHostForUrl(host) {
|
|
163
|
+
if (typeof host !== 'string') return '127.0.0.1';
|
|
164
|
+
// Bracket IPv6 for URL usage.
|
|
165
|
+
return host.includes(':') ? `[${host}]` : host;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function buildLocalUrl(port, endpoint = '') {
|
|
169
|
+
const host = formatHostForUrl(resolveApiHost());
|
|
170
|
+
const pathPart = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
171
|
+
return `http://${host}:${port}${pathPart}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function formatUnsafePortWarning(port) {
|
|
175
|
+
return `Port ${port} is browser-unsafe (ERR_UNSAFE_PORT) and is not supported for Vinci UI at ${buildLocalUrl(port, '/')}.`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function assertSafeBrowserPort(port, { context = 'This action' } = {}) {
|
|
179
|
+
if (!isUnsafeBrowserPort(port)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
throw new TunnelCliError(
|
|
183
|
+
`${context} cannot use port ${port}. ${formatUnsafePortWarning(port)} Use a safe port such as 3000, 5173, 8080, or a high ephemeral port.`,
|
|
184
|
+
EXIT_CODE.USAGE_ERROR,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseHumanDurationToMs(value) {
|
|
189
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
190
|
+
return Math.round(value);
|
|
191
|
+
}
|
|
192
|
+
if (typeof value !== 'string') {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const trimmed = value.trim().toLowerCase();
|
|
197
|
+
if (!trimmed) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (/^\d+$/.test(trimmed)) {
|
|
202
|
+
return Number.parseInt(trimmed, 10);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const normalized = trimmed.replace(/\s+/g, '');
|
|
206
|
+
const pattern = /(\d+)(ms|s|m|h|d)/g;
|
|
207
|
+
let cursor = 0;
|
|
208
|
+
let total = 0;
|
|
209
|
+
let match;
|
|
210
|
+
while ((match = pattern.exec(normalized)) !== null) {
|
|
211
|
+
if (match.index !== cursor) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
cursor = pattern.lastIndex;
|
|
215
|
+
const amount = Number.parseInt(match[1], 10);
|
|
216
|
+
const unit = match[2];
|
|
217
|
+
const unitMs = unit === 'ms'
|
|
218
|
+
? 1
|
|
219
|
+
: unit === 's'
|
|
220
|
+
? 1000
|
|
221
|
+
: unit === 'm'
|
|
222
|
+
? 60 * 1000
|
|
223
|
+
: unit === 'h'
|
|
224
|
+
? 60 * 60 * 1000
|
|
225
|
+
: 24 * 60 * 60 * 1000;
|
|
226
|
+
total += amount * unitMs;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (cursor !== normalized.length) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return total;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseTtlMsOrThrow(rawValue, {
|
|
237
|
+
flagName,
|
|
238
|
+
minMs,
|
|
239
|
+
maxMs,
|
|
240
|
+
} = {}) {
|
|
241
|
+
const parsed = parseHumanDurationToMs(rawValue);
|
|
242
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
243
|
+
throw new TunnelCliError(
|
|
244
|
+
`Invalid value for ${flagName}. Use a positive duration like 30m, 24h, 1d, or milliseconds.`,
|
|
245
|
+
EXIT_CODE.USAGE_ERROR,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (parsed < minMs || parsed > maxMs) {
|
|
249
|
+
throw new TunnelCliError(
|
|
250
|
+
`${flagName} must be between ${minMs}ms and ${maxMs}ms.`,
|
|
251
|
+
EXIT_CODE.USAGE_ERROR,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return parsed;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function formatDurationForCli(ms) {
|
|
258
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const value = Math.round(ms);
|
|
262
|
+
if (value % (24 * 60 * 60 * 1000) === 0) return `${value / (24 * 60 * 60 * 1000)}d`;
|
|
263
|
+
if (value % (60 * 60 * 1000) === 0) return `${value / (60 * 60 * 1000)}h`;
|
|
264
|
+
if (value % (60 * 1000) === 0) return `${value / (60 * 1000)}m`;
|
|
265
|
+
if (value % 1000 === 0) return `${value / 1000}s`;
|
|
266
|
+
return `${value}ms`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function shellQuote(value) {
|
|
270
|
+
const text = String(value);
|
|
271
|
+
if (/^[A-Za-z0-9._\-/:=]+$/.test(text)) {
|
|
272
|
+
return text;
|
|
273
|
+
}
|
|
274
|
+
return `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function buildTunnelStartReplayCommand({
|
|
278
|
+
port,
|
|
279
|
+
provider,
|
|
280
|
+
mode,
|
|
281
|
+
profileName,
|
|
282
|
+
configPath,
|
|
283
|
+
hostname,
|
|
284
|
+
connectTtlMs,
|
|
285
|
+
sessionTtlMs,
|
|
286
|
+
qr,
|
|
287
|
+
noQr,
|
|
288
|
+
includeTokenPlaceholder,
|
|
289
|
+
tokenViaStdin,
|
|
290
|
+
tokenFileProvided,
|
|
291
|
+
}) {
|
|
292
|
+
const parts = ['vinci', 'tunnel', 'start'];
|
|
293
|
+
if (Number.isFinite(port) && port > 0) {
|
|
294
|
+
parts.push('--port', String(port));
|
|
295
|
+
}
|
|
296
|
+
if (profileName) {
|
|
297
|
+
parts.push('--profile', shellQuote(profileName));
|
|
298
|
+
}
|
|
299
|
+
if (provider) {
|
|
300
|
+
parts.push('--provider', shellQuote(provider));
|
|
301
|
+
}
|
|
302
|
+
if (mode) {
|
|
303
|
+
parts.push('--mode', shellQuote(mode));
|
|
304
|
+
}
|
|
305
|
+
if (typeof configPath === 'string' && configPath.trim().length > 0) {
|
|
306
|
+
parts.push('--config', shellQuote(configPath));
|
|
307
|
+
}
|
|
308
|
+
if (typeof hostname === 'string' && hostname.trim().length > 0) {
|
|
309
|
+
parts.push('--hostname', shellQuote(hostname));
|
|
310
|
+
}
|
|
311
|
+
const connectTtl = formatDurationForCli(connectTtlMs);
|
|
312
|
+
if (connectTtl) {
|
|
313
|
+
parts.push('--connect-ttl', connectTtl);
|
|
314
|
+
}
|
|
315
|
+
const sessionTtl = formatDurationForCli(sessionTtlMs);
|
|
316
|
+
if (sessionTtl) {
|
|
317
|
+
parts.push('--session-ttl', sessionTtl);
|
|
318
|
+
}
|
|
319
|
+
if (qr) parts.push('--qr');
|
|
320
|
+
if (noQr) parts.push('--no-qr');
|
|
321
|
+
|
|
322
|
+
if (includeTokenPlaceholder) {
|
|
323
|
+
if (tokenViaStdin) {
|
|
324
|
+
parts.push('--token-stdin');
|
|
325
|
+
} else if (tokenFileProvided) {
|
|
326
|
+
parts.push('--token-file', '<redacted>');
|
|
327
|
+
} else {
|
|
328
|
+
parts.push('--token', '<redacted>');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return parts.join(' ');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function buildTunnelProfileAddCommand({ provider, hostname }) {
|
|
336
|
+
const parts = [
|
|
337
|
+
'vinci',
|
|
338
|
+
'tunnel',
|
|
339
|
+
'profile',
|
|
340
|
+
'add',
|
|
341
|
+
'--provider',
|
|
342
|
+
shellQuote(provider || 'cloudflare'),
|
|
343
|
+
'--mode',
|
|
344
|
+
'managed-remote',
|
|
345
|
+
'--name',
|
|
346
|
+
'<name>',
|
|
347
|
+
'--hostname',
|
|
348
|
+
shellQuote(hostname || '<hostname>'),
|
|
349
|
+
'--token',
|
|
350
|
+
'<token>',
|
|
351
|
+
];
|
|
352
|
+
return parts.join(' ');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function resolveTunnelTtlOverrides(options) {
|
|
356
|
+
let connectTtlRaw = typeof options.connectTtl === 'string' ? options.connectTtl : undefined;
|
|
357
|
+
let sessionTtlRaw = typeof options.sessionTtl === 'string' ? options.sessionTtl : undefined;
|
|
358
|
+
|
|
359
|
+
const shouldPrompt = !connectTtlRaw
|
|
360
|
+
&& !sessionTtlRaw
|
|
361
|
+
&& canPrompt(options);
|
|
362
|
+
|
|
363
|
+
if (shouldPrompt) {
|
|
364
|
+
const connectChoice = await clackSelect({
|
|
365
|
+
message: 'Select connect-link TTL',
|
|
366
|
+
options: CONNECT_TTL_PICKER_OPTIONS,
|
|
367
|
+
});
|
|
368
|
+
if (clackIsCancel(connectChoice)) {
|
|
369
|
+
clackCancel('Tunnel start cancelled.');
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
if (connectChoice === '__custom__') {
|
|
373
|
+
const enteredConnect = await clackText({
|
|
374
|
+
message: 'Enter connect-link TTL (e.g. 30m, 2h, 1d)',
|
|
375
|
+
placeholder: '30m',
|
|
376
|
+
validate(value) {
|
|
377
|
+
try {
|
|
378
|
+
parseTtlMsOrThrow(value, {
|
|
379
|
+
flagName: '--connect-ttl',
|
|
380
|
+
minMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS,
|
|
381
|
+
maxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS,
|
|
382
|
+
});
|
|
383
|
+
return undefined;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return error instanceof Error ? error.message : 'Invalid TTL value';
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
if (clackIsCancel(enteredConnect)) {
|
|
390
|
+
clackCancel('Tunnel start cancelled.');
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
connectTtlRaw = enteredConnect.trim();
|
|
394
|
+
} else {
|
|
395
|
+
connectTtlRaw = connectChoice;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const sessionChoice = await clackSelect({
|
|
399
|
+
message: 'Select session TTL',
|
|
400
|
+
options: SESSION_TTL_PICKER_OPTIONS,
|
|
401
|
+
});
|
|
402
|
+
if (clackIsCancel(sessionChoice)) {
|
|
403
|
+
clackCancel('Tunnel start cancelled.');
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
if (sessionChoice === '__custom__') {
|
|
407
|
+
const enteredSession = await clackText({
|
|
408
|
+
message: 'Enter session TTL (e.g. 8h, 24h, 1d)',
|
|
409
|
+
placeholder: '8h',
|
|
410
|
+
validate(value) {
|
|
411
|
+
try {
|
|
412
|
+
parseTtlMsOrThrow(value, {
|
|
413
|
+
flagName: '--session-ttl',
|
|
414
|
+
minMs: TUNNEL_SESSION_TTL_MIN_MS,
|
|
415
|
+
maxMs: TUNNEL_SESSION_TTL_MAX_MS,
|
|
416
|
+
});
|
|
417
|
+
return undefined;
|
|
418
|
+
} catch (error) {
|
|
419
|
+
return error instanceof Error ? error.message : 'Invalid TTL value';
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
if (clackIsCancel(enteredSession)) {
|
|
424
|
+
clackCancel('Tunnel start cancelled.');
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
sessionTtlRaw = enteredSession.trim();
|
|
428
|
+
} else {
|
|
429
|
+
sessionTtlRaw = sessionChoice;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const connectTtlMs = connectTtlRaw !== undefined
|
|
434
|
+
? parseTtlMsOrThrow(connectTtlRaw, {
|
|
435
|
+
flagName: '--connect-ttl',
|
|
436
|
+
minMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS,
|
|
437
|
+
maxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS,
|
|
438
|
+
})
|
|
439
|
+
: undefined;
|
|
440
|
+
|
|
441
|
+
const sessionTtlMs = sessionTtlRaw !== undefined
|
|
442
|
+
? parseTtlMsOrThrow(sessionTtlRaw, {
|
|
443
|
+
flagName: '--session-ttl',
|
|
444
|
+
minMs: TUNNEL_SESSION_TTL_MIN_MS,
|
|
445
|
+
maxMs: TUNNEL_SESSION_TTL_MAX_MS,
|
|
446
|
+
})
|
|
447
|
+
: undefined;
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
connectTtlMs,
|
|
451
|
+
sessionTtlMs,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
function levenshteinDistance(a, b) {
|
|
458
|
+
const m = a.length;
|
|
459
|
+
const n = b.length;
|
|
460
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
461
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
462
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
463
|
+
for (let i = 1; i <= m; i++) {
|
|
464
|
+
for (let j = 1; j <= n; j++) {
|
|
465
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
466
|
+
? dp[i - 1][j - 1]
|
|
467
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return dp[m][n];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function findClosestMatch(input, candidates, maxDistance = 3) {
|
|
474
|
+
if (typeof input !== 'string' || input.length === 0 || !Array.isArray(candidates)) {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
const normalized = input.toLowerCase();
|
|
478
|
+
let bestCandidate = null;
|
|
479
|
+
let bestDistance = maxDistance + 1;
|
|
480
|
+
for (const candidate of candidates) {
|
|
481
|
+
const distance = levenshteinDistance(normalized, candidate.toLowerCase());
|
|
482
|
+
if (distance < bestDistance) {
|
|
483
|
+
bestDistance = distance;
|
|
484
|
+
bestCandidate = candidate;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return bestDistance <= maxDistance ? bestCandidate : null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function importFromFilePath(filePath) {
|
|
491
|
+
return import(pathToFileURL(filePath).href);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function getBunBinary() {
|
|
495
|
+
if (typeof process.env.BUN_BINARY === 'string' && process.env.BUN_BINARY.trim().length > 0) {
|
|
496
|
+
return process.env.BUN_BINARY.trim();
|
|
497
|
+
}
|
|
498
|
+
if (typeof process.env.BUN_INSTALL === 'string' && process.env.BUN_INSTALL.trim().length > 0) {
|
|
499
|
+
return path.join(process.env.BUN_INSTALL.trim(), 'bin', 'bun');
|
|
500
|
+
}
|
|
501
|
+
return 'bun';
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function hasUiPasswordConfigured(password) {
|
|
505
|
+
return typeof password === 'string' && password.trim().length > 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const BUN_BIN = getBunBinary();
|
|
509
|
+
|
|
510
|
+
function isBunRuntime() {
|
|
511
|
+
return typeof globalThis.Bun !== 'undefined';
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function isBunInstalled() {
|
|
515
|
+
try {
|
|
516
|
+
const result = spawnSync(BUN_BIN, ['--version'], {
|
|
517
|
+
stdio: 'ignore',
|
|
518
|
+
env: process.env,
|
|
519
|
+
windowsHide: true,
|
|
520
|
+
});
|
|
521
|
+
return result.status === 0;
|
|
522
|
+
} catch {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function getPreferredServerRuntime() {
|
|
528
|
+
return isBunInstalled() ? 'bun' : 'node';
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function displayTunnelQrCode(url) {
|
|
532
|
+
try {
|
|
533
|
+
const qrcode = await import('qrcode-terminal');
|
|
534
|
+
console.log('\n📱 Scan this QR code to access the tunnel:\n');
|
|
535
|
+
qrcode.default.generate(url, { small: true });
|
|
536
|
+
console.log('');
|
|
537
|
+
} catch (error) {
|
|
538
|
+
console.warn(`Warning: Could not generate QR code: ${error.message}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function isTruthyEnv(value) {
|
|
543
|
+
if (typeof value !== 'string') return false;
|
|
544
|
+
const normalized = value.trim().toLowerCase();
|
|
545
|
+
if (!normalized) return false;
|
|
546
|
+
return normalized !== '0' && normalized !== 'false' && normalized !== 'no';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function shouldDisplayTunnelQr(options) {
|
|
550
|
+
if (options?.json) return false;
|
|
551
|
+
if (options?.quiet) return false;
|
|
552
|
+
if (options?.explicitQr === true) return options.qr === true;
|
|
553
|
+
if (!process.stdout?.isTTY) return false;
|
|
554
|
+
return !isTruthyEnv(process.env.CI);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function splitOptionToken(arg) {
|
|
558
|
+
if (!arg.startsWith('-')) return null;
|
|
559
|
+
if (arg.startsWith('--')) {
|
|
560
|
+
const eqIndex = arg.indexOf('=');
|
|
561
|
+
return {
|
|
562
|
+
name: eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2),
|
|
563
|
+
inlineValue: eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined,
|
|
564
|
+
long: true,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
name: arg.slice(1),
|
|
569
|
+
inlineValue: undefined,
|
|
570
|
+
long: false,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
575
|
+
const args = Array.isArray(argv) ? [...argv] : [];
|
|
576
|
+
const options = {
|
|
577
|
+
port: DEFAULT_PORT,
|
|
578
|
+
host: undefined,
|
|
579
|
+
uiPassword: process.env.VINCI_UI_PASSWORD || undefined,
|
|
580
|
+
json: false,
|
|
581
|
+
all: false,
|
|
582
|
+
follow: true,
|
|
583
|
+
lines: DEFAULT_TAIL_LINES,
|
|
584
|
+
provider: undefined,
|
|
585
|
+
mode: undefined,
|
|
586
|
+
profile: undefined,
|
|
587
|
+
name: undefined,
|
|
588
|
+
configPath: undefined,
|
|
589
|
+
token: undefined,
|
|
590
|
+
tokenFile: undefined,
|
|
591
|
+
tokenStdin: false,
|
|
592
|
+
hostname: undefined,
|
|
593
|
+
connectTtl: undefined,
|
|
594
|
+
sessionTtl: undefined,
|
|
595
|
+
qr: false,
|
|
596
|
+
explicitQr: false,
|
|
597
|
+
force: false,
|
|
598
|
+
showSecrets: false,
|
|
599
|
+
dryRun: false,
|
|
600
|
+
plain: false,
|
|
601
|
+
quiet: false,
|
|
602
|
+
explicitPort: false,
|
|
603
|
+
explicitUiPassword: false,
|
|
604
|
+
foreground: false,
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const removedFlagErrors = [];
|
|
608
|
+
const positional = [];
|
|
609
|
+
let helpRequested = false;
|
|
610
|
+
let versionRequested = false;
|
|
611
|
+
|
|
612
|
+
const consumeValue = (index, inlineValue) => {
|
|
613
|
+
if (typeof inlineValue === 'string' && inlineValue.length > 0) {
|
|
614
|
+
return { value: inlineValue, nextIndex: index };
|
|
615
|
+
}
|
|
616
|
+
const candidate = args[index + 1];
|
|
617
|
+
if (typeof candidate === 'string' && !candidate.startsWith('-')) {
|
|
618
|
+
return { value: candidate, nextIndex: index + 1 };
|
|
619
|
+
}
|
|
620
|
+
return { value: undefined, nextIndex: index };
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
for (let i = 0; i < args.length; i++) {
|
|
624
|
+
const arg = args[i];
|
|
625
|
+
const parsedToken = splitOptionToken(arg);
|
|
626
|
+
if (!parsedToken) {
|
|
627
|
+
positional.push(arg);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const { name, inlineValue, long } = parsedToken;
|
|
632
|
+
switch (name) {
|
|
633
|
+
case 'port':
|
|
634
|
+
case 'p': {
|
|
635
|
+
const { value: consumedValue, nextIndex: consumedIndex } = consumeValue(i, inlineValue);
|
|
636
|
+
let value = consumedValue;
|
|
637
|
+
let nextIndex = consumedIndex;
|
|
638
|
+
|
|
639
|
+
// Support explicit negative numeric values like `-p -1` so we can report
|
|
640
|
+
// a clear range validation error instead of "Unknown option".
|
|
641
|
+
if (value === undefined && typeof inlineValue !== 'string') {
|
|
642
|
+
const candidate = args[i + 1];
|
|
643
|
+
if (typeof candidate === 'string' && /^-\d+$/.test(candidate)) {
|
|
644
|
+
value = candidate;
|
|
645
|
+
nextIndex = i + 1;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
i = nextIndex;
|
|
650
|
+
|
|
651
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
652
|
+
throw new TunnelCliError('Missing value for --port.', EXIT_CODE.USAGE_ERROR);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (!/^-?\d+$/.test(value.trim())) {
|
|
656
|
+
throw new TunnelCliError(`Invalid port value: ${value}`, EXIT_CODE.USAGE_ERROR);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const parsed = parseInt(value, 10);
|
|
660
|
+
if (parsed < 1 || parsed > 65535) {
|
|
661
|
+
throw new TunnelCliError(`Invalid port value: ${parsed}`, EXIT_CODE.USAGE_ERROR);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
options.port = parsed;
|
|
665
|
+
options.explicitPort = true;
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
case 'host': {
|
|
669
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
670
|
+
i = nextIndex;
|
|
671
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
672
|
+
throw new TunnelCliError('Missing value for --host.', EXIT_CODE.USAGE_ERROR);
|
|
673
|
+
}
|
|
674
|
+
options.host = value.trim();
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
case 'ui-password': {
|
|
678
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
679
|
+
i = nextIndex;
|
|
680
|
+
options.uiPassword = typeof value === 'string' ? value : '';
|
|
681
|
+
options.explicitUiPassword = true;
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
case 'provider': {
|
|
685
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
686
|
+
i = nextIndex;
|
|
687
|
+
options.provider = typeof value === 'string' ? value : options.provider;
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
case 'mode': {
|
|
691
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
692
|
+
i = nextIndex;
|
|
693
|
+
options.mode = typeof value === 'string' ? value : options.mode;
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
case 'profile': {
|
|
697
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
698
|
+
i = nextIndex;
|
|
699
|
+
options.profile = typeof value === 'string' ? value : options.profile;
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
case 'name': {
|
|
703
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
704
|
+
i = nextIndex;
|
|
705
|
+
options.name = typeof value === 'string' ? value : options.name;
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
case 'config': {
|
|
709
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
710
|
+
i = nextIndex;
|
|
711
|
+
options.configPath = typeof value === 'string' ? value : null;
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
case 'token': {
|
|
715
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
716
|
+
i = nextIndex;
|
|
717
|
+
options.token = typeof value === 'string' ? value : options.token;
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
case 'token-file': {
|
|
721
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
722
|
+
i = nextIndex;
|
|
723
|
+
options.tokenFile = typeof value === 'string' ? value : options.tokenFile;
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
case 'token-stdin':
|
|
727
|
+
options.tokenStdin = true;
|
|
728
|
+
break;
|
|
729
|
+
case 'hostname': {
|
|
730
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
731
|
+
i = nextIndex;
|
|
732
|
+
options.hostname = typeof value === 'string' ? value : options.hostname;
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
case 'connect-ttl': {
|
|
736
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
737
|
+
i = nextIndex;
|
|
738
|
+
options.connectTtl = typeof value === 'string' ? value : options.connectTtl;
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
case 'session-ttl': {
|
|
742
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
743
|
+
i = nextIndex;
|
|
744
|
+
options.sessionTtl = typeof value === 'string' ? value : options.sessionTtl;
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
case 'json':
|
|
748
|
+
options.json = true;
|
|
749
|
+
break;
|
|
750
|
+
case 'all':
|
|
751
|
+
options.all = true;
|
|
752
|
+
break;
|
|
753
|
+
case 'no-follow':
|
|
754
|
+
options.follow = false;
|
|
755
|
+
break;
|
|
756
|
+
case 'lines': {
|
|
757
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
758
|
+
i = nextIndex;
|
|
759
|
+
const parsed = parseInt(value ?? '', 10);
|
|
760
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
761
|
+
options.lines = parsed;
|
|
762
|
+
}
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
case 'qr':
|
|
766
|
+
options.qr = true;
|
|
767
|
+
options.explicitQr = true;
|
|
768
|
+
break;
|
|
769
|
+
case 'no-qr':
|
|
770
|
+
options.qr = false;
|
|
771
|
+
options.explicitQr = true;
|
|
772
|
+
break;
|
|
773
|
+
case 'force':
|
|
774
|
+
options.force = true;
|
|
775
|
+
break;
|
|
776
|
+
case 'show-secrets':
|
|
777
|
+
options.showSecrets = true;
|
|
778
|
+
break;
|
|
779
|
+
case 'dry-run':
|
|
780
|
+
options.dryRun = true;
|
|
781
|
+
break;
|
|
782
|
+
case 'plain':
|
|
783
|
+
options.plain = true;
|
|
784
|
+
break;
|
|
785
|
+
case 'quiet':
|
|
786
|
+
case 'q':
|
|
787
|
+
options.quiet = true;
|
|
788
|
+
break;
|
|
789
|
+
case 'help':
|
|
790
|
+
case 'h':
|
|
791
|
+
helpRequested = true;
|
|
792
|
+
break;
|
|
793
|
+
case 'version':
|
|
794
|
+
case 'v':
|
|
795
|
+
versionRequested = true;
|
|
796
|
+
break;
|
|
797
|
+
case 'foreground':
|
|
798
|
+
case 'no-daemon':
|
|
799
|
+
options.foreground = true;
|
|
800
|
+
break;
|
|
801
|
+
case 'daemon':
|
|
802
|
+
case 'd':
|
|
803
|
+
// Legacy no-op: daemon mode is already the default, but older clients
|
|
804
|
+
// may still pass this when starting a remote server.
|
|
805
|
+
break;
|
|
806
|
+
case 'try-cf-tunnel':
|
|
807
|
+
removedFlagErrors.push('`--try-cf-tunnel` was removed. Use: vinci tunnel start --provider cloudflare --mode quick');
|
|
808
|
+
break;
|
|
809
|
+
case 'tunnel-qr':
|
|
810
|
+
removedFlagErrors.push('`--tunnel-qr` was removed. Use: vinci tunnel start ... --qr');
|
|
811
|
+
break;
|
|
812
|
+
case 'tunnel-password-url':
|
|
813
|
+
removedFlagErrors.push('`--tunnel-password-url` was removed. Use UI password auth directly after tunnel start.');
|
|
814
|
+
break;
|
|
815
|
+
case 'tunnel-provider':
|
|
816
|
+
case 'tunnel-mode':
|
|
817
|
+
case 'tunnel-config':
|
|
818
|
+
case 'tunnel-token':
|
|
819
|
+
case 'tunnel-hostname':
|
|
820
|
+
case 'tunnel':
|
|
821
|
+
removedFlagErrors.push(`\`--${name}\` was removed from top-level serve flow. Use: vinci tunnel start ...`);
|
|
822
|
+
break;
|
|
823
|
+
default:
|
|
824
|
+
if (!long && name.length === 1) {
|
|
825
|
+
removedFlagErrors.push(`Unknown option: -${name}`);
|
|
826
|
+
} else {
|
|
827
|
+
removedFlagErrors.push(`Unknown option: --${name}`);
|
|
828
|
+
}
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const command = positional[0] || 'serve';
|
|
834
|
+
const subcommand = command === 'tunnel' ? (positional[1] || 'help') : null;
|
|
835
|
+
const tunnelAction = command === 'tunnel' ? (positional[2] || null) : null;
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
command,
|
|
839
|
+
subcommand,
|
|
840
|
+
tunnelAction,
|
|
841
|
+
options,
|
|
842
|
+
removedFlagErrors,
|
|
843
|
+
helpRequested,
|
|
844
|
+
versionRequested,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function showHelp() {
|
|
849
|
+
console.log(`
|
|
850
|
+
Vinci - Web interface for the OpenCode AI coding agent
|
|
851
|
+
|
|
852
|
+
USAGE:
|
|
853
|
+
vinci [COMMAND] [OPTIONS]
|
|
854
|
+
|
|
855
|
+
COMMANDS:
|
|
856
|
+
serve Start the web server (daemon default)
|
|
857
|
+
stop Stop running instance(s)
|
|
858
|
+
restart Stop and start the server
|
|
859
|
+
status Show server status
|
|
860
|
+
tunnel Tunnel lifecycle commands
|
|
861
|
+
logs Tail Vinci logs
|
|
862
|
+
|
|
863
|
+
OPTIONS:
|
|
864
|
+
-p, --port Web server port (default: ${DEFAULT_PORT})
|
|
865
|
+
--host Bind address (default: 127.0.0.1)
|
|
866
|
+
--ui-password Protect browser UI with single password
|
|
867
|
+
--foreground Run server in foreground (use with systemd/process managers)
|
|
868
|
+
--no-daemon Alias for --foreground
|
|
869
|
+
-h, --help Show help
|
|
870
|
+
-v, --version Show version
|
|
871
|
+
|
|
872
|
+
ENVIRONMENT:
|
|
873
|
+
VINCI_HOST Bind address (e.g. 0.0.0.0 for all interfaces)
|
|
874
|
+
VINCI_UI_PASSWORD Alternative to --ui-password flag
|
|
875
|
+
VINCI_DATA_DIR Override Vinci data directory
|
|
876
|
+
OPENCODE_HOST External OpenCode server base URL, e.g. http://hostname:4096
|
|
877
|
+
OPENCODE_PORT Port of external OpenCode server to connect to
|
|
878
|
+
OPENCODE_SKIP_START Skip starting OpenCode, use external server
|
|
879
|
+
VINCI_OPENCODE_HOSTNAME Bind hostname for managed OpenCode server (default: 127.0.0.1)
|
|
880
|
+
|
|
881
|
+
EXAMPLES:
|
|
882
|
+
vinci # Start in daemon mode on default port 3000 (or free port)
|
|
883
|
+
vinci --port 8080 # Start on port 8080 (daemon)
|
|
884
|
+
vinci serve --foreground # Start in foreground (for systemd Type=simple)
|
|
885
|
+
vinci tunnel help # Show tunnel lifecycle help
|
|
886
|
+
vinci logs # Follow logs for latest running instance
|
|
887
|
+
`);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function showTunnelHelp() {
|
|
891
|
+
console.log(`
|
|
892
|
+
Tunnel Lifecycle Commands
|
|
893
|
+
|
|
894
|
+
USAGE:
|
|
895
|
+
vinci tunnel <SUBCOMMAND> [OPTIONS]
|
|
896
|
+
|
|
897
|
+
SUBCOMMANDS:
|
|
898
|
+
help Show this tunnel help
|
|
899
|
+
providers Show available tunnel providers and capabilities
|
|
900
|
+
ready Check tunnel readiness for a provider
|
|
901
|
+
doctor Run deep tunnel diagnostics
|
|
902
|
+
status Show tunnel status
|
|
903
|
+
start Start a tunnel
|
|
904
|
+
stop Stop active tunnel (keep server running)
|
|
905
|
+
profile Manage saved managed-remote profiles
|
|
906
|
+
|
|
907
|
+
COMMON OPTIONS:
|
|
908
|
+
-p, --port Target Vinci instance port
|
|
909
|
+
--json Output machine-readable JSON
|
|
910
|
+
--all Apply to all running instances (doctor default, stop)
|
|
911
|
+
|
|
912
|
+
START OPTIONS:
|
|
913
|
+
--provider <id> Tunnel provider id (default: cloudflare)
|
|
914
|
+
--mode <id> Tunnel mode (default: quick)
|
|
915
|
+
--profile <name> Start tunnel from saved profile name
|
|
916
|
+
--config [path] Managed-local config path (optional)
|
|
917
|
+
--token <token> Managed-remote token (visible in process list)
|
|
918
|
+
--token-file <path> Read token from file (recommended)
|
|
919
|
+
--token-stdin Read token from stdin
|
|
920
|
+
--hostname <hostname> Managed-remote hostname
|
|
921
|
+
--connect-ttl <value> Connect-link TTL (e.g. 30m, 24h, 1d)
|
|
922
|
+
--session-ttl <value> Session TTL (e.g. 8h, 24h, 1d)
|
|
923
|
+
--qr Print QR code for resulting tunnel URL
|
|
924
|
+
--no-qr Disable QR output
|
|
925
|
+
--dry-run Validate inputs without applying changes
|
|
926
|
+
|
|
927
|
+
OUTPUT OPTIONS:
|
|
928
|
+
--show-secrets Show full tokens in output (default: redacted)
|
|
929
|
+
--plain Disable colors and decorations
|
|
930
|
+
-q, --quiet Suppress non-essential output
|
|
931
|
+
--json Output machine-readable JSON
|
|
932
|
+
|
|
933
|
+
BEHAVIOR NOTES:
|
|
934
|
+
- One active tunnel per Vinci instance.
|
|
935
|
+
- Starting a different mode/provider replaces the current tunnel and revokes old connect links/sessions.
|
|
936
|
+
- Connect links are one-time; generating a new link revokes the previous unused link.
|
|
937
|
+
|
|
938
|
+
PROFILE USAGE:
|
|
939
|
+
vinci tunnel profile list [--provider <id>] [--json]
|
|
940
|
+
vinci tunnel profile show --name <name> [--provider <id>] [--json]
|
|
941
|
+
vinci tunnel profile add --provider <id> --mode managed-remote --name <name> --hostname <host> --token <token> [--force] [--json]
|
|
942
|
+
vinci tunnel profile add --provider <id> --mode managed-remote --name <name> --hostname <host> --token-file <path> [--force] [--json]
|
|
943
|
+
vinci tunnel profile remove --name <name> [--provider <id>] [--json]
|
|
944
|
+
|
|
945
|
+
SHELL COMPLETION:
|
|
946
|
+
vinci tunnel completion bash Generate Bash completion script
|
|
947
|
+
vinci tunnel completion zsh Generate Zsh completion script
|
|
948
|
+
vinci tunnel completion fish Generate Fish completion script
|
|
949
|
+
|
|
950
|
+
EXAMPLES:
|
|
951
|
+
vinci tunnel providers
|
|
952
|
+
vinci tunnel ready --provider cloudflare
|
|
953
|
+
vinci tunnel doctor --provider cloudflare
|
|
954
|
+
vinci tunnel status
|
|
955
|
+
vinci tunnel start --qr
|
|
956
|
+
vinci tunnel start --profile prod-main
|
|
957
|
+
vinci tunnel start --provider cloudflare --mode managed-remote --token-file ~/.secrets/cf-token --hostname app.example.com
|
|
958
|
+
vinci tunnel start --provider cloudflare --mode managed-local --config ~/.cloudflared/config.yml
|
|
959
|
+
vinci tunnel start --dry-run --provider cloudflare --mode managed-remote --token-file ~/.secrets/cf-token --hostname app.example.com
|
|
960
|
+
echo "$TOKEN" | vinci tunnel profile add --provider cloudflare --mode managed-remote --name prod-main --hostname app.example.com --token-stdin
|
|
961
|
+
vinci tunnel profile list --provider cloudflare
|
|
962
|
+
vinci tunnel profile list --json --show-secrets
|
|
963
|
+
vinci tunnel stop --port 3000
|
|
964
|
+
`);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function generateCompletionScript(shell) {
|
|
968
|
+
const normalized = typeof shell === 'string' ? shell.trim().toLowerCase() : '';
|
|
969
|
+
|
|
970
|
+
if (normalized === 'bash') {
|
|
971
|
+
return `# Bash completion for vinci tunnel
|
|
972
|
+
# Add to ~/.bashrc: eval "$(vinci tunnel completion bash)"
|
|
973
|
+
_vinci_tunnel() {
|
|
974
|
+
local cur prev commands tunnel_commands profile_commands common_flags start_flags
|
|
975
|
+
COMPREPLY=()
|
|
976
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
977
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
978
|
+
|
|
979
|
+
commands="serve stop restart status tunnel logs"
|
|
980
|
+
tunnel_commands="help providers ready doctor status start stop profile completion"
|
|
981
|
+
profile_commands="list show add remove"
|
|
982
|
+
common_flags="--port --foreground --no-daemon --json --all --help --version --plain --quiet"
|
|
983
|
+
start_flags="--provider --mode --profile --config --token --token-file --token-stdin --hostname --connect-ttl --session-ttl --qr --no-qr --dry-run --show-secrets"
|
|
984
|
+
|
|
985
|
+
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
|
986
|
+
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
|
|
987
|
+
return 0
|
|
988
|
+
fi
|
|
989
|
+
|
|
990
|
+
if [[ "\${COMP_WORDS[1]}" == "tunnel" ]]; then
|
|
991
|
+
if [[ \${COMP_CWORD} -eq 2 ]]; then
|
|
992
|
+
COMPREPLY=( $(compgen -W "\${tunnel_commands}" -- "\${cur}") )
|
|
993
|
+
return 0
|
|
994
|
+
fi
|
|
995
|
+
if [[ "\${COMP_WORDS[2]}" == "profile" && \${COMP_CWORD} -eq 3 ]]; then
|
|
996
|
+
COMPREPLY=( $(compgen -W "\${profile_commands}" -- "\${cur}") )
|
|
997
|
+
return 0
|
|
998
|
+
fi
|
|
999
|
+
if [[ "\${COMP_WORDS[2]}" == "completion" && \${COMP_CWORD} -eq 3 ]]; then
|
|
1000
|
+
COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${cur}") )
|
|
1001
|
+
return 0
|
|
1002
|
+
fi
|
|
1003
|
+
if [[ "\${COMP_WORDS[2]}" == "start" ]]; then
|
|
1004
|
+
COMPREPLY=( $(compgen -W "\${start_flags} \${common_flags}" -- "\${cur}") )
|
|
1005
|
+
return 0
|
|
1006
|
+
fi
|
|
1007
|
+
COMPREPLY=( $(compgen -W "\${common_flags}" -- "\${cur}") )
|
|
1008
|
+
return 0
|
|
1009
|
+
fi
|
|
1010
|
+
|
|
1011
|
+
COMPREPLY=( $(compgen -W "\${common_flags}" -- "\${cur}") )
|
|
1012
|
+
return 0
|
|
1013
|
+
}
|
|
1014
|
+
complete -F _vinci_tunnel vinci
|
|
1015
|
+
`;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (normalized === 'zsh') {
|
|
1019
|
+
return `#compdef vinci
|
|
1020
|
+
# Zsh completion for vinci tunnel
|
|
1021
|
+
# Add to ~/.zshrc: eval "$(vinci tunnel completion zsh)"
|
|
1022
|
+
|
|
1023
|
+
_vinci() {
|
|
1024
|
+
local -a commands tunnel_commands profile_commands
|
|
1025
|
+
|
|
1026
|
+
commands=(
|
|
1027
|
+
'serve:Start the web server'
|
|
1028
|
+
'stop:Stop running instance(s)'
|
|
1029
|
+
'restart:Stop and start the server'
|
|
1030
|
+
'status:Show server status'
|
|
1031
|
+
'tunnel:Tunnel lifecycle commands'
|
|
1032
|
+
'logs:Tail Vinci logs'
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
tunnel_commands=(
|
|
1036
|
+
'help:Show tunnel help'
|
|
1037
|
+
'providers:Show available providers'
|
|
1038
|
+
'ready:Check tunnel readiness'
|
|
1039
|
+
'doctor:Run tunnel diagnostics'
|
|
1040
|
+
'status:Show tunnel status'
|
|
1041
|
+
'start:Start a tunnel'
|
|
1042
|
+
'stop:Stop active tunnel'
|
|
1043
|
+
'profile:Manage saved profiles'
|
|
1044
|
+
'completion:Generate shell completion'
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
profile_commands=(
|
|
1048
|
+
'list:List profiles'
|
|
1049
|
+
'show:Show profile details'
|
|
1050
|
+
'add:Add a profile'
|
|
1051
|
+
'remove:Remove a profile'
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
_arguments -C \\
|
|
1055
|
+
'1:command:->command' \\
|
|
1056
|
+
'*::arg:->args'
|
|
1057
|
+
|
|
1058
|
+
case \$state in
|
|
1059
|
+
command)
|
|
1060
|
+
_describe 'command' commands
|
|
1061
|
+
;;
|
|
1062
|
+
args)
|
|
1063
|
+
case \$words[1] in
|
|
1064
|
+
tunnel)
|
|
1065
|
+
if (( CURRENT == 2 )); then
|
|
1066
|
+
_describe 'tunnel command' tunnel_commands
|
|
1067
|
+
elif [[ \$words[2] == "profile" ]] && (( CURRENT == 3 )); then
|
|
1068
|
+
_describe 'profile action' profile_commands
|
|
1069
|
+
elif [[ \$words[2] == "completion" ]] && (( CURRENT == 3 )); then
|
|
1070
|
+
_values 'shell' bash zsh fish
|
|
1071
|
+
fi
|
|
1072
|
+
;;
|
|
1073
|
+
esac
|
|
1074
|
+
;;
|
|
1075
|
+
esac
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
compdef _vinci vinci
|
|
1079
|
+
`;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (normalized === 'fish') {
|
|
1083
|
+
return `# Fish completion for vinci tunnel
|
|
1084
|
+
# Save to ~/.config/fish/completions/vinci.fish
|
|
1085
|
+
|
|
1086
|
+
complete -c vinci -n '__fish_use_subcommand' -a 'serve' -d 'Start the web server'
|
|
1087
|
+
complete -c vinci -n '__fish_seen_subcommand_from serve' -l foreground -d 'Run in foreground (for systemd/process managers)'
|
|
1088
|
+
complete -c vinci -n '__fish_seen_subcommand_from serve' -l no-daemon -d 'Run in foreground (alias for --foreground)'
|
|
1089
|
+
complete -c vinci -n '__fish_use_subcommand' -a 'stop' -d 'Stop running instance(s)'
|
|
1090
|
+
complete -c vinci -n '__fish_use_subcommand' -a 'restart' -d 'Stop and start the server'
|
|
1091
|
+
complete -c vinci -n '__fish_use_subcommand' -a 'status' -d 'Show server status'
|
|
1092
|
+
complete -c vinci -n '__fish_use_subcommand' -a 'tunnel' -d 'Tunnel lifecycle commands'
|
|
1093
|
+
complete -c vinci -n '__fish_use_subcommand' -a 'logs' -d 'Tail logs'
|
|
1094
|
+
|
|
1095
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'help' -d 'Show tunnel help'
|
|
1096
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'providers' -d 'Show providers'
|
|
1097
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'ready' -d 'Check readiness'
|
|
1098
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'doctor' -d 'Run diagnostics'
|
|
1099
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'status' -d 'Show tunnel status'
|
|
1100
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'start' -d 'Start a tunnel'
|
|
1101
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'stop' -d 'Stop tunnel'
|
|
1102
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'profile' -d 'Manage profiles'
|
|
1103
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'completion' -d 'Generate completions'
|
|
1104
|
+
|
|
1105
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l provider -d 'Provider id'
|
|
1106
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l mode -d 'Tunnel mode'
|
|
1107
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l profile -d 'Profile name'
|
|
1108
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l config -d 'Config path'
|
|
1109
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l token -d 'Token'
|
|
1110
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l token-file -d 'Token file path'
|
|
1111
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l token-stdin -d 'Read token from stdin'
|
|
1112
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l hostname -d 'Hostname'
|
|
1113
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l dry-run -d 'Validate without applying'
|
|
1114
|
+
complete -c vinci -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l qr -d 'Show QR code'
|
|
1115
|
+
`;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function getDataDir() {
|
|
1122
|
+
if (typeof process.env.VINCI_DATA_DIR === 'string' && process.env.VINCI_DATA_DIR.trim().length > 0) {
|
|
1123
|
+
return path.resolve(process.env.VINCI_DATA_DIR.trim());
|
|
1124
|
+
}
|
|
1125
|
+
return path.join(os.homedir(), '.config', 'vinci');
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function getLogsDir() {
|
|
1129
|
+
return path.join(getDataDir(), 'logs');
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function getSettingsFilePath() {
|
|
1133
|
+
return path.join(getDataDir(), 'settings.json');
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function readDesktopLocalPortFromSettings() {
|
|
1137
|
+
try {
|
|
1138
|
+
const raw = fs.readFileSync(getSettingsFilePath(), 'utf8');
|
|
1139
|
+
const parsed = JSON.parse(raw);
|
|
1140
|
+
const value = parsed?.desktopLocalPort;
|
|
1141
|
+
if (Number.isFinite(value) && value > 0 && value <= 65535) {
|
|
1142
|
+
return value;
|
|
1143
|
+
}
|
|
1144
|
+
return null;
|
|
1145
|
+
} catch {
|
|
1146
|
+
return null;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function ensureLogsDir() {
|
|
1151
|
+
fs.mkdirSync(getLogsDir(), { recursive: true });
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function getLogFilePath(port) {
|
|
1155
|
+
return path.join(getLogsDir(), `vinci-${port}.log`);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function getTunnelProfilesFilePath() {
|
|
1159
|
+
return path.join(getDataDir(), TUNNEL_PROFILES_FILE_NAME);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function getLegacyCloudflareManagedRemoteFilePath() {
|
|
1163
|
+
return path.join(getDataDir(), LEGACY_CLOUDFLARE_MANAGED_REMOTE_FILE_NAME);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function getTunnelCliStateFilePath() {
|
|
1167
|
+
return path.join(getDataDir(), TUNNEL_CLI_STATE_FILE_NAME);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function readTunnelCliState() {
|
|
1171
|
+
const filePath = getTunnelCliStateFilePath();
|
|
1172
|
+
try {
|
|
1173
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
1174
|
+
const parsed = JSON.parse(raw);
|
|
1175
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1176
|
+
return {};
|
|
1177
|
+
}
|
|
1178
|
+
return parsed;
|
|
1179
|
+
} catch {
|
|
1180
|
+
return {};
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function readLastManagedLocalConfigPath() {
|
|
1185
|
+
const state = readTunnelCliState();
|
|
1186
|
+
if (typeof state.lastManagedLocalConfigPath !== 'string') {
|
|
1187
|
+
return '';
|
|
1188
|
+
}
|
|
1189
|
+
return state.lastManagedLocalConfigPath.trim();
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function writeLastManagedLocalConfigPath(configPath) {
|
|
1193
|
+
if (typeof configPath !== 'string' || configPath.trim().length === 0) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const filePath = getTunnelCliStateFilePath();
|
|
1197
|
+
const current = readTunnelCliState();
|
|
1198
|
+
const next = {
|
|
1199
|
+
...current,
|
|
1200
|
+
lastManagedLocalConfigPath: configPath.trim(),
|
|
1201
|
+
updatedAt: Date.now(),
|
|
1202
|
+
};
|
|
1203
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1204
|
+
fs.writeFileSync(filePath, JSON.stringify(next, null, 2), 'utf8');
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function normalizeProfileProvider(value) {
|
|
1208
|
+
if (typeof value !== 'string') return '';
|
|
1209
|
+
return value.trim().toLowerCase();
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function normalizeProfileMode(value) {
|
|
1213
|
+
if (typeof value !== 'string') return '';
|
|
1214
|
+
return value.trim().toLowerCase();
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function normalizeProfileName(value) {
|
|
1218
|
+
if (typeof value !== 'string') return '';
|
|
1219
|
+
return value.trim();
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function normalizeProfileHostname(value) {
|
|
1223
|
+
if (typeof value !== 'string') return '';
|
|
1224
|
+
return value.trim();
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function normalizeProfileToken(value) {
|
|
1228
|
+
if (typeof value !== 'string') return '';
|
|
1229
|
+
return value.trim();
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function suggestProfileNameFromHostname(hostname) {
|
|
1233
|
+
const normalizedHost = normalizeProfileHostname(hostname);
|
|
1234
|
+
if (!normalizedHost) return 'prod-main';
|
|
1235
|
+
const firstLabel = normalizedHost.split('.')[0] || normalizedHost;
|
|
1236
|
+
const sanitized = firstLabel.replace(/[^a-zA-Z0-9-_]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
1237
|
+
return sanitized || 'prod-main';
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function maskToken(token) {
|
|
1241
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
1242
|
+
return '***';
|
|
1243
|
+
}
|
|
1244
|
+
if (token.length <= 4) {
|
|
1245
|
+
return '*'.repeat(token.length);
|
|
1246
|
+
}
|
|
1247
|
+
return `${'*'.repeat(Math.max(4, token.length - 4))}${token.slice(-4)}`;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const MAX_TOKEN_FILE_BYTES = 8 * 1024;
|
|
1251
|
+
|
|
1252
|
+
function readTokenFromFileSafely(tokenFilePath) {
|
|
1253
|
+
const absolutePath = path.resolve(tokenFilePath);
|
|
1254
|
+
let realPath;
|
|
1255
|
+
try {
|
|
1256
|
+
realPath = fs.realpathSync(absolutePath);
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
if (error?.code === 'ENOENT') {
|
|
1259
|
+
throw new Error(`Token file '${absolutePath}' not found.`);
|
|
1260
|
+
}
|
|
1261
|
+
if (error?.code === 'EACCES') {
|
|
1262
|
+
throw new Error(`Token file '${absolutePath}' is not readable. Check file permissions.`);
|
|
1263
|
+
}
|
|
1264
|
+
throw error;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
let stats;
|
|
1268
|
+
try {
|
|
1269
|
+
stats = fs.statSync(realPath);
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
if (error?.code === 'EACCES') {
|
|
1272
|
+
throw new Error(`Token file '${absolutePath}' is not readable. Check file permissions.`);
|
|
1273
|
+
}
|
|
1274
|
+
throw error;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if (!stats.isFile()) {
|
|
1278
|
+
throw new Error(`Token file '${absolutePath}' must be a regular file.`);
|
|
1279
|
+
}
|
|
1280
|
+
if (stats.size <= 0) {
|
|
1281
|
+
throw new Error(`Token file '${absolutePath}' is empty.`);
|
|
1282
|
+
}
|
|
1283
|
+
if (stats.size > MAX_TOKEN_FILE_BYTES) {
|
|
1284
|
+
throw new Error(`Token file '${absolutePath}' is too large (max ${MAX_TOKEN_FILE_BYTES} bytes).`);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const raw = fs.readFileSync(realPath, 'utf8');
|
|
1288
|
+
if (raw.includes('\u0000')) {
|
|
1289
|
+
throw new Error(`Token file '${absolutePath}' appears to be binary. Use a plain text token file.`);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const value = raw.trim();
|
|
1293
|
+
if (!value) {
|
|
1294
|
+
throw new Error(`Token file '${absolutePath}' is empty.`);
|
|
1295
|
+
}
|
|
1296
|
+
return value;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function resolveToken(options) {
|
|
1300
|
+
const sources = [
|
|
1301
|
+
options.tokenStdin ? 'stdin' : null,
|
|
1302
|
+
options.tokenFile ? 'file' : null,
|
|
1303
|
+
options.token ? 'flag' : null,
|
|
1304
|
+
].filter(Boolean);
|
|
1305
|
+
|
|
1306
|
+
if (sources.length > 1) {
|
|
1307
|
+
throw new Error(`Multiple token sources specified (${sources.join(', ')}). Use only one of --token, --token-file, or --token-stdin.`);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (options.tokenStdin) {
|
|
1311
|
+
const fd = fs.openSync('/dev/stdin', 'r');
|
|
1312
|
+
try {
|
|
1313
|
+
const buf = Buffer.alloc(65536);
|
|
1314
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, null);
|
|
1315
|
+
const value = buf.slice(0, bytesRead).toString('utf8').trim();
|
|
1316
|
+
if (!value) {
|
|
1317
|
+
throw new Error('No token received from stdin.');
|
|
1318
|
+
}
|
|
1319
|
+
return value;
|
|
1320
|
+
} finally {
|
|
1321
|
+
fs.closeSync(fd);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (options.tokenFile) {
|
|
1326
|
+
return readTokenFromFileSafely(options.tokenFile);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
return typeof options.token === 'string' ? options.token.trim() : undefined;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function redactProfileForOutput(profile, showSecrets = false) {
|
|
1333
|
+
if (!profile || typeof profile !== 'object') {
|
|
1334
|
+
return profile;
|
|
1335
|
+
}
|
|
1336
|
+
return {
|
|
1337
|
+
...profile,
|
|
1338
|
+
token: showSecrets ? profile.token : maskToken(profile.token),
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function redactProfilesForOutput(profiles, showSecrets = false) {
|
|
1343
|
+
if (!Array.isArray(profiles)) {
|
|
1344
|
+
return profiles;
|
|
1345
|
+
}
|
|
1346
|
+
return profiles.map((entry) => redactProfileForOutput(entry, showSecrets));
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function formatProfileTokenStatus(profile, showSecrets = false) {
|
|
1350
|
+
const token = typeof profile?.token === 'string' ? profile.token.trim() : '';
|
|
1351
|
+
if (!token) {
|
|
1352
|
+
return 'token:missing';
|
|
1353
|
+
}
|
|
1354
|
+
if (showSecrets) {
|
|
1355
|
+
return `token:${token}`;
|
|
1356
|
+
}
|
|
1357
|
+
return 'token:present';
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function sanitizeTunnelProfilesData(data) {
|
|
1361
|
+
const parsed = data && typeof data === 'object' ? data : {};
|
|
1362
|
+
const list = Array.isArray(parsed.profiles) ? parsed.profiles : [];
|
|
1363
|
+
const seen = new Set();
|
|
1364
|
+
const profiles = [];
|
|
1365
|
+
for (const entry of list) {
|
|
1366
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
1367
|
+
const id = typeof entry.id === 'string' && entry.id.trim().length > 0 ? entry.id.trim() : crypto.randomUUID();
|
|
1368
|
+
const provider = normalizeProfileProvider(entry.provider);
|
|
1369
|
+
const mode = normalizeProfileMode(entry.mode);
|
|
1370
|
+
const name = normalizeProfileName(entry.name);
|
|
1371
|
+
const hostname = normalizeProfileHostname(entry.hostname);
|
|
1372
|
+
const token = normalizeProfileToken(entry.token);
|
|
1373
|
+
if (!provider || !mode || !name || !hostname || !token) continue;
|
|
1374
|
+
const key = `${provider}::${name.toLowerCase()}`;
|
|
1375
|
+
if (seen.has(key)) continue;
|
|
1376
|
+
seen.add(key);
|
|
1377
|
+
profiles.push({
|
|
1378
|
+
id,
|
|
1379
|
+
name,
|
|
1380
|
+
provider,
|
|
1381
|
+
mode,
|
|
1382
|
+
hostname,
|
|
1383
|
+
token,
|
|
1384
|
+
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(),
|
|
1385
|
+
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
return { version: TUNNEL_PROFILES_VERSION, profiles };
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function warnIfUnsafeFilePermissions(filePath) {
|
|
1392
|
+
if (process.platform === 'win32') {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
if (isJsonMode(activeCommandOptions) || isQuietMode(activeCommandOptions)) {
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
try {
|
|
1399
|
+
const stats = fs.statSync(filePath);
|
|
1400
|
+
const perms = stats.mode & 0o777;
|
|
1401
|
+
if (perms & 0o077) {
|
|
1402
|
+
const octal = perms.toString(8).padStart(3, '0');
|
|
1403
|
+
console.warn(
|
|
1404
|
+
`Warning: Profile file '${filePath}' has permissions ${octal} (should be 600). ` +
|
|
1405
|
+
`Other users may be able to read tunnel tokens. Fix with: chmod 600 '${filePath}'`
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
} catch {
|
|
1409
|
+
// File may not exist yet — not an error
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function readTunnelProfilesFromDisk() {
|
|
1414
|
+
const filePath = getTunnelProfilesFilePath();
|
|
1415
|
+
try {
|
|
1416
|
+
warnIfUnsafeFilePermissions(filePath);
|
|
1417
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
1418
|
+
return sanitizeTunnelProfilesData(JSON.parse(raw));
|
|
1419
|
+
} catch {
|
|
1420
|
+
return { version: TUNNEL_PROFILES_VERSION, profiles: [] };
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function writeTunnelProfilesToDisk(data) {
|
|
1425
|
+
const filePath = getTunnelProfilesFilePath();
|
|
1426
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1427
|
+
fs.writeFileSync(filePath, JSON.stringify(sanitizeTunnelProfilesData(data), null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function writeManagedRemotePairsToDiskFromProfiles(profilesData) {
|
|
1431
|
+
const profiles = sanitizeTunnelProfilesData(profilesData).profiles;
|
|
1432
|
+
const cloudflareManagedRemote = profiles.filter(
|
|
1433
|
+
(entry) => entry.provider === 'cloudflare' && entry.mode === 'managed-remote'
|
|
1434
|
+
);
|
|
1435
|
+
|
|
1436
|
+
const tunnels = cloudflareManagedRemote.map((entry) => ({
|
|
1437
|
+
id: entry.id,
|
|
1438
|
+
name: entry.name,
|
|
1439
|
+
hostname: entry.hostname,
|
|
1440
|
+
token: entry.token,
|
|
1441
|
+
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
|
|
1442
|
+
}));
|
|
1443
|
+
|
|
1444
|
+
const filePath = getLegacyCloudflareManagedRemoteFilePath();
|
|
1445
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1446
|
+
fs.writeFileSync(filePath, JSON.stringify({ version: 1, tunnels }, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function readLegacyManagedRemoteEntries() {
|
|
1450
|
+
try {
|
|
1451
|
+
const raw = fs.readFileSync(getLegacyCloudflareManagedRemoteFilePath(), 'utf8');
|
|
1452
|
+
const parsed = JSON.parse(raw);
|
|
1453
|
+
const tunnels = Array.isArray(parsed?.tunnels) ? parsed.tunnels : [];
|
|
1454
|
+
return tunnels
|
|
1455
|
+
.map((entry) => {
|
|
1456
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
1457
|
+
const id = typeof entry.id === 'string' && entry.id.trim().length > 0 ? entry.id.trim() : crypto.randomUUID();
|
|
1458
|
+
const name = normalizeProfileName(entry.name);
|
|
1459
|
+
const hostname = normalizeProfileHostname(entry.hostname);
|
|
1460
|
+
const token = normalizeProfileToken(entry.token);
|
|
1461
|
+
if (!name || !hostname || !token) return null;
|
|
1462
|
+
return {
|
|
1463
|
+
id,
|
|
1464
|
+
name,
|
|
1465
|
+
provider: 'cloudflare',
|
|
1466
|
+
mode: 'managed-remote',
|
|
1467
|
+
hostname,
|
|
1468
|
+
token,
|
|
1469
|
+
createdAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
|
|
1470
|
+
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
|
|
1471
|
+
};
|
|
1472
|
+
})
|
|
1473
|
+
.filter(Boolean);
|
|
1474
|
+
} catch {
|
|
1475
|
+
return [];
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function makeUniqueProfileName(provider, desiredName, existingProfiles) {
|
|
1480
|
+
const normalizedDesired = normalizeProfileName(desiredName);
|
|
1481
|
+
if (!normalizedDesired) {
|
|
1482
|
+
return '';
|
|
1483
|
+
}
|
|
1484
|
+
const existingNames = new Set(
|
|
1485
|
+
existingProfiles
|
|
1486
|
+
.filter((entry) => entry.provider === provider)
|
|
1487
|
+
.map((entry) => entry.name.toLowerCase())
|
|
1488
|
+
);
|
|
1489
|
+
|
|
1490
|
+
if (!existingNames.has(normalizedDesired.toLowerCase())) {
|
|
1491
|
+
return normalizedDesired;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
let index = 2;
|
|
1495
|
+
while (true) {
|
|
1496
|
+
const candidate = `${normalizedDesired}-${index}`;
|
|
1497
|
+
if (!existingNames.has(candidate.toLowerCase())) {
|
|
1498
|
+
return candidate;
|
|
1499
|
+
}
|
|
1500
|
+
index += 1;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function ensureTunnelProfilesMigrated() {
|
|
1505
|
+
const current = readTunnelProfilesFromDisk();
|
|
1506
|
+
if (current.profiles.length > 0) {
|
|
1507
|
+
return current;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
const legacyEntries = readLegacyManagedRemoteEntries();
|
|
1511
|
+
if (legacyEntries.length === 0) {
|
|
1512
|
+
return current;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const migratedProfiles = [];
|
|
1516
|
+
for (const entry of legacyEntries) {
|
|
1517
|
+
const name = makeUniqueProfileName(entry.provider, entry.name, migratedProfiles);
|
|
1518
|
+
migratedProfiles.push({ ...entry, name });
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const migrated = sanitizeTunnelProfilesData({ version: TUNNEL_PROFILES_VERSION, profiles: migratedProfiles });
|
|
1522
|
+
writeTunnelProfilesToDisk(migrated);
|
|
1523
|
+
writeManagedRemotePairsToDiskFromProfiles(migrated);
|
|
1524
|
+
return migrated;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function resolveProfileByName(profiles, profileName, provider) {
|
|
1528
|
+
const normalizedName = normalizeProfileName(profileName).toLowerCase();
|
|
1529
|
+
const normalizedProvider = normalizeProfileProvider(provider);
|
|
1530
|
+
const matches = profiles.filter((entry) => {
|
|
1531
|
+
if (entry.name.toLowerCase() !== normalizedName) return false;
|
|
1532
|
+
if (!normalizedProvider) return true;
|
|
1533
|
+
return entry.provider === normalizedProvider;
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
if (matches.length === 0) {
|
|
1537
|
+
return { profile: null, error: `No tunnel profile found for name '${profileName}'. Run 'vinci tunnel profile list'.` };
|
|
1538
|
+
}
|
|
1539
|
+
if (matches.length > 1) {
|
|
1540
|
+
return { profile: null, error: `Profile name '${profileName}' exists for multiple providers. Use --provider <id>.` };
|
|
1541
|
+
}
|
|
1542
|
+
return { profile: matches[0], error: null };
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function rotateLogFile(logPath) {
|
|
1546
|
+
try {
|
|
1547
|
+
const stats = fs.statSync(logPath);
|
|
1548
|
+
if (stats.size < LOG_ROTATE_MAX_BYTES) {
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
} catch {
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
for (let i = LOG_ROTATE_KEEP - 1; i >= 1; i--) {
|
|
1556
|
+
const src = `${logPath}.${i}`;
|
|
1557
|
+
const dst = `${logPath}.${i + 1}`;
|
|
1558
|
+
if (fs.existsSync(src)) {
|
|
1559
|
+
try {
|
|
1560
|
+
fs.renameSync(src, dst);
|
|
1561
|
+
} catch {
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
try {
|
|
1567
|
+
if (fs.existsSync(logPath)) {
|
|
1568
|
+
fs.renameSync(logPath, `${logPath}.1`);
|
|
1569
|
+
}
|
|
1570
|
+
} catch {
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
const WINDOWS_EXTENSIONS = process.platform === 'win32'
|
|
1575
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
|
1576
|
+
.split(';')
|
|
1577
|
+
.map((ext) => ext.trim().toLowerCase())
|
|
1578
|
+
.filter(Boolean)
|
|
1579
|
+
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
|
|
1580
|
+
: [''];
|
|
1581
|
+
|
|
1582
|
+
function isExecutable(filePath) {
|
|
1583
|
+
try {
|
|
1584
|
+
const stats = fs.statSync(filePath);
|
|
1585
|
+
if (!stats.isFile()) {
|
|
1586
|
+
return false;
|
|
1587
|
+
}
|
|
1588
|
+
if (process.platform === 'win32') {
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
1592
|
+
return true;
|
|
1593
|
+
} catch {
|
|
1594
|
+
return false;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function resolveExplicitBinary(candidate) {
|
|
1599
|
+
if (!candidate) {
|
|
1600
|
+
return null;
|
|
1601
|
+
}
|
|
1602
|
+
if (candidate.includes(path.sep) || path.isAbsolute(candidate)) {
|
|
1603
|
+
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(candidate);
|
|
1604
|
+
return isExecutable(resolved) ? resolved : null;
|
|
1605
|
+
}
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function searchPathFor(command) {
|
|
1610
|
+
const pathValue = process.env.PATH || '';
|
|
1611
|
+
const segments = pathValue.split(path.delimiter).filter(Boolean);
|
|
1612
|
+
for (const dir of segments) {
|
|
1613
|
+
for (const ext of WINDOWS_EXTENSIONS) {
|
|
1614
|
+
const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
|
|
1615
|
+
const candidate = path.join(dir, fileName);
|
|
1616
|
+
if (isExecutable(candidate)) {
|
|
1617
|
+
return candidate;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
return null;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
async function checkOpenCodeCLI(onNotice) {
|
|
1625
|
+
if (process.env.OPENCODE_BINARY) {
|
|
1626
|
+
const override = resolveExplicitBinary(process.env.OPENCODE_BINARY);
|
|
1627
|
+
if (override) {
|
|
1628
|
+
process.env.OPENCODE_BINARY = override;
|
|
1629
|
+
return override;
|
|
1630
|
+
}
|
|
1631
|
+
const message = `OPENCODE_BINARY="${process.env.OPENCODE_BINARY}" is not an executable file. Falling back to PATH lookup.`;
|
|
1632
|
+
if (typeof onNotice === 'function') {
|
|
1633
|
+
onNotice({ level: 'warning', code: 'OPENCODE_BINARY_INVALID', message });
|
|
1634
|
+
} else {
|
|
1635
|
+
console.warn(`Warning: ${message}`);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const resolvedFromPath = searchPathFor('opencode');
|
|
1640
|
+
if (resolvedFromPath) {
|
|
1641
|
+
process.env.OPENCODE_BINARY = resolvedFromPath;
|
|
1642
|
+
return resolvedFromPath;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
throw new Error(
|
|
1646
|
+
`Unable to locate the opencode CLI on PATH (${process.env.PATH || '<empty>'}). ` +
|
|
1647
|
+
'Ensure the CLI is installed and reachable, or set OPENCODE_BINARY to its full path.'
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
async function isPortAvailable(port, host) {
|
|
1652
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
1653
|
+
return false;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
return await new Promise((resolve) => {
|
|
1657
|
+
const server = net.createServer();
|
|
1658
|
+
server.unref();
|
|
1659
|
+
server.on('error', () => resolve(false));
|
|
1660
|
+
server.listen({ port, host }, () => {
|
|
1661
|
+
server.close(() => resolve(true));
|
|
1662
|
+
});
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
async function resolveAvailablePort(desiredPort, explicitPort = false, onNotice) {
|
|
1667
|
+
const startPort = Number.isFinite(desiredPort) ? Math.trunc(desiredPort) : DEFAULT_PORT;
|
|
1668
|
+
if (explicitPort) {
|
|
1669
|
+
return startPort;
|
|
1670
|
+
}
|
|
1671
|
+
if (await isPortAvailable(startPort)) {
|
|
1672
|
+
return startPort;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const occupant = await fetchSystemInfoFromPort(startPort);
|
|
1676
|
+
let message;
|
|
1677
|
+
if (occupant?.runtime === 'desktop') {
|
|
1678
|
+
message = `Port ${startPort} is used by Vinci Desktop; using a free port`;
|
|
1679
|
+
} else if (occupant?.runtime) {
|
|
1680
|
+
message = `Port ${startPort} is used by an existing Vinci instance; using a free port`;
|
|
1681
|
+
} else {
|
|
1682
|
+
message = `Port ${startPort} in use; using a free port`;
|
|
1683
|
+
}
|
|
1684
|
+
if (typeof onNotice === 'function' && message) {
|
|
1685
|
+
onNotice({
|
|
1686
|
+
level: 'warning',
|
|
1687
|
+
code: 'PORT_REASSIGNED',
|
|
1688
|
+
message,
|
|
1689
|
+
});
|
|
1690
|
+
} else if (message) {
|
|
1691
|
+
console.warn(message);
|
|
1692
|
+
}
|
|
1693
|
+
return 0;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function getRunDir() {
|
|
1697
|
+
const dir = path.join(getDataDir(), 'run');
|
|
1698
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1699
|
+
return dir;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
async function getPidFilePath(port) {
|
|
1703
|
+
return path.join(getRunDir(), `vinci-${port}.pid`);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
async function getInstanceFilePath(port) {
|
|
1707
|
+
return path.join(getRunDir(), `vinci-${port}.json`);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function readPidFile(pidFilePath) {
|
|
1711
|
+
try {
|
|
1712
|
+
const content = fs.readFileSync(pidFilePath, 'utf8').trim();
|
|
1713
|
+
const pid = parseInt(content, 10);
|
|
1714
|
+
return Number.isFinite(pid) ? pid : null;
|
|
1715
|
+
} catch {
|
|
1716
|
+
return null;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function writePidFile(pidFilePath, pid, onNotice) {
|
|
1721
|
+
try {
|
|
1722
|
+
fs.writeFileSync(pidFilePath, String(pid), { mode: 0o600 });
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
const message = `Could not write PID file: ${error.message}`;
|
|
1725
|
+
if (typeof onNotice === 'function') {
|
|
1726
|
+
onNotice({ level: 'warning', code: 'PID_FILE_WRITE_FAILED', message });
|
|
1727
|
+
} else {
|
|
1728
|
+
console.warn(`Warning: ${message}`);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function removePidFile(pidFilePath) {
|
|
1734
|
+
try {
|
|
1735
|
+
if (fs.existsSync(pidFilePath)) {
|
|
1736
|
+
fs.unlinkSync(pidFilePath);
|
|
1737
|
+
}
|
|
1738
|
+
} catch {
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
function readInstanceOptions(instanceFilePath) {
|
|
1743
|
+
try {
|
|
1744
|
+
return JSON.parse(fs.readFileSync(instanceFilePath, 'utf8'));
|
|
1745
|
+
} catch {
|
|
1746
|
+
return null;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
function writeInstanceOptions(instanceFilePath, options, onNotice) {
|
|
1751
|
+
try {
|
|
1752
|
+
const toStore = {
|
|
1753
|
+
port: options.port,
|
|
1754
|
+
host: typeof options.host === 'string' && options.host.length > 0 ? options.host : undefined,
|
|
1755
|
+
launchMode: options.launchMode === 'foreground' ? 'foreground' : 'daemon',
|
|
1756
|
+
uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : undefined,
|
|
1757
|
+
hasUiPassword: typeof options.uiPassword === 'string',
|
|
1758
|
+
startedAt: Number.isFinite(options.startedAt) ? options.startedAt : Date.now(),
|
|
1759
|
+
};
|
|
1760
|
+
fs.writeFileSync(instanceFilePath, JSON.stringify(toStore, null, 2), { mode: 0o600 });
|
|
1761
|
+
} catch (error) {
|
|
1762
|
+
const message = `Could not write instance file: ${error.message}`;
|
|
1763
|
+
if (typeof onNotice === 'function') {
|
|
1764
|
+
onNotice({ level: 'warning', code: 'INSTANCE_FILE_WRITE_FAILED', message });
|
|
1765
|
+
} else {
|
|
1766
|
+
console.warn(`Warning: ${message}`);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function removeInstanceFile(instanceFilePath) {
|
|
1772
|
+
try {
|
|
1773
|
+
if (fs.existsSync(instanceFilePath)) {
|
|
1774
|
+
fs.unlinkSync(instanceFilePath);
|
|
1775
|
+
}
|
|
1776
|
+
} catch {
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function isProcessRunning(pid) {
|
|
1781
|
+
try {
|
|
1782
|
+
process.kill(pid, 0);
|
|
1783
|
+
return true;
|
|
1784
|
+
} catch {
|
|
1785
|
+
return false;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function waitForProcessExit(pid, timeoutMs) {
|
|
1790
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
1791
|
+
return Promise.resolve(true);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
const deadline = Date.now() + timeoutMs;
|
|
1795
|
+
return new Promise((resolve) => {
|
|
1796
|
+
const check = () => {
|
|
1797
|
+
if (!isProcessRunning(pid)) {
|
|
1798
|
+
resolve(true);
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
if (Date.now() >= deadline) {
|
|
1802
|
+
resolve(false);
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
setTimeout(check, 150);
|
|
1806
|
+
};
|
|
1807
|
+
check();
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
async function terminateProcessTree(pid, options = {}) {
|
|
1812
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
1813
|
+
return true;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const gracefulTimeoutMs = Number.isFinite(options.gracefulTimeoutMs) && options.gracefulTimeoutMs >= 0
|
|
1817
|
+
? Math.trunc(options.gracefulTimeoutMs)
|
|
1818
|
+
: 2500;
|
|
1819
|
+
const forceTimeoutMs = Number.isFinite(options.forceTimeoutMs) && options.forceTimeoutMs >= 0
|
|
1820
|
+
? Math.trunc(options.forceTimeoutMs)
|
|
1821
|
+
: 3000;
|
|
1822
|
+
|
|
1823
|
+
if (process.platform === 'win32') {
|
|
1824
|
+
try {
|
|
1825
|
+
process.kill(pid);
|
|
1826
|
+
} catch {
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
if (await waitForProcessExit(pid, 800)) {
|
|
1830
|
+
return true;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
try {
|
|
1834
|
+
spawnSync('taskkill', ['/pid', String(pid), '/t'], {
|
|
1835
|
+
stdio: 'ignore',
|
|
1836
|
+
timeout: 3000,
|
|
1837
|
+
windowsHide: true,
|
|
1838
|
+
});
|
|
1839
|
+
} catch {
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
if (await waitForProcessExit(pid, gracefulTimeoutMs)) {
|
|
1843
|
+
return true;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
try {
|
|
1847
|
+
spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], {
|
|
1848
|
+
stdio: 'ignore',
|
|
1849
|
+
timeout: 5000,
|
|
1850
|
+
windowsHide: true,
|
|
1851
|
+
});
|
|
1852
|
+
} catch {
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
return waitForProcessExit(pid, forceTimeoutMs);
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
try {
|
|
1859
|
+
process.kill(pid, 'SIGTERM');
|
|
1860
|
+
} catch {
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
if (await waitForProcessExit(pid, gracefulTimeoutMs)) {
|
|
1864
|
+
return true;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
try {
|
|
1868
|
+
process.kill(pid, 'SIGKILL');
|
|
1869
|
+
} catch {
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
return waitForProcessExit(pid, forceTimeoutMs);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
async function stopInstanceProcess(pid, options = {}) {
|
|
1876
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
1877
|
+
return true;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
const shutdownWaitMs = Number.isFinite(options.shutdownWaitMs) && options.shutdownWaitMs >= 0
|
|
1881
|
+
? Math.trunc(options.shutdownWaitMs)
|
|
1882
|
+
: 5000;
|
|
1883
|
+
|
|
1884
|
+
if (await waitForProcessExit(pid, shutdownWaitMs)) {
|
|
1885
|
+
return true;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
return terminateProcessTree(pid, options);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
async function requestServerShutdown(port) {
|
|
1892
|
+
if (!Number.isFinite(port) || port <= 0) return false;
|
|
1893
|
+
const controller = new AbortController();
|
|
1894
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
1895
|
+
try {
|
|
1896
|
+
const resp = await fetch(buildLocalUrl(port, '/api/system/shutdown'), {
|
|
1897
|
+
method: 'POST',
|
|
1898
|
+
signal: controller.signal,
|
|
1899
|
+
});
|
|
1900
|
+
return resp.ok;
|
|
1901
|
+
} catch {
|
|
1902
|
+
return false;
|
|
1903
|
+
} finally {
|
|
1904
|
+
clearTimeout(timeout);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
async function requestJson(port, endpoint, options = {}) {
|
|
1909
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
1910
|
+
? Math.trunc(options.timeoutMs)
|
|
1911
|
+
: 4000;
|
|
1912
|
+
const fetchOptions = { ...options };
|
|
1913
|
+
delete fetchOptions.timeoutMs;
|
|
1914
|
+
|
|
1915
|
+
const controller = new AbortController();
|
|
1916
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1917
|
+
try {
|
|
1918
|
+
const response = await fetch(buildLocalUrl(port, endpoint), {
|
|
1919
|
+
...fetchOptions,
|
|
1920
|
+
headers: {
|
|
1921
|
+
Accept: 'application/json',
|
|
1922
|
+
...(fetchOptions.body ? { 'Content-Type': 'application/json' } : {}),
|
|
1923
|
+
...(fetchOptions.headers || {}),
|
|
1924
|
+
},
|
|
1925
|
+
signal: controller.signal,
|
|
1926
|
+
});
|
|
1927
|
+
const body = await response.json().catch(() => null);
|
|
1928
|
+
return { response, body };
|
|
1929
|
+
} catch (error) {
|
|
1930
|
+
if (error && (error.name === 'AbortError' || error.code === 'ABORT_ERR')) {
|
|
1931
|
+
throw new Error(`Request to ${endpoint} timed out after ${timeoutMs}ms.`);
|
|
1932
|
+
}
|
|
1933
|
+
throw error;
|
|
1934
|
+
} finally {
|
|
1935
|
+
clearTimeout(timeout);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
async function isServerHealthReady(port, timeoutMs = 1000) {
|
|
1940
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
1941
|
+
return false;
|
|
1942
|
+
}
|
|
1943
|
+
const requestTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.trunc(timeoutMs) : 1000;
|
|
1944
|
+
const controller = new AbortController();
|
|
1945
|
+
const timeout = setTimeout(() => controller.abort(), requestTimeout);
|
|
1946
|
+
try {
|
|
1947
|
+
const response = await fetch(buildLocalUrl(port, '/health'), {
|
|
1948
|
+
headers: { Accept: 'text/plain' },
|
|
1949
|
+
signal: controller.signal,
|
|
1950
|
+
});
|
|
1951
|
+
return response.ok;
|
|
1952
|
+
} catch {
|
|
1953
|
+
return false;
|
|
1954
|
+
} finally {
|
|
1955
|
+
clearTimeout(timeout);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
async function waitForServerHealth(port, {
|
|
1960
|
+
timeoutMs = 60000,
|
|
1961
|
+
intervalMs = 250,
|
|
1962
|
+
onTick,
|
|
1963
|
+
} = {}) {
|
|
1964
|
+
const start = Date.now();
|
|
1965
|
+
const deadline = start + timeoutMs;
|
|
1966
|
+
while (Date.now() < deadline) {
|
|
1967
|
+
const elapsedMs = Date.now() - start;
|
|
1968
|
+
if (typeof onTick === 'function') {
|
|
1969
|
+
onTick({ elapsedMs, timeoutMs });
|
|
1970
|
+
}
|
|
1971
|
+
if (await isServerHealthReady(port, Math.min(1000, intervalMs * 2))) {
|
|
1972
|
+
if (typeof onTick === 'function') {
|
|
1973
|
+
onTick({ elapsedMs: Math.min(Date.now() - start, timeoutMs), timeoutMs, complete: true });
|
|
1974
|
+
}
|
|
1975
|
+
return true;
|
|
1976
|
+
}
|
|
1977
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1978
|
+
}
|
|
1979
|
+
if (typeof onTick === 'function') {
|
|
1980
|
+
onTick({ elapsedMs: timeoutMs, timeoutMs, timedOut: true });
|
|
1981
|
+
}
|
|
1982
|
+
return false;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function isValidTunnelDoctorResponse(body) {
|
|
1986
|
+
if (!body || typeof body !== 'object') {
|
|
1987
|
+
return false;
|
|
1988
|
+
}
|
|
1989
|
+
if (body.ok !== true) {
|
|
1990
|
+
return false;
|
|
1991
|
+
}
|
|
1992
|
+
if (!Array.isArray(body.providerChecks)) {
|
|
1993
|
+
return false;
|
|
1994
|
+
}
|
|
1995
|
+
if (!Array.isArray(body.modes)) {
|
|
1996
|
+
return false;
|
|
1997
|
+
}
|
|
1998
|
+
return body.modes.every((entry) => {
|
|
1999
|
+
if (!entry || typeof entry.mode !== 'string') return false;
|
|
2000
|
+
// Accept new shape: { ready: boolean, blockers: [] }
|
|
2001
|
+
if (typeof entry.ready === 'boolean' && Array.isArray(entry.blockers)) return true;
|
|
2002
|
+
// Accept server shape: { checks: [], summary: { ready: boolean } }
|
|
2003
|
+
if (Array.isArray(entry.checks) && entry.summary && typeof entry.summary.ready === 'boolean') return true;
|
|
2004
|
+
return false;
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
async function resolveDoctorPortStatuses(options = {}) {
|
|
2009
|
+
const runningEntries = await discoverRunningInstances();
|
|
2010
|
+
const desktopEntry = await discoverDesktopInstance();
|
|
2011
|
+
const statuses = [];
|
|
2012
|
+
|
|
2013
|
+
if (options.explicitPort) {
|
|
2014
|
+
const requestedPort = options.port;
|
|
2015
|
+
const runningMatch = runningEntries.find((entry) => entry.port === requestedPort);
|
|
2016
|
+
if (runningMatch) {
|
|
2017
|
+
statuses.push({
|
|
2018
|
+
port: requestedPort,
|
|
2019
|
+
available: true,
|
|
2020
|
+
status: 'success',
|
|
2021
|
+
line: `port ${requestedPort} available for tunneling`,
|
|
2022
|
+
detail: 'Double-check this same port is configured in your provider dashboard/config.',
|
|
2023
|
+
});
|
|
2024
|
+
return { statuses, availableEntries: [runningMatch] };
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
if (desktopEntry && desktopEntry.port === requestedPort) {
|
|
2028
|
+
statuses.push({
|
|
2029
|
+
port: requestedPort,
|
|
2030
|
+
available: false,
|
|
2031
|
+
status: 'warning',
|
|
2032
|
+
line: `port ${requestedPort} not available (desktop runtime)`,
|
|
2033
|
+
detail: 'Use a CLI instance port from `vinci serve` for tunneling.',
|
|
2034
|
+
});
|
|
2035
|
+
return { statuses, availableEntries: [] };
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
statuses.push({
|
|
2039
|
+
port: requestedPort,
|
|
2040
|
+
available: false,
|
|
2041
|
+
status: 'error',
|
|
2042
|
+
line: `port ${requestedPort} not available (no running instance)`,
|
|
2043
|
+
detail: `Start one with \`vinci serve --port ${requestedPort}\`.`,
|
|
2044
|
+
});
|
|
2045
|
+
return { statuses, availableEntries: [] };
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
for (const entry of runningEntries) {
|
|
2049
|
+
statuses.push({
|
|
2050
|
+
port: entry.port,
|
|
2051
|
+
available: true,
|
|
2052
|
+
status: 'success',
|
|
2053
|
+
line: `port ${entry.port} available for tunneling`,
|
|
2054
|
+
detail: 'Double-check this same port is configured in your provider dashboard/config.',
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
if (desktopEntry && !runningEntries.some((entry) => entry.port === desktopEntry.port)) {
|
|
2059
|
+
statuses.push({
|
|
2060
|
+
port: desktopEntry.port,
|
|
2061
|
+
available: false,
|
|
2062
|
+
status: 'warning',
|
|
2063
|
+
line: `port ${desktopEntry.port} not available (desktop runtime)`,
|
|
2064
|
+
detail: 'Use a CLI instance port from `vinci serve` for tunneling.',
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
if (runningEntries.length === 0) {
|
|
2069
|
+
statuses.push({
|
|
2070
|
+
port: null,
|
|
2071
|
+
available: false,
|
|
2072
|
+
status: 'warning',
|
|
2073
|
+
line: 'no CLI ports available for tunneling',
|
|
2074
|
+
detail: 'Start one with `vinci serve`.',
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
return { statuses, availableEntries: runningEntries };
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
async function discoverRunningInstances() {
|
|
2082
|
+
const instances = [];
|
|
2083
|
+
const runDir = getRunDir();
|
|
2084
|
+
try {
|
|
2085
|
+
const files = fs.readdirSync(runDir);
|
|
2086
|
+
const pidFiles = files.filter((file) => file.startsWith('vinci-') && file.endsWith('.pid'));
|
|
2087
|
+
for (const file of pidFiles) {
|
|
2088
|
+
const port = parseInt(file.replace('vinci-', '').replace('.pid', ''), 10);
|
|
2089
|
+
if (!Number.isFinite(port) || port <= 0) continue;
|
|
2090
|
+
const pidFilePath = path.join(runDir, file);
|
|
2091
|
+
const pid = readPidFile(pidFilePath);
|
|
2092
|
+
if (!pid || !isProcessRunning(pid)) {
|
|
2093
|
+
removePidFile(pidFilePath);
|
|
2094
|
+
removeInstanceFile(path.join(runDir, `vinci-${port}.json`));
|
|
2095
|
+
continue;
|
|
2096
|
+
}
|
|
2097
|
+
const instanceFilePath = path.join(runDir, `vinci-${port}.json`);
|
|
2098
|
+
let mtime = 0;
|
|
2099
|
+
let startedAt = 0;
|
|
2100
|
+
try {
|
|
2101
|
+
mtime = fs.statSync(pidFilePath).mtimeMs;
|
|
2102
|
+
} catch {
|
|
2103
|
+
}
|
|
2104
|
+
const storedOptions = readInstanceOptions(instanceFilePath);
|
|
2105
|
+
if (Number.isFinite(storedOptions?.startedAt)) {
|
|
2106
|
+
startedAt = storedOptions.startedAt;
|
|
2107
|
+
}
|
|
2108
|
+
const launchMode = storedOptions?.launchMode === 'foreground' ? 'foreground' : 'daemon';
|
|
2109
|
+
instances.push({ port, pid, pidFilePath, instanceFilePath, mtime, startedAt, launchMode });
|
|
2110
|
+
}
|
|
2111
|
+
} catch {
|
|
2112
|
+
}
|
|
2113
|
+
instances.sort((a, b) => a.port - b.port);
|
|
2114
|
+
return instances;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function getLatestInstance(instances) {
|
|
2118
|
+
if (!instances.length) return null;
|
|
2119
|
+
return [...instances].sort((a, b) => {
|
|
2120
|
+
const startedDelta = (b.startedAt || 0) - (a.startedAt || 0);
|
|
2121
|
+
if (startedDelta !== 0) return startedDelta;
|
|
2122
|
+
const mtimeDelta = (b.mtime || 0) - (a.mtime || 0);
|
|
2123
|
+
if (mtimeDelta !== 0) return mtimeDelta;
|
|
2124
|
+
return b.port - a.port;
|
|
2125
|
+
})[0];
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
async function fetchTunnelProvidersFromPort(port, fetchImpl = globalThis.fetch) {
|
|
2129
|
+
if (!Number.isFinite(port) || port <= 0 || typeof fetchImpl !== 'function') {
|
|
2130
|
+
return null;
|
|
2131
|
+
}
|
|
2132
|
+
try {
|
|
2133
|
+
const response = await fetchImpl(buildLocalUrl(port, '/api/vinci/tunnel/providers'));
|
|
2134
|
+
if (!response.ok) return null;
|
|
2135
|
+
const body = await response.json().catch(() => null);
|
|
2136
|
+
if (!body || !Array.isArray(body.providers)) return null;
|
|
2137
|
+
return body.providers;
|
|
2138
|
+
} catch {
|
|
2139
|
+
return null;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
async function fetchSystemInfoFromPort(port, fetchImpl = globalThis.fetch) {
|
|
2144
|
+
if (!Number.isFinite(port) || port <= 0 || typeof fetchImpl !== 'function') {
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
const controller = new AbortController();
|
|
2149
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
2150
|
+
try {
|
|
2151
|
+
const response = await fetchImpl(buildLocalUrl(port, '/api/system/info'), {
|
|
2152
|
+
headers: { Accept: 'application/json' },
|
|
2153
|
+
signal: controller.signal,
|
|
2154
|
+
});
|
|
2155
|
+
if (!response.ok) return null;
|
|
2156
|
+
const body = await response.json().catch(() => null);
|
|
2157
|
+
if (!body || typeof body.runtime !== 'string') return null;
|
|
2158
|
+
|
|
2159
|
+
return {
|
|
2160
|
+
runtime: body.runtime,
|
|
2161
|
+
pid: Number.isFinite(body.pid) ? body.pid : null,
|
|
2162
|
+
};
|
|
2163
|
+
} catch {
|
|
2164
|
+
return null;
|
|
2165
|
+
} finally {
|
|
2166
|
+
clearTimeout(timeout);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
async function inspectTunnelAttachability(port, { requireHealthy = true } = {}) {
|
|
2171
|
+
const info = await fetchSystemInfoFromPort(port);
|
|
2172
|
+
if (!info || typeof info.runtime !== 'string') {
|
|
2173
|
+
return { attachable: false, reason: 'unreachable' };
|
|
2174
|
+
}
|
|
2175
|
+
if (info.runtime === 'desktop') {
|
|
2176
|
+
return { attachable: false, reason: 'desktop', info };
|
|
2177
|
+
}
|
|
2178
|
+
if (requireHealthy) {
|
|
2179
|
+
const healthy = await isServerHealthReady(port, 1200);
|
|
2180
|
+
if (!healthy) {
|
|
2181
|
+
return { attachable: false, reason: 'unhealthy', info };
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
return { attachable: true, reason: 'ok', info };
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
async function discoverDesktopInstance(fetchImpl = globalThis.fetch) {
|
|
2188
|
+
const port = readDesktopLocalPortFromSettings();
|
|
2189
|
+
if (!port) {
|
|
2190
|
+
return null;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
const info = await fetchSystemInfoFromPort(port, fetchImpl);
|
|
2194
|
+
if (!info || info.runtime !== 'desktop') {
|
|
2195
|
+
return null;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
return {
|
|
2199
|
+
port,
|
|
2200
|
+
pid: info.pid,
|
|
2201
|
+
runtime: info.runtime,
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
async function resolveTunnelProviders(options = {}, deps = {}) {
|
|
2206
|
+
const readPorts = typeof deps.readPorts === 'function'
|
|
2207
|
+
? deps.readPorts
|
|
2208
|
+
: async () => (await discoverRunningInstances()).map((entry) => entry.port);
|
|
2209
|
+
const fetchImpl = typeof deps.fetchImpl === 'function' ? deps.fetchImpl : globalThis.fetch;
|
|
2210
|
+
|
|
2211
|
+
const candidatePorts = [];
|
|
2212
|
+
if (Number.isFinite(options.port) && options.port > 0) {
|
|
2213
|
+
candidatePorts.push(options.port);
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
const discoveredPorts = await Promise.resolve(readPorts());
|
|
2217
|
+
if (Array.isArray(discoveredPorts)) {
|
|
2218
|
+
candidatePorts.push(...discoveredPorts);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
if (!candidatePorts.includes(DEFAULT_PORT)) {
|
|
2222
|
+
candidatePorts.push(DEFAULT_PORT);
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
for (const port of candidatePorts) {
|
|
2226
|
+
const providers = await fetchTunnelProvidersFromPort(port, fetchImpl);
|
|
2227
|
+
if (providers) {
|
|
2228
|
+
return { providers, source: `api:${port}` };
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
return { providers: DEFAULT_TUNNEL_PROVIDER_CAPABILITIES, source: 'fallback' };
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
async function resolveTargetInstance({
|
|
2236
|
+
options,
|
|
2237
|
+
allowAutoStart,
|
|
2238
|
+
requireAll = false,
|
|
2239
|
+
rejectDesktopRuntime = false,
|
|
2240
|
+
}) {
|
|
2241
|
+
let running = await discoverRunningInstances();
|
|
2242
|
+
|
|
2243
|
+
if (options.all && requireAll) {
|
|
2244
|
+
if (running.length === 0) {
|
|
2245
|
+
throw new Error('No running Vinci instance found. Start one with `vinci serve`.');
|
|
2246
|
+
}
|
|
2247
|
+
return running;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
if (options.explicitPort) {
|
|
2251
|
+
const found = running.find((entry) => entry.port === options.port);
|
|
2252
|
+
if (found) {
|
|
2253
|
+
if (rejectDesktopRuntime) {
|
|
2254
|
+
const attachability = await inspectTunnelAttachability(found.port, { requireHealthy: true });
|
|
2255
|
+
if (!attachability.attachable) {
|
|
2256
|
+
if (attachability.reason === 'desktop') {
|
|
2257
|
+
throw new Error(
|
|
2258
|
+
`Port ${options.port} is used by Vinci Desktop app. Tunnel attach requires a CLI instance from \`vinci serve\`.`
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
throw new Error(
|
|
2262
|
+
`Port ${options.port} is not an attachable Vinci tunnel instance. Ensure it is healthy and running Vinci CLI runtime.`
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
return found;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
if (rejectDesktopRuntime) {
|
|
2270
|
+
const systemInfo = await fetchSystemInfoFromPort(options.port);
|
|
2271
|
+
if (systemInfo?.runtime === 'desktop') {
|
|
2272
|
+
throw new Error(
|
|
2273
|
+
`Port ${options.port} is used by Vinci Desktop app. Tunnel attach requires a CLI instance from \`vinci serve\`.`
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
if (allowAutoStart) {
|
|
2279
|
+
await commands.serve({
|
|
2280
|
+
port: options.port,
|
|
2281
|
+
explicitPort: true,
|
|
2282
|
+
uiPassword: options.uiPassword,
|
|
2283
|
+
suppressUnsafePortWarning: true,
|
|
2284
|
+
suppressUiPasswordWarning: true,
|
|
2285
|
+
suppressStartupSummary: true,
|
|
2286
|
+
});
|
|
2287
|
+
running = await discoverRunningInstances();
|
|
2288
|
+
const started = running.find((entry) => entry.port === options.port);
|
|
2289
|
+
if (started) return { ...started, autoStarted: true };
|
|
2290
|
+
}
|
|
2291
|
+
throw new Error(`No running Vinci instance found on port ${options.port}.`);
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
if (rejectDesktopRuntime) {
|
|
2295
|
+
const attachableEntries = [];
|
|
2296
|
+
let sawDesktop = false;
|
|
2297
|
+
for (const entry of running) {
|
|
2298
|
+
const attachability = await inspectTunnelAttachability(entry.port, { requireHealthy: true });
|
|
2299
|
+
if (attachability.reason === 'desktop') {
|
|
2300
|
+
sawDesktop = true;
|
|
2301
|
+
}
|
|
2302
|
+
if (attachability.attachable) {
|
|
2303
|
+
attachableEntries.push(entry);
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
if (attachableEntries.length === 1) {
|
|
2308
|
+
return attachableEntries[0];
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
if (attachableEntries.length > 1) {
|
|
2312
|
+
const ports = attachableEntries.map((entry) => entry.port).join(', ');
|
|
2313
|
+
throw new Error(`Multiple attachable Vinci instances found: ${ports}. Use --port <port> or --all.`);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
if (allowAutoStart) {
|
|
2317
|
+
const startedPort = await commands.serve({
|
|
2318
|
+
...options,
|
|
2319
|
+
explicitPort: false,
|
|
2320
|
+
suppressUnsafePortWarning: true,
|
|
2321
|
+
suppressUiPasswordWarning: true,
|
|
2322
|
+
suppressStartupSummary: true,
|
|
2323
|
+
});
|
|
2324
|
+
running = await discoverRunningInstances();
|
|
2325
|
+
const started = running.find((entry) => entry.port === startedPort) || getLatestInstance(running);
|
|
2326
|
+
if (started) return { ...started, autoStarted: true };
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
if (sawDesktop) {
|
|
2330
|
+
throw new Error('Only Vinci Desktop instance(s) detected. Tunnel attach requires a CLI instance from `vinci serve`.');
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
throw new Error('No attachable Vinci instance found. Start one with `vinci serve`.');
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
if (running.length === 1) {
|
|
2337
|
+
return running[0];
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
if (running.length === 0) {
|
|
2341
|
+
if (allowAutoStart) {
|
|
2342
|
+
const startedPort = await commands.serve({
|
|
2343
|
+
...options,
|
|
2344
|
+
explicitPort: false,
|
|
2345
|
+
suppressUnsafePortWarning: true,
|
|
2346
|
+
suppressUiPasswordWarning: true,
|
|
2347
|
+
});
|
|
2348
|
+
running = await discoverRunningInstances();
|
|
2349
|
+
const started = running.find((entry) => entry.port === startedPort) || getLatestInstance(running);
|
|
2350
|
+
if (started) return { ...started, autoStarted: true };
|
|
2351
|
+
}
|
|
2352
|
+
throw new Error('No running Vinci instance found. Start one with `vinci serve`.');
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
const ports = running.map((entry) => entry.port).join(', ');
|
|
2356
|
+
throw new Error(`Multiple Vinci instances found: ${ports}. Use --port <port> or --all.`);
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
async function resolveTunnelReadEntries(options) {
|
|
2360
|
+
const running = await discoverRunningInstances();
|
|
2361
|
+
|
|
2362
|
+
if (options.explicitPort) {
|
|
2363
|
+
const found = running.find((entry) => entry.port === options.port);
|
|
2364
|
+
if (!found) {
|
|
2365
|
+
throw new Error(`No running Vinci instance found on port ${options.port}.`);
|
|
2366
|
+
}
|
|
2367
|
+
return [found];
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
if (running.length === 0) {
|
|
2371
|
+
throw new Error('No running Vinci instance found. Start one with `vinci serve`.');
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
return running;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function formatTunnelStatusLine(statusBody, port) {
|
|
2378
|
+
const active = Boolean(statusBody?.active);
|
|
2379
|
+
const provider = statusBody?.provider || 'unknown';
|
|
2380
|
+
const mode = statusBody?.mode || 'unknown';
|
|
2381
|
+
const url = statusBody?.url || 'n/a';
|
|
2382
|
+
return {
|
|
2383
|
+
status: active ? 'success' : 'neutral',
|
|
2384
|
+
line: `port ${port} ${active ? 'active' : 'inactive'} (${clackFormatProviderWithIcon(provider)}/${mode})`,
|
|
2385
|
+
detail: url,
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
function formatModeRequirements(mode) {
|
|
2390
|
+
const requires = Array.isArray(mode?.requires) ? mode.requires.filter(Boolean) : [];
|
|
2391
|
+
if ((mode?.key || '') === 'managed-local') {
|
|
2392
|
+
return 'config-path (or default cloudflared config)';
|
|
2393
|
+
}
|
|
2394
|
+
if (requires.length === 0) {
|
|
2395
|
+
return 'none';
|
|
2396
|
+
}
|
|
2397
|
+
return requires.join(', ');
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
function annotateTunnelProvidersForOutput(providers) {
|
|
2401
|
+
if (!Array.isArray(providers)) return providers;
|
|
2402
|
+
return providers.map((provider) => {
|
|
2403
|
+
const modes = Array.isArray(provider?.modes) ? provider.modes : [];
|
|
2404
|
+
return {
|
|
2405
|
+
...provider,
|
|
2406
|
+
modes: modes.map((mode) => ({
|
|
2407
|
+
...mode,
|
|
2408
|
+
displayRequires: formatModeRequirements(mode),
|
|
2409
|
+
})),
|
|
2410
|
+
};
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function readTailLines(filePath, lineCount = DEFAULT_TAIL_LINES) {
|
|
2415
|
+
if (!fs.existsSync(filePath)) {
|
|
2416
|
+
return [];
|
|
2417
|
+
}
|
|
2418
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
2419
|
+
const lines = raw.split(/\r?\n/);
|
|
2420
|
+
if (lines.length && lines[lines.length - 1] === '') {
|
|
2421
|
+
lines.pop();
|
|
2422
|
+
}
|
|
2423
|
+
return lines.slice(Math.max(0, lines.length - lineCount));
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function followFile(filePath, onLine) {
|
|
2427
|
+
let position = 0;
|
|
2428
|
+
try {
|
|
2429
|
+
position = fs.statSync(filePath).size;
|
|
2430
|
+
} catch {
|
|
2431
|
+
position = 0;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
let remainder = '';
|
|
2435
|
+
const interval = setInterval(() => {
|
|
2436
|
+
try {
|
|
2437
|
+
const stats = fs.statSync(filePath);
|
|
2438
|
+
if (stats.size < position) {
|
|
2439
|
+
position = 0;
|
|
2440
|
+
}
|
|
2441
|
+
if (stats.size === position) {
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
const fd = fs.openSync(filePath, 'r');
|
|
2446
|
+
try {
|
|
2447
|
+
const length = stats.size - position;
|
|
2448
|
+
const buffer = Buffer.alloc(length);
|
|
2449
|
+
fs.readSync(fd, buffer, 0, length, position);
|
|
2450
|
+
position = stats.size;
|
|
2451
|
+
const chunk = remainder + buffer.toString('utf8');
|
|
2452
|
+
const parts = chunk.split(/\r?\n/);
|
|
2453
|
+
remainder = parts.pop() || '';
|
|
2454
|
+
for (const line of parts) {
|
|
2455
|
+
onLine(line);
|
|
2456
|
+
}
|
|
2457
|
+
} finally {
|
|
2458
|
+
fs.closeSync(fd);
|
|
2459
|
+
}
|
|
2460
|
+
} catch {
|
|
2461
|
+
}
|
|
2462
|
+
}, 400);
|
|
2463
|
+
|
|
2464
|
+
return () => {
|
|
2465
|
+
clearInterval(interval);
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
async function handleTunnelProfileSubcommand(options, action) {
|
|
2470
|
+
const sub = typeof action === 'string' ? action.trim().toLowerCase() : '';
|
|
2471
|
+
const store = ensureTunnelProfilesMigrated();
|
|
2472
|
+
|
|
2473
|
+
if (!sub) {
|
|
2474
|
+
if (isJsonMode(options)) {
|
|
2475
|
+
printJson({
|
|
2476
|
+
command: 'tunnel profile',
|
|
2477
|
+
subcommands: ['list', 'show', 'add', 'remove'],
|
|
2478
|
+
});
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
if (!isQuietMode(options)) {
|
|
2483
|
+
clackIntro('Tunnel Profile');
|
|
2484
|
+
logStatus('info', 'Available subcommands', 'list, show, add, remove');
|
|
2485
|
+
clackLog.step('List profiles: `vinci tunnel profile list`');
|
|
2486
|
+
clackLog.step('Show one profile: `vinci tunnel profile show --name <name>`');
|
|
2487
|
+
clackLog.step('Add profile: `vinci tunnel profile add --provider cloudflare --mode managed-remote --name <name> --hostname <host> --token <token>`');
|
|
2488
|
+
clackLog.step('Remove profile: `vinci tunnel profile remove --name <name>`');
|
|
2489
|
+
clackOutro('Choose a subcommand');
|
|
2490
|
+
}
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
if (sub === 'list') {
|
|
2495
|
+
const providerFilter = normalizeProfileProvider(options.provider);
|
|
2496
|
+
const profiles = providerFilter
|
|
2497
|
+
? store.profiles.filter((entry) => entry.provider === providerFilter)
|
|
2498
|
+
: store.profiles;
|
|
2499
|
+
if (isJsonMode(options)) {
|
|
2500
|
+
printJson({ profiles: redactProfilesForOutput(profiles, options.showSecrets) });
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
if (isQuietMode(options)) {
|
|
2505
|
+
for (const profile of profiles) {
|
|
2506
|
+
process.stdout.write(`${profile.name} ${profile.provider}/${profile.mode} ${profile.hostname}\n`);
|
|
2507
|
+
}
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
clackIntro('Tunnel Profiles');
|
|
2512
|
+
for (const profile of profiles) {
|
|
2513
|
+
logStatus('success', `${profile.name} (${profile.provider}/${profile.mode})`, `${profile.hostname} ${formatProfileTokenStatus(profile, options.showSecrets)}`);
|
|
2514
|
+
}
|
|
2515
|
+
clackOutro(`${profiles.length} profile(s)`);
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
if (sub === 'show') {
|
|
2520
|
+
const name = normalizeProfileName(options.name);
|
|
2521
|
+
if (!name) {
|
|
2522
|
+
throw new Error('`tunnel profile show` requires --name <name>.');
|
|
2523
|
+
}
|
|
2524
|
+
const { profile, error } = resolveProfileByName(store.profiles, name, options.provider);
|
|
2525
|
+
if (!profile) {
|
|
2526
|
+
throw new Error(error);
|
|
2527
|
+
}
|
|
2528
|
+
if (isJsonMode(options)) {
|
|
2529
|
+
printJson({ profile: redactProfileForOutput(profile, options.showSecrets) });
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
if (isQuietMode(options)) {
|
|
2533
|
+
process.stdout.write(`${profile.name} ${profile.provider}/${profile.mode} ${profile.hostname} ${formatProfileTokenStatus(profile, options.showSecrets)}\n`);
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
clackIntro('Tunnel Profile');
|
|
2537
|
+
logStatus('success', `${profile.name} (${profile.provider}/${profile.mode})`, `${profile.hostname} ${formatProfileTokenStatus(profile, options.showSecrets)}`);
|
|
2538
|
+
clackOutro('show complete');
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
if (sub === 'add') {
|
|
2543
|
+
let provider = normalizeProfileProvider(options.provider);
|
|
2544
|
+
let mode = normalizeProfileMode(options.mode);
|
|
2545
|
+
let name = normalizeProfileName(options.name);
|
|
2546
|
+
let hostname = normalizeProfileHostname(options.hostname);
|
|
2547
|
+
const resolvedTokenValue = resolveToken(options);
|
|
2548
|
+
let token = normalizeProfileToken(resolvedTokenValue);
|
|
2549
|
+
|
|
2550
|
+
if (canPrompt(options)) {
|
|
2551
|
+
if (!provider) {
|
|
2552
|
+
const providerResult = await resolveTunnelProviders(options, {
|
|
2553
|
+
readPorts: async () => (await discoverRunningInstances()).map((entry) => entry.port),
|
|
2554
|
+
});
|
|
2555
|
+
const providerOptions = (Array.isArray(providerResult.providers) ? providerResult.providers : [])
|
|
2556
|
+
.map((entry) => normalizeProfileProvider(entry?.provider))
|
|
2557
|
+
.filter(Boolean)
|
|
2558
|
+
.map((providerId) => ({ value: providerId, label: clackFormatProviderWithIcon(providerId) }));
|
|
2559
|
+
|
|
2560
|
+
if (providerOptions.length === 1) {
|
|
2561
|
+
provider = providerOptions[0].value;
|
|
2562
|
+
} else if (providerOptions.length > 1) {
|
|
2563
|
+
const selectedProvider = await clackSelect({
|
|
2564
|
+
message: 'Select tunnel provider',
|
|
2565
|
+
options: providerOptions,
|
|
2566
|
+
});
|
|
2567
|
+
if (clackIsCancel(selectedProvider)) {
|
|
2568
|
+
clackCancel('Profile add cancelled.');
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
provider = normalizeProfileProvider(selectedProvider);
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
if (!mode) {
|
|
2576
|
+
mode = 'managed-remote';
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
if (!name) {
|
|
2580
|
+
const enteredName = await clackText({
|
|
2581
|
+
message: 'Profile name (Enter to accept/edit)',
|
|
2582
|
+
placeholder: 'prod-main',
|
|
2583
|
+
initialValue: 'prod-main',
|
|
2584
|
+
validate(value) {
|
|
2585
|
+
return normalizeProfileName(value).length > 0 ? undefined : 'Profile name is required.';
|
|
2586
|
+
},
|
|
2587
|
+
});
|
|
2588
|
+
if (clackIsCancel(enteredName)) {
|
|
2589
|
+
clackCancel('Profile add cancelled.');
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
name = normalizeProfileName(enteredName);
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
const existingProfile = provider && name
|
|
2596
|
+
? store.profiles.find((entry) => entry.provider === provider && entry.name.toLowerCase() === name.toLowerCase())
|
|
2597
|
+
: null;
|
|
2598
|
+
|
|
2599
|
+
if (!hostname) {
|
|
2600
|
+
const enteredHostname = await clackText({
|
|
2601
|
+
message: 'Tunnel hostname (Enter to accept/edit)',
|
|
2602
|
+
placeholder: existingProfile?.hostname || 'app.example.com',
|
|
2603
|
+
initialValue: existingProfile?.hostname || 'app.example.com',
|
|
2604
|
+
validate(value) {
|
|
2605
|
+
return normalizeProfileHostname(value).length > 0 ? undefined : 'Hostname is required.';
|
|
2606
|
+
},
|
|
2607
|
+
});
|
|
2608
|
+
if (clackIsCancel(enteredHostname)) {
|
|
2609
|
+
clackCancel('Profile add cancelled.');
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
hostname = normalizeProfileHostname(enteredHostname);
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
if (!token && existingProfile?.token) {
|
|
2616
|
+
const useExistingToken = await clackConfirm({
|
|
2617
|
+
message: `Reuse saved token for profile '${existingProfile.name}'?`,
|
|
2618
|
+
initialValue: true,
|
|
2619
|
+
});
|
|
2620
|
+
if (clackIsCancel(useExistingToken)) {
|
|
2621
|
+
clackCancel('Profile add cancelled.');
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
if (useExistingToken) {
|
|
2625
|
+
token = existingProfile.token;
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
if (!provider || !mode || !name || !hostname) {
|
|
2631
|
+
throw new Error('`tunnel profile add` requires --provider, --mode managed-remote, --name, and --hostname.');
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
if (!token) {
|
|
2635
|
+
if (canPrompt(options)) {
|
|
2636
|
+
const entered = await clackPassword({
|
|
2637
|
+
message: `Enter tunnel token for profile '${name}'`,
|
|
2638
|
+
});
|
|
2639
|
+
if (clackIsCancel(entered) || !entered || !entered.trim()) {
|
|
2640
|
+
clackCancel('Profile add cancelled.');
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
token = normalizeProfileToken(entered.trim());
|
|
2644
|
+
}
|
|
2645
|
+
if (!token) {
|
|
2646
|
+
throw new Error('`tunnel profile add` requires a token (--token, --token-file, or --token-stdin).');
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
if (mode !== 'managed-remote') {
|
|
2650
|
+
throw new Error('`tunnel profile add` currently supports only --mode managed-remote.');
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
const existingIndex = store.profiles.findIndex(
|
|
2654
|
+
(entry) => entry.provider === provider && entry.name.toLowerCase() === name.toLowerCase()
|
|
2655
|
+
);
|
|
2656
|
+
|
|
2657
|
+
if (existingIndex >= 0 && !options.force && !options.dryRun) {
|
|
2658
|
+
if (canPrompt(options)) {
|
|
2659
|
+
const shouldOverwrite = await clackConfirm({
|
|
2660
|
+
message: `Profile '${name}' already exists for provider '${provider}'. Overwrite?`,
|
|
2661
|
+
});
|
|
2662
|
+
if (clackIsCancel(shouldOverwrite) || !shouldOverwrite) {
|
|
2663
|
+
clackCancel('Profile add cancelled.');
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
} else {
|
|
2667
|
+
throw new Error(`Profile '${name}' already exists for provider '${provider}'. Use --force to overwrite.`);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
if (options.dryRun) {
|
|
2672
|
+
const dryRunResult = {
|
|
2673
|
+
ok: true,
|
|
2674
|
+
dryRun: true,
|
|
2675
|
+
action: existingIndex >= 0 ? 'overwrite' : 'create',
|
|
2676
|
+
profile: redactProfileForOutput({ name, provider, mode, hostname, token }, options.showSecrets),
|
|
2677
|
+
};
|
|
2678
|
+
if (isJsonMode(options)) {
|
|
2679
|
+
printJson(dryRunResult);
|
|
2680
|
+
} else if (!isQuietMode(options)) {
|
|
2681
|
+
clackIntro('Tunnel Profile Add (dry-run)');
|
|
2682
|
+
logStatus('info', `Would ${existingIndex >= 0 ? 'overwrite' : 'create'}: ${name} (${provider}/${mode})`, `${hostname} ${formatProfileTokenStatus({ token }, options.showSecrets)}`);
|
|
2683
|
+
clackOutro('dry-run complete (no changes applied)');
|
|
2684
|
+
}
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
const next = [...store.profiles];
|
|
2689
|
+
const now = Date.now();
|
|
2690
|
+
if (existingIndex >= 0) {
|
|
2691
|
+
const current = next[existingIndex];
|
|
2692
|
+
next[existingIndex] = {
|
|
2693
|
+
...current,
|
|
2694
|
+
mode,
|
|
2695
|
+
hostname,
|
|
2696
|
+
token,
|
|
2697
|
+
updatedAt: now,
|
|
2698
|
+
};
|
|
2699
|
+
} else {
|
|
2700
|
+
next.push({
|
|
2701
|
+
id: crypto.randomUUID(),
|
|
2702
|
+
name,
|
|
2703
|
+
provider,
|
|
2704
|
+
mode,
|
|
2705
|
+
hostname,
|
|
2706
|
+
token,
|
|
2707
|
+
createdAt: now,
|
|
2708
|
+
updatedAt: now,
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
const persisted = { version: TUNNEL_PROFILES_VERSION, profiles: next };
|
|
2713
|
+
writeTunnelProfilesToDisk(persisted);
|
|
2714
|
+
writeManagedRemotePairsToDiskFromProfiles(persisted);
|
|
2715
|
+
const added = persisted.profiles.find((entry) => entry.provider === provider && entry.name.toLowerCase() === name.toLowerCase());
|
|
2716
|
+
|
|
2717
|
+
if (isJsonMode(options)) {
|
|
2718
|
+
printJson({ ok: true, profile: redactProfileForOutput(added, options.showSecrets) });
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
if (isQuietMode(options)) {
|
|
2723
|
+
process.stdout.write(`saved ${added.name} ${added.provider}/${added.mode} ${added.hostname}\n`);
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
console.log('');
|
|
2728
|
+
clackIntro(boldText('Tunnel Profile Saved'));
|
|
2729
|
+
logStatus('success', `${added.name} (${added.provider}/${added.mode})`, `${added.hostname} ${formatProfileTokenStatus(added, options.showSecrets)}`);
|
|
2730
|
+
clackOutro('save complete');
|
|
2731
|
+
logStatus('info', '[START_PROFILE]', `vinci tunnel start --profile ${added.name}`);
|
|
2732
|
+
clackOutro('');
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
if (sub === 'remove') {
|
|
2737
|
+
const name = normalizeProfileName(options.name);
|
|
2738
|
+
if (!name) {
|
|
2739
|
+
throw new Error('`tunnel profile remove` requires --name <name>.');
|
|
2740
|
+
}
|
|
2741
|
+
const { profile, error } = resolveProfileByName(store.profiles, name, options.provider);
|
|
2742
|
+
if (!profile) {
|
|
2743
|
+
throw new Error(error);
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
const next = store.profiles.filter((entry) => entry.id !== profile.id);
|
|
2747
|
+
const persisted = { version: TUNNEL_PROFILES_VERSION, profiles: next };
|
|
2748
|
+
writeTunnelProfilesToDisk(persisted);
|
|
2749
|
+
writeManagedRemotePairsToDiskFromProfiles(persisted);
|
|
2750
|
+
|
|
2751
|
+
if (isJsonMode(options)) {
|
|
2752
|
+
printJson({ ok: true, removed: redactProfileForOutput(profile, options.showSecrets) });
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
if (isQuietMode(options)) {
|
|
2757
|
+
process.stdout.write(`removed ${profile.name} ${profile.provider}/${profile.mode} ${profile.hostname}\n`);
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
clackIntro('Tunnel Profile Removed');
|
|
2762
|
+
logStatus('success', `${profile.name} (${profile.provider}/${profile.mode})`, profile.hostname);
|
|
2763
|
+
clackOutro('remove complete');
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
const knownProfileActions = ['list', 'show', 'add', 'remove'];
|
|
2768
|
+
const suggestion = findClosestMatch(sub, knownProfileActions);
|
|
2769
|
+
const hint = suggestion ? ` Did you mean '${suggestion}'?` : '';
|
|
2770
|
+
throw new TunnelCliError(
|
|
2771
|
+
`Unknown tunnel profile subcommand '${sub}'.${hint} Use 'vinci tunnel help'.`,
|
|
2772
|
+
EXIT_CODE.USAGE_ERROR
|
|
2773
|
+
);
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
const commands = {
|
|
2777
|
+
async serve(options) {
|
|
2778
|
+
const showOutput = shouldRenderHumanOutput(options);
|
|
2779
|
+
const jsonMessages = [];
|
|
2780
|
+
const emitNotice = (notice) => {
|
|
2781
|
+
if (!notice || typeof notice !== 'object' || typeof notice.message !== 'string') return;
|
|
2782
|
+
const level = notice.level === 'error' ? 'error' : (notice.level === 'warning' ? 'warning' : 'info');
|
|
2783
|
+
|
|
2784
|
+
if (isJsonMode(options)) {
|
|
2785
|
+
jsonMessages.push({
|
|
2786
|
+
level,
|
|
2787
|
+
code: notice.code,
|
|
2788
|
+
message: notice.message,
|
|
2789
|
+
});
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
if (showOutput) {
|
|
2794
|
+
logStatus(level, notice.message);
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
if (!isQuietMode(options)) {
|
|
2799
|
+
const prefix = level === 'warning' ? 'Warning' : level === 'error' ? 'Error' : 'Info';
|
|
2800
|
+
const line = `${prefix}: ${notice.message}`;
|
|
2801
|
+
if (level === 'error') {
|
|
2802
|
+
console.error(line);
|
|
2803
|
+
} else {
|
|
2804
|
+
console.warn(line);
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
};
|
|
2808
|
+
const explicitPort = options.explicitPort === true;
|
|
2809
|
+
const targetPort = await resolveAvailablePort(options.port, explicitPort, emitNotice);
|
|
2810
|
+
|
|
2811
|
+
if (targetPort !== 0 && !options.suppressUnsafePortWarning) {
|
|
2812
|
+
assertSafeBrowserPort(targetPort, { context: 'Vinci serve' });
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
if (targetPort !== 0) {
|
|
2816
|
+
const pidFilePath = await getPidFilePath(targetPort);
|
|
2817
|
+
const existingPid = readPidFile(pidFilePath);
|
|
2818
|
+
if (existingPid && isProcessRunning(existingPid)) {
|
|
2819
|
+
throw new Error(`Vinci is already running on port ${targetPort} (PID: ${existingPid})`);
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
if (explicitPort && !(await isPortAvailable(targetPort, options.host))) {
|
|
2823
|
+
const systemInfo = await fetchSystemInfoFromPort(targetPort);
|
|
2824
|
+
if (systemInfo?.runtime === 'desktop') {
|
|
2825
|
+
throw new Error(
|
|
2826
|
+
`Port ${targetPort} is used by Vinci Desktop app. Choose another port or stop the desktop app.`
|
|
2827
|
+
);
|
|
2828
|
+
}
|
|
2829
|
+
if (systemInfo?.runtime) {
|
|
2830
|
+
throw new Error(`Vinci is already running on port ${targetPort}. Use \`vinci status\` or \`vinci stop --port ${targetPort}\`.`);
|
|
2831
|
+
}
|
|
2832
|
+
throw new Error(`Port ${targetPort} is already in use by another process.`);
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
const opencodeBinary = await checkOpenCodeCLI(emitNotice);
|
|
2837
|
+
const serverPath = path.join(__dirname, '..', 'server', 'index.js');
|
|
2838
|
+
const preferredRuntime = getPreferredServerRuntime();
|
|
2839
|
+
const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : process.execPath;
|
|
2840
|
+
|
|
2841
|
+
ensureLogsDir();
|
|
2842
|
+
const initialLogPort = targetPort === 0 ? 'auto' : String(targetPort);
|
|
2843
|
+
const initialLogPath = getLogFilePath(initialLogPort);
|
|
2844
|
+
rotateLogFile(initialLogPath);
|
|
2845
|
+
const logFd = fs.openSync(initialLogPath, 'a');
|
|
2846
|
+
|
|
2847
|
+
const effectiveUiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined;
|
|
2848
|
+
if (!effectiveUiPassword && !options.suppressUiPasswordWarning) {
|
|
2849
|
+
const warningLine = 'VINCI_UI_PASSWORD is not set';
|
|
2850
|
+
const warningDetail = 'browser UI is unsecured. Use --ui-password or VINCI_UI_PASSWORD.';
|
|
2851
|
+
if (showOutput) {
|
|
2852
|
+
logStatus('warning', warningLine, warningDetail);
|
|
2853
|
+
} else if (isJsonMode(options)) {
|
|
2854
|
+
emitNotice({
|
|
2855
|
+
level: 'warning',
|
|
2856
|
+
code: 'UI_PASSWORD_MISSING',
|
|
2857
|
+
message: `${warningLine}; ${warningDetail}`,
|
|
2858
|
+
});
|
|
2859
|
+
} else if (!isQuietMode(options)) {
|
|
2860
|
+
console.warn(`Warning: ${warningLine}; ${warningDetail}`);
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
// Foreground mode: run server inline so the CLI process is the server process.
|
|
2864
|
+
// Required for process managers like systemd (Type=simple) that track the
|
|
2865
|
+
// direct child rather than a detached grandchild.
|
|
2866
|
+
// IMPORTANT: foreground MUST remain inline (in-process). Do not convert to
|
|
2867
|
+
// child-process orchestration — that causes shell job-control suspension.
|
|
2868
|
+
if (options.foreground) {
|
|
2869
|
+
if (isJsonMode(options)) {
|
|
2870
|
+
throw new TunnelCliError(
|
|
2871
|
+
'--json is not supported with --foreground. Use --json with background (daemon) mode instead.',
|
|
2872
|
+
EXIT_CODE.USAGE_ERROR
|
|
2873
|
+
);
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
// Propagate resolved values into env before importing the server module.
|
|
2877
|
+
if (opencodeBinary) {
|
|
2878
|
+
process.env.OPENCODE_BINARY = opencodeBinary;
|
|
2879
|
+
}
|
|
2880
|
+
if (effectiveUiPassword) {
|
|
2881
|
+
process.env.VINCI_UI_PASSWORD = effectiveUiPassword;
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
// In --quiet mode, redirect stdout/stderr to the log file so that
|
|
2885
|
+
// server runtime output (console.log calls) does not pollute the
|
|
2886
|
+
// deterministic CLI output contract. In plain human mode, close the
|
|
2887
|
+
// log fd and let output go to the inherited terminal as before.
|
|
2888
|
+
const suppressServerOutput = isQuietMode(options);
|
|
2889
|
+
// Keep a reference to the real stdout.write so CLI output (port, JSON)
|
|
2890
|
+
// can bypass the log-file redirect.
|
|
2891
|
+
const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
2892
|
+
if (suppressServerOutput) {
|
|
2893
|
+
const logStream = fs.createWriteStream(null, { fd: logFd });
|
|
2894
|
+
process.stdout.write = (chunk, encoding, callback) => {
|
|
2895
|
+
return logStream.write(chunk, encoding, callback);
|
|
2896
|
+
};
|
|
2897
|
+
process.stderr.write = (chunk, encoding, callback) => {
|
|
2898
|
+
return logStream.write(chunk, encoding, callback);
|
|
2899
|
+
};
|
|
2900
|
+
} else {
|
|
2901
|
+
// Close the log fd – in foreground human mode stdout/stderr are
|
|
2902
|
+
// inherited from the parent (e.g. journald/terminal).
|
|
2903
|
+
try {
|
|
2904
|
+
fs.closeSync(logFd);
|
|
2905
|
+
} catch {
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
if (!isQuietMode(options)) {
|
|
2910
|
+
console.log(`Starting Vinci on port ${targetPort === 0 ? 'auto' : targetPort} (foreground)`);
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
const effectiveHost = typeof options.host === 'string' && options.host.length > 0
|
|
2914
|
+
? options.host : undefined;
|
|
2915
|
+
|
|
2916
|
+
const { startWebUiServer } = await import(pathToFileURL(serverPath).href);
|
|
2917
|
+
const controller = await startWebUiServer({
|
|
2918
|
+
port: targetPort,
|
|
2919
|
+
host: effectiveHost,
|
|
2920
|
+
uiPassword: effectiveUiPassword,
|
|
2921
|
+
attachSignals: false,
|
|
2922
|
+
exitOnShutdown: false,
|
|
2923
|
+
});
|
|
2924
|
+
|
|
2925
|
+
const resolvedPort = controller.getPort();
|
|
2926
|
+
|
|
2927
|
+
// Write PID / instance files so status, stop, and restart can discover
|
|
2928
|
+
// this foreground instance the same way they discover daemon instances.
|
|
2929
|
+
const fgPidFilePath = await getPidFilePath(resolvedPort);
|
|
2930
|
+
const fgInstanceFilePath = await getInstanceFilePath(resolvedPort);
|
|
2931
|
+
writePidFile(fgPidFilePath, process.pid, emitNotice);
|
|
2932
|
+
writeInstanceOptions(fgInstanceFilePath, {
|
|
2933
|
+
port: resolvedPort,
|
|
2934
|
+
host: effectiveHost,
|
|
2935
|
+
launchMode: 'foreground',
|
|
2936
|
+
uiPassword: effectiveUiPassword,
|
|
2937
|
+
}, emitNotice);
|
|
2938
|
+
|
|
2939
|
+
if (isQuietMode(options)) {
|
|
2940
|
+
if (!options.suppressQuietOutput) {
|
|
2941
|
+
realStdoutWrite(`${resolvedPort}\n`);
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
// Clean up PID / instance files.
|
|
2946
|
+
const cleanupFiles = () => {
|
|
2947
|
+
removePidFile(fgPidFilePath);
|
|
2948
|
+
removeInstanceFile(fgInstanceFilePath);
|
|
2949
|
+
};
|
|
2950
|
+
|
|
2951
|
+
process.on('exit', cleanupFiles);
|
|
2952
|
+
|
|
2953
|
+
// Idempotent graceful shutdown with deterministic exit codes.
|
|
2954
|
+
let shutdownInProgress = false;
|
|
2955
|
+
const shutdownForegroundServer = async (signal = 'SIGTERM') => {
|
|
2956
|
+
if (shutdownInProgress) return;
|
|
2957
|
+
shutdownInProgress = true;
|
|
2958
|
+
try {
|
|
2959
|
+
await controller.stop({ exitProcess: false });
|
|
2960
|
+
} catch {
|
|
2961
|
+
}
|
|
2962
|
+
cleanupFiles();
|
|
2963
|
+
foregroundServerActive = false;
|
|
2964
|
+
foregroundShutdown = null;
|
|
2965
|
+
const exitCode = signal === 'SIGINT' ? 130 : signal === 'SIGQUIT' ? 131 : 143;
|
|
2966
|
+
process.exit(exitCode);
|
|
2967
|
+
};
|
|
2968
|
+
|
|
2969
|
+
// Expose shutdown to the global SIGINT handler.
|
|
2970
|
+
foregroundShutdown = shutdownForegroundServer;
|
|
2971
|
+
foregroundServerActive = true;
|
|
2972
|
+
|
|
2973
|
+
// Register signal handlers (additive, no removeAllListeners).
|
|
2974
|
+
process.on('SIGINT', () => { void shutdownForegroundServer('SIGINT'); });
|
|
2975
|
+
process.on('SIGTERM', () => { void shutdownForegroundServer('SIGTERM'); });
|
|
2976
|
+
process.on('SIGQUIT', () => { void shutdownForegroundServer('SIGQUIT'); });
|
|
2977
|
+
|
|
2978
|
+
// Block forever – the process stays alive until signalled.
|
|
2979
|
+
await new Promise(() => {});
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
const serverArgs = [serverPath, '--port', String(targetPort)];
|
|
2983
|
+
const effectiveHost = typeof options.host === 'string' && options.host.length > 0 ? options.host : undefined;
|
|
2984
|
+
if (effectiveHost) {
|
|
2985
|
+
serverArgs.push('--host', effectiveHost);
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
const serveSpin = showOutput ? createSpinner(options) : null;
|
|
2989
|
+
|
|
2990
|
+
const child = spawn(runtimeBin, serverArgs, {
|
|
2991
|
+
detached: true,
|
|
2992
|
+
windowsHide: true,
|
|
2993
|
+
stdio: ['ignore', logFd, logFd, 'ipc'],
|
|
2994
|
+
env: {
|
|
2995
|
+
...process.env,
|
|
2996
|
+
VINCI_PORT: String(targetPort),
|
|
2997
|
+
OPENCODE_BINARY: opencodeBinary,
|
|
2998
|
+
...(effectiveHost ? { VINCI_HOST: effectiveHost } : {}),
|
|
2999
|
+
...(effectiveUiPassword ? { VINCI_UI_PASSWORD: effectiveUiPassword } : {}),
|
|
3000
|
+
...(process.env.OPENCODE_SKIP_START ? { VINCI_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}),
|
|
3001
|
+
},
|
|
3002
|
+
});
|
|
3003
|
+
|
|
3004
|
+
child.unref();
|
|
3005
|
+
serveSpin?.start(`Starting Vinci on port ${targetPort === 0 ? 'auto' : targetPort}...`);
|
|
3006
|
+
|
|
3007
|
+
let resolvedPort;
|
|
3008
|
+
try {
|
|
3009
|
+
resolvedPort = await new Promise((resolve, reject) => {
|
|
3010
|
+
let settled = false;
|
|
3011
|
+
const timeout = setTimeout(() => {
|
|
3012
|
+
if (settled) return;
|
|
3013
|
+
settled = true;
|
|
3014
|
+
reject(new Error(`Vinci daemon did not report ready within ${DAEMON_READY_TIMEOUT_MS / 1000}s`));
|
|
3015
|
+
}, DAEMON_READY_TIMEOUT_MS);
|
|
3016
|
+
|
|
3017
|
+
child.on('message', (msg) => {
|
|
3018
|
+
if (settled) return;
|
|
3019
|
+
if (msg && msg.type === 'vinci:ready' && typeof msg.port === 'number') {
|
|
3020
|
+
settled = true;
|
|
3021
|
+
clearTimeout(timeout);
|
|
3022
|
+
resolve(msg.port);
|
|
3023
|
+
}
|
|
3024
|
+
});
|
|
3025
|
+
|
|
3026
|
+
child.on('error', (error) => {
|
|
3027
|
+
if (settled) return;
|
|
3028
|
+
settled = true;
|
|
3029
|
+
clearTimeout(timeout);
|
|
3030
|
+
reject(error);
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
child.on('exit', (code, signal) => {
|
|
3034
|
+
if (settled) return;
|
|
3035
|
+
settled = true;
|
|
3036
|
+
clearTimeout(timeout);
|
|
3037
|
+
reject(new Error(`Vinci daemon exited before reporting ready${signal ? ` (${signal})` : ` (code ${code ?? 'unknown'})`}`));
|
|
3038
|
+
});
|
|
3039
|
+
});
|
|
3040
|
+
} catch (error) {
|
|
3041
|
+
await terminateProcessTree(child.pid, { gracefulTimeoutMs: 1500, forceTimeoutMs: 1500 });
|
|
3042
|
+
throw error;
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
try {
|
|
3046
|
+
if (typeof child.disconnect === 'function' && child.connected) {
|
|
3047
|
+
child.disconnect();
|
|
3048
|
+
}
|
|
3049
|
+
} catch {
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
try {
|
|
3053
|
+
fs.closeSync(logFd);
|
|
3054
|
+
} catch {
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
const resolvedLogPath = getLogFilePath(resolvedPort);
|
|
3058
|
+
if (initialLogPath !== resolvedLogPath && !fs.existsSync(resolvedLogPath)) {
|
|
3059
|
+
try {
|
|
3060
|
+
fs.renameSync(initialLogPath, resolvedLogPath);
|
|
3061
|
+
} catch {
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
if (!isProcessRunning(child.pid)) {
|
|
3066
|
+
serveSpin?.error('Failed to start Vinci');
|
|
3067
|
+
throw new Error('Failed to start server in daemon mode');
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
const pidFilePath = await getPidFilePath(resolvedPort);
|
|
3071
|
+
const instanceFilePath = await getInstanceFilePath(resolvedPort);
|
|
3072
|
+
writePidFile(pidFilePath, child.pid, emitNotice);
|
|
3073
|
+
writeInstanceOptions(instanceFilePath, {
|
|
3074
|
+
port: resolvedPort,
|
|
3075
|
+
host: effectiveHost,
|
|
3076
|
+
launchMode: 'daemon',
|
|
3077
|
+
uiPassword: effectiveUiPassword,
|
|
3078
|
+
}, emitNotice);
|
|
3079
|
+
|
|
3080
|
+
const serveResult = {
|
|
3081
|
+
port: resolvedPort,
|
|
3082
|
+
pid: child.pid,
|
|
3083
|
+
url: buildLocalUrl(resolvedPort, '/'),
|
|
3084
|
+
logs: `vinci logs -p ${resolvedPort}`,
|
|
3085
|
+
launchMode: 'daemon',
|
|
3086
|
+
};
|
|
3087
|
+
|
|
3088
|
+
if (isJsonMode(options)) {
|
|
3089
|
+
printJson({ ...serveResult, messages: jsonMessages });
|
|
3090
|
+
return resolvedPort;
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
if (isQuietMode(options)) {
|
|
3094
|
+
if (options.suppressQuietOutput) {
|
|
3095
|
+
return resolvedPort;
|
|
3096
|
+
}
|
|
3097
|
+
process.stdout.write(`${resolvedPort}\n`);
|
|
3098
|
+
return resolvedPort;
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
serveSpin?.clear();
|
|
3102
|
+
|
|
3103
|
+
if (!options.suppressStartupSummary && showOutput) {
|
|
3104
|
+
clackIntro('Vinci Started');
|
|
3105
|
+
logStatus('success', `port ${serveResult.port} (PID: ${serveResult.pid})`);
|
|
3106
|
+
logStatus('info', `visit: ${serveResult.url}`);
|
|
3107
|
+
logStatus('info', `logs: ${serveResult.logs}`);
|
|
3108
|
+
clackOutro('daemon running');
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
return resolvedPort;
|
|
3112
|
+
},
|
|
3113
|
+
|
|
3114
|
+
async stop(options) {
|
|
3115
|
+
const showOutput = shouldRenderHumanOutput(options);
|
|
3116
|
+
const suppressQuietOutput = options?.suppressQuietOutput === true;
|
|
3117
|
+
const jsonResults = [];
|
|
3118
|
+
const printQuietStopResults = () => {
|
|
3119
|
+
if (suppressQuietOutput) return;
|
|
3120
|
+
if (!isQuietMode(options) || isJsonMode(options)) return;
|
|
3121
|
+
if (jsonResults.length === 0) {
|
|
3122
|
+
process.stdout.write('none\n');
|
|
3123
|
+
return;
|
|
3124
|
+
}
|
|
3125
|
+
for (const result of jsonResults) {
|
|
3126
|
+
if (result.stopped) {
|
|
3127
|
+
process.stdout.write(`stopped ${result.port}\n`);
|
|
3128
|
+
} else {
|
|
3129
|
+
const reason = result.reason || 'failed';
|
|
3130
|
+
process.stderr.write(`failed ${result.port} ${reason}\n`);
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
};
|
|
3134
|
+
const finish = (text) => {
|
|
3135
|
+
if (!showOutput) return;
|
|
3136
|
+
clackOutro(text);
|
|
3137
|
+
};
|
|
3138
|
+
|
|
3139
|
+
if (showOutput) {
|
|
3140
|
+
clackIntro('Vinci Stop');
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
let runningInstances = await discoverRunningInstances();
|
|
3144
|
+
if (runningInstances.length === 0) {
|
|
3145
|
+
if (isJsonMode(options)) {
|
|
3146
|
+
printJson({ stoppedCount: 0, results: jsonResults });
|
|
3147
|
+
}
|
|
3148
|
+
if (showOutput) {
|
|
3149
|
+
logStatus('info', 'No running Vinci instances found');
|
|
3150
|
+
finish('nothing to stop');
|
|
3151
|
+
}
|
|
3152
|
+
printQuietStopResults();
|
|
3153
|
+
return;
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
if (options.explicitPort) {
|
|
3157
|
+
runningInstances = runningInstances.filter((entry) => entry.port === options.port);
|
|
3158
|
+
if (runningInstances.length === 0) {
|
|
3159
|
+
const systemInfo = await fetchSystemInfoFromPort(options.port);
|
|
3160
|
+
if (systemInfo?.runtime === 'desktop') {
|
|
3161
|
+
jsonResults.push({ port: options.port, runtime: 'desktop', stopped: false, reason: 'desktop-managed' });
|
|
3162
|
+
if (isJsonMode(options)) {
|
|
3163
|
+
printJson({ stoppedCount: 0, results: jsonResults, messages: [{ level: 'warning', code: 'DESKTOP_MANAGED_PORT', message: `Port ${options.port} is managed by Vinci Desktop and cannot be stopped with this command.` }] });
|
|
3164
|
+
}
|
|
3165
|
+
if (showOutput) {
|
|
3166
|
+
logStatus('warning', `port ${options.port} is managed by Vinci Desktop`, 'cannot be stopped with this command');
|
|
3167
|
+
finish('no changes applied');
|
|
3168
|
+
}
|
|
3169
|
+
printQuietStopResults();
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
if (systemInfo?.runtime) {
|
|
3174
|
+
const unmanagedStopSpin = showOutput ? createSpinner(options) : null;
|
|
3175
|
+
if (showOutput && !unmanagedStopSpin) {
|
|
3176
|
+
logStatus('info', `found unmanaged Vinci instance on port ${options.port}`, 'attempting shutdown');
|
|
3177
|
+
}
|
|
3178
|
+
unmanagedStopSpin?.start(`Stopping unmanaged Vinci on port ${options.port}...`);
|
|
3179
|
+
const requested = await requestServerShutdown(options.port);
|
|
3180
|
+
|
|
3181
|
+
if (Number.isFinite(systemInfo.pid) && isProcessRunning(systemInfo.pid)) {
|
|
3182
|
+
await stopInstanceProcess(systemInfo.pid, {
|
|
3183
|
+
shutdownWaitMs: requested ? 5000 : 0,
|
|
3184
|
+
gracefulTimeoutMs: 2500,
|
|
3185
|
+
forceTimeoutMs: 3000,
|
|
3186
|
+
}).catch(() => false);
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
const stopped = await isPortAvailable(options.port);
|
|
3190
|
+
if (stopped) {
|
|
3191
|
+
unmanagedStopSpin?.stop(`Stopped unmanaged Vinci on port ${options.port}`);
|
|
3192
|
+
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: true });
|
|
3193
|
+
if (isJsonMode(options)) {
|
|
3194
|
+
printJson({ stoppedCount: 1, results: jsonResults });
|
|
3195
|
+
}
|
|
3196
|
+
if (showOutput && !unmanagedStopSpin) {
|
|
3197
|
+
logStatus('success', `stopped Vinci on port ${options.port}`);
|
|
3198
|
+
finish('stop complete');
|
|
3199
|
+
}
|
|
3200
|
+
printQuietStopResults();
|
|
3201
|
+
} else if (requested) {
|
|
3202
|
+
unmanagedStopSpin?.stop(`Shutdown requested on port ${options.port} (still occupied)`);
|
|
3203
|
+
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'shutdown-requested-port-busy' });
|
|
3204
|
+
if (isJsonMode(options)) {
|
|
3205
|
+
printJson({
|
|
3206
|
+
status: 'warning',
|
|
3207
|
+
stoppedCount: 0,
|
|
3208
|
+
results: jsonResults,
|
|
3209
|
+
messages: [{ level: 'warning', code: 'SHUTDOWN_PARTIAL', message: `Shutdown was requested for port ${options.port}, but the port is still occupied.` }],
|
|
3210
|
+
});
|
|
3211
|
+
}
|
|
3212
|
+
if (showOutput && !unmanagedStopSpin) {
|
|
3213
|
+
logStatus('warning', `shutdown requested on port ${options.port}`, 'port is still occupied');
|
|
3214
|
+
finish('partial stop');
|
|
3215
|
+
}
|
|
3216
|
+
printQuietStopResults();
|
|
3217
|
+
} else {
|
|
3218
|
+
unmanagedStopSpin?.error(`Could not stop Vinci on port ${options.port}`);
|
|
3219
|
+
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'stop-failed' });
|
|
3220
|
+
if (isJsonMode(options)) {
|
|
3221
|
+
printJson({
|
|
3222
|
+
status: 'error',
|
|
3223
|
+
stoppedCount: 0,
|
|
3224
|
+
results: jsonResults,
|
|
3225
|
+
messages: [{ level: 'error', code: 'STOP_FAILED', message: `Could not stop Vinci on port ${options.port}.` }],
|
|
3226
|
+
});
|
|
3227
|
+
}
|
|
3228
|
+
if (showOutput && !unmanagedStopSpin) {
|
|
3229
|
+
logStatus('error', `could not stop Vinci on port ${options.port}`);
|
|
3230
|
+
finish('failed');
|
|
3231
|
+
}
|
|
3232
|
+
printQuietStopResults();
|
|
3233
|
+
}
|
|
3234
|
+
return;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
jsonResults.push({ port: options.port, stopped: false, reason: 'not-found' });
|
|
3238
|
+
if (isJsonMode(options)) {
|
|
3239
|
+
printJson({ stoppedCount: 0, results: jsonResults });
|
|
3240
|
+
}
|
|
3241
|
+
if (showOutput) {
|
|
3242
|
+
logStatus('info', `no Vinci instance found on port ${options.port}`);
|
|
3243
|
+
finish('nothing to stop');
|
|
3244
|
+
}
|
|
3245
|
+
printQuietStopResults();
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
for (const instance of runningInstances) {
|
|
3251
|
+
const stopSpin = showOutput ? createSpinner(options) : null;
|
|
3252
|
+
if (showOutput && !stopSpin) {
|
|
3253
|
+
logStatus('info', `stopping port ${instance.port} (PID: ${instance.pid})`);
|
|
3254
|
+
}
|
|
3255
|
+
stopSpin?.start(`Stopping Vinci on port ${instance.port}...`);
|
|
3256
|
+
try {
|
|
3257
|
+
const requested = await requestServerShutdown(instance.port);
|
|
3258
|
+
const stopped = await stopInstanceProcess(instance.pid, {
|
|
3259
|
+
shutdownWaitMs: requested ? 5000 : 0,
|
|
3260
|
+
gracefulTimeoutMs: 2500,
|
|
3261
|
+
forceTimeoutMs: 3000,
|
|
3262
|
+
});
|
|
3263
|
+
if (!stopped && isProcessRunning(instance.pid)) {
|
|
3264
|
+
throw new Error(`Timed out stopping pid ${instance.pid}`);
|
|
3265
|
+
}
|
|
3266
|
+
removePidFile(instance.pidFilePath);
|
|
3267
|
+
removeInstanceFile(instance.instanceFilePath);
|
|
3268
|
+
stopSpin?.stop(`Stopped Vinci on port ${instance.port}`);
|
|
3269
|
+
jsonResults.push({ port: instance.port, pid: instance.pid, stopped: true });
|
|
3270
|
+
if (showOutput && !stopSpin) {
|
|
3271
|
+
logStatus('success', `stopped port ${instance.port}`);
|
|
3272
|
+
}
|
|
3273
|
+
} catch (error) {
|
|
3274
|
+
stopSpin?.error(`Failed to stop Vinci on port ${instance.port}`);
|
|
3275
|
+
jsonResults.push({ port: instance.port, pid: instance.pid, stopped: false, reason: error instanceof Error ? error.message : String(error) });
|
|
3276
|
+
if (showOutput) {
|
|
3277
|
+
logStatus('error', `error stopping port ${instance.port}`, error.message);
|
|
3278
|
+
} else if (!isJsonMode(options) && !isQuietMode(options)) {
|
|
3279
|
+
console.error(`Error stopping port ${instance.port}: ${error.message}`);
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
if (isJsonMode(options)) {
|
|
3285
|
+
const stoppedCount = jsonResults.filter((entry) => entry.stopped).length;
|
|
3286
|
+
const hasFailure = jsonResults.some((entry) => !entry.stopped);
|
|
3287
|
+
printJson({
|
|
3288
|
+
status: hasFailure ? 'warning' : 'ok',
|
|
3289
|
+
stoppedCount,
|
|
3290
|
+
results: jsonResults,
|
|
3291
|
+
});
|
|
3292
|
+
return;
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
finish(`${runningInstances.length} instance(s)`);
|
|
3296
|
+
printQuietStopResults();
|
|
3297
|
+
},
|
|
3298
|
+
|
|
3299
|
+
async restart(options) {
|
|
3300
|
+
const showOutput = shouldRenderHumanOutput(options);
|
|
3301
|
+
const restarted = [];
|
|
3302
|
+
|
|
3303
|
+
if (showOutput) {
|
|
3304
|
+
clackIntro('Vinci Restart');
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
let runningInstances = await discoverRunningInstances();
|
|
3308
|
+
if (runningInstances.length === 0) {
|
|
3309
|
+
if (isJsonMode(options)) {
|
|
3310
|
+
printJson({ restartedCount: 0, results: restarted });
|
|
3311
|
+
}
|
|
3312
|
+
if (showOutput) {
|
|
3313
|
+
logStatus('info', 'No running Vinci instances to restart');
|
|
3314
|
+
clackOutro('nothing to restart');
|
|
3315
|
+
} else if (isQuietMode(options)) {
|
|
3316
|
+
process.stdout.write('restarted 0\n');
|
|
3317
|
+
}
|
|
3318
|
+
return;
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
if (options.explicitPort) {
|
|
3322
|
+
runningInstances = runningInstances.filter((entry) => entry.port === options.port);
|
|
3323
|
+
if (runningInstances.length === 0) {
|
|
3324
|
+
if (isJsonMode(options)) {
|
|
3325
|
+
printJson({ restartedCount: 0, results: restarted });
|
|
3326
|
+
}
|
|
3327
|
+
if (showOutput) {
|
|
3328
|
+
logStatus('warning', `no Vinci instance found on port ${options.port}`);
|
|
3329
|
+
clackOutro('nothing to restart');
|
|
3330
|
+
} else if (isQuietMode(options)) {
|
|
3331
|
+
process.stdout.write('restarted 0\n');
|
|
3332
|
+
}
|
|
3333
|
+
return;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
for (const instance of runningInstances) {
|
|
3338
|
+
const storedOptions = readInstanceOptions(instance.instanceFilePath) || { port: instance.port };
|
|
3339
|
+
const launchMode = instance.launchMode || 'daemon';
|
|
3340
|
+
const isForeground = launchMode === 'foreground';
|
|
3341
|
+
|
|
3342
|
+
const restartPort = options.explicitPort ? options.port : instance.port;
|
|
3343
|
+
|
|
3344
|
+
const restartSpin = showOutput ? createSpinner(options) : null;
|
|
3345
|
+
if (showOutput && !restartSpin) {
|
|
3346
|
+
logStatus('info', `restarting port ${instance.port}`, `mode: ${launchMode}`);
|
|
3347
|
+
}
|
|
3348
|
+
restartSpin?.start(`Restarting Vinci on port ${instance.port}...`);
|
|
3349
|
+
try {
|
|
3350
|
+
await this.stop({
|
|
3351
|
+
explicitPort: true,
|
|
3352
|
+
port: instance.port,
|
|
3353
|
+
quiet: true,
|
|
3354
|
+
suppressQuietOutput: true,
|
|
3355
|
+
});
|
|
3356
|
+
|
|
3357
|
+
// Foreground instances are managed by a process manager (systemd,
|
|
3358
|
+
// Docker, etc.) that will restart them automatically after stop.
|
|
3359
|
+
// Do not call serve() here — just record the stop as a successful
|
|
3360
|
+
// restart and let the process manager handle the actual restart.
|
|
3361
|
+
if (isForeground) {
|
|
3362
|
+
restarted.push({ fromPort: instance.port, toPort: restartPort, launchMode, ok: true });
|
|
3363
|
+
restartSpin?.stop(`Stopped foreground instance on port ${instance.port} (process manager will restart)`);
|
|
3364
|
+
if (showOutput && !restartSpin) {
|
|
3365
|
+
logStatus('success', `port ${instance.port} stopped`, 'process manager will restart');
|
|
3366
|
+
}
|
|
3367
|
+
continue;
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
3371
|
+
|
|
3372
|
+
const restartedPort = await this.serve({
|
|
3373
|
+
port: restartPort,
|
|
3374
|
+
host: storedOptions.host,
|
|
3375
|
+
explicitPort: true,
|
|
3376
|
+
uiPassword: options.explicitUiPassword ? options.uiPassword : storedOptions.uiPassword,
|
|
3377
|
+
suppressStartupSummary: true,
|
|
3378
|
+
quiet: true,
|
|
3379
|
+
suppressUiPasswordWarning: true,
|
|
3380
|
+
suppressQuietOutput: true,
|
|
3381
|
+
});
|
|
3382
|
+
restarted.push({ fromPort: instance.port, toPort: restartedPort, launchMode, ok: true });
|
|
3383
|
+
restartSpin?.stop(`Restarted Vinci on port ${restartedPort}`);
|
|
3384
|
+
if (showOutput && !restartSpin) {
|
|
3385
|
+
logStatus('success', `port ${restartedPort} restarted`, `mode: ${launchMode}`);
|
|
3386
|
+
}
|
|
3387
|
+
} catch (error) {
|
|
3388
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3389
|
+
restartSpin?.error(`Failed to restart Vinci on port ${instance.port}`);
|
|
3390
|
+
if (showOutput && !restartSpin) {
|
|
3391
|
+
logStatus('error', `failed to restart port ${instance.port}`, message);
|
|
3392
|
+
}
|
|
3393
|
+
throw error;
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
if (isJsonMode(options)) {
|
|
3398
|
+
printJson({ restartedCount: restarted.length, results: restarted.map((r) => ({ ...r, launchMode: r.launchMode })) });
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
if (showOutput) {
|
|
3403
|
+
clackOutro(`${runningInstances.length} instance(s) restarted`);
|
|
3404
|
+
} else if (isQuietMode(options)) {
|
|
3405
|
+
process.stdout.write(`restarted ${restarted.length}\n`);
|
|
3406
|
+
}
|
|
3407
|
+
},
|
|
3408
|
+
|
|
3409
|
+
async status(options = {}) {
|
|
3410
|
+
const [runningInstances, desktopInstance] = await Promise.all([
|
|
3411
|
+
discoverRunningInstances(),
|
|
3412
|
+
discoverDesktopInstance(),
|
|
3413
|
+
]);
|
|
3414
|
+
|
|
3415
|
+
const toPasswordProtectionLabel = (value) => {
|
|
3416
|
+
if (value === true) return 'yes';
|
|
3417
|
+
if (value === false) return 'no';
|
|
3418
|
+
return 'unknown';
|
|
3419
|
+
};
|
|
3420
|
+
|
|
3421
|
+
const desktopOnly = desktopInstance && !runningInstances.some((entry) => entry.port === desktopInstance.port)
|
|
3422
|
+
? {
|
|
3423
|
+
runtime: 'desktop',
|
|
3424
|
+
port: desktopInstance.port,
|
|
3425
|
+
pid: Number.isFinite(desktopInstance.pid) ? desktopInstance.pid : null,
|
|
3426
|
+
launchMode: null,
|
|
3427
|
+
passwordProtected: null,
|
|
3428
|
+
}
|
|
3429
|
+
: null;
|
|
3430
|
+
|
|
3431
|
+
const cliInstances = runningInstances.map((instance) => {
|
|
3432
|
+
const storedOptions = readInstanceOptions(instance.instanceFilePath) || {};
|
|
3433
|
+
const passwordProtected = storedOptions.hasUiPassword === true
|
|
3434
|
+
|| (typeof storedOptions.uiPassword === 'string' && storedOptions.uiPassword.trim().length > 0);
|
|
3435
|
+
|
|
3436
|
+
return {
|
|
3437
|
+
runtime: 'cli',
|
|
3438
|
+
port: instance.port,
|
|
3439
|
+
pid: instance.pid,
|
|
3440
|
+
launchMode: instance.launchMode || 'daemon',
|
|
3441
|
+
passwordProtected,
|
|
3442
|
+
};
|
|
3443
|
+
});
|
|
3444
|
+
|
|
3445
|
+
const instances = desktopOnly ? [...cliInstances, desktopOnly] : cliInstances;
|
|
3446
|
+
const runningCount = instances.length;
|
|
3447
|
+
|
|
3448
|
+
if (isJsonMode(options)) {
|
|
3449
|
+
printJson({
|
|
3450
|
+
state: runningCount > 0 ? 'running' : 'stopped',
|
|
3451
|
+
runningCount,
|
|
3452
|
+
instances,
|
|
3453
|
+
});
|
|
3454
|
+
return;
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
if (isQuietMode(options)) {
|
|
3458
|
+
if (runningCount === 0) {
|
|
3459
|
+
process.stdout.write('stopped\n');
|
|
3460
|
+
return;
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
for (const instance of instances) {
|
|
3464
|
+
process.stdout.write(
|
|
3465
|
+
`port ${instance.port} mode:${instance.launchMode || 'n/a'} pass:${toPasswordProtectionLabel(instance.passwordProtected)}\n`
|
|
3466
|
+
);
|
|
3467
|
+
}
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
clackIntro('Vinci Status');
|
|
3472
|
+
|
|
3473
|
+
if (runningCount === 0) {
|
|
3474
|
+
logStatus('warning', 'stopped');
|
|
3475
|
+
clackOutro('no running instances');
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
for (const instance of instances) {
|
|
3480
|
+
const pidSuffix = Number.isFinite(instance.pid) ? ` (PID: ${instance.pid})` : '';
|
|
3481
|
+
const modeDetail = instance.launchMode ? `mode: ${instance.launchMode}` : '';
|
|
3482
|
+
const protectionDetail = `password: ${toPasswordProtectionLabel(instance.passwordProtected)}`;
|
|
3483
|
+
const detail = modeDetail ? `${modeDetail}; ${protectionDetail}` : protectionDetail;
|
|
3484
|
+
if (instance.runtime === 'desktop') {
|
|
3485
|
+
logStatus('info', `desktop app on port ${instance.port}${pidSuffix}`, detail);
|
|
3486
|
+
} else {
|
|
3487
|
+
logStatus('success', `port ${instance.port}${pidSuffix}`, detail);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
clackOutro(`${runningCount} running runtime(s)`);
|
|
3492
|
+
},
|
|
3493
|
+
|
|
3494
|
+
async tunnel(options, subcommand, action) {
|
|
3495
|
+
switch (subcommand) {
|
|
3496
|
+
case 'help':
|
|
3497
|
+
showTunnelHelp();
|
|
3498
|
+
return;
|
|
3499
|
+
case 'profile':
|
|
3500
|
+
await handleTunnelProfileSubcommand(options, action);
|
|
3501
|
+
return;
|
|
3502
|
+
case 'providers': {
|
|
3503
|
+
const result = await resolveTunnelProviders(options, {
|
|
3504
|
+
readPorts: async () => (await discoverRunningInstances()).map((entry) => entry.port),
|
|
3505
|
+
});
|
|
3506
|
+
if (isJsonMode(options)) {
|
|
3507
|
+
printJson({ providers: annotateTunnelProvidersForOutput(result.providers), source: result.source });
|
|
3508
|
+
return;
|
|
3509
|
+
}
|
|
3510
|
+
if (isQuietMode(options)) {
|
|
3511
|
+
for (const provider of result.providers) {
|
|
3512
|
+
const modes = Array.isArray(provider?.modes) ? provider.modes : [];
|
|
3513
|
+
const providerId = provider?.provider || 'unknown';
|
|
3514
|
+
process.stdout.write(`provider ${providerId} modes ${modes.length}\n`);
|
|
3515
|
+
for (const mode of modes) {
|
|
3516
|
+
const requires = formatModeRequirements(mode).replace(/,\s+/g, ',');
|
|
3517
|
+
process.stdout.write(`mode ${mode?.key || 'unknown'} requires ${requires}\n`);
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
return;
|
|
3521
|
+
}
|
|
3522
|
+
clackIntro('Tunnel Providers');
|
|
3523
|
+
for (const provider of result.providers) {
|
|
3524
|
+
const modes = Array.isArray(provider?.modes) ? provider.modes : [];
|
|
3525
|
+
clackLog.success(`${clackFormatProviderWithIcon(provider.provider)} — ${modes.length} mode(s)`);
|
|
3526
|
+
for (const mode of modes) {
|
|
3527
|
+
const label = mode.label || mode.key;
|
|
3528
|
+
const requires = formatModeRequirements(mode);
|
|
3529
|
+
clackLog.step(`${mode.key} — ${label}\n requires: ${requires}`);
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
clackOutro(`${result.providers.length} provider(s)`);
|
|
3533
|
+
return;
|
|
3534
|
+
}
|
|
3535
|
+
case 'ready': {
|
|
3536
|
+
const entries = await resolveTunnelReadEntries(options);
|
|
3537
|
+
const provider = typeof options.provider === 'string' && options.provider.trim().length > 0
|
|
3538
|
+
? options.provider.trim().toLowerCase()
|
|
3539
|
+
: 'cloudflare';
|
|
3540
|
+
|
|
3541
|
+
const results = [];
|
|
3542
|
+
for (const entry of entries) {
|
|
3543
|
+
try {
|
|
3544
|
+
const { response, body } = await requestJson(entry.port, `/api/vinci/tunnel/check?provider=${encodeURIComponent(provider)}`);
|
|
3545
|
+
if (!response.ok) {
|
|
3546
|
+
results.push({ port: entry.port, error: body?.error || `check ${response.status}` });
|
|
3547
|
+
continue;
|
|
3548
|
+
}
|
|
3549
|
+
results.push({ port: entry.port, result: body });
|
|
3550
|
+
} catch (error) {
|
|
3551
|
+
results.push({ port: entry.port, error: error instanceof Error ? error.message : String(error) });
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
|
|
3555
|
+
if (isJsonMode(options)) {
|
|
3556
|
+
printJson({ instances: results });
|
|
3557
|
+
return;
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
if (isQuietMode(options)) {
|
|
3561
|
+
for (const result of results) {
|
|
3562
|
+
if (result.error) {
|
|
3563
|
+
process.stderr.write(`port ${result.port} failed: ${result.error}\n`);
|
|
3564
|
+
continue;
|
|
3565
|
+
}
|
|
3566
|
+
const providerId = result.result?.provider || provider;
|
|
3567
|
+
if (result.result?.available) {
|
|
3568
|
+
process.stdout.write(`port ${result.port} ready ${providerId} ${result.result?.version || 'unknown'}\n`);
|
|
3569
|
+
} else {
|
|
3570
|
+
process.stdout.write(`port ${result.port} not-ready ${providerId} ${result.result?.message || 'not ready'}\n`);
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
clackIntro('Tunnel Ready');
|
|
3577
|
+
for (const result of results) {
|
|
3578
|
+
if (result.error) {
|
|
3579
|
+
logStatus('error', `port ${result.port} failed`, result.error);
|
|
3580
|
+
continue;
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
logStatus(
|
|
3584
|
+
result.result?.available ? 'success' : 'warning',
|
|
3585
|
+
`port ${result.port} provider ${clackFormatProviderWithIcon(result.result?.provider || provider)}`,
|
|
3586
|
+
result.result?.available
|
|
3587
|
+
? `ready (${result.result?.version || 'unknown version'})`
|
|
3588
|
+
: (result.result?.message || 'not ready'),
|
|
3589
|
+
);
|
|
3590
|
+
}
|
|
3591
|
+
clackOutro(`${results.length} instance(s)`);
|
|
3592
|
+
return;
|
|
3593
|
+
}
|
|
3594
|
+
case 'status': {
|
|
3595
|
+
const entries = await resolveTunnelReadEntries(options);
|
|
3596
|
+
|
|
3597
|
+
const results = [];
|
|
3598
|
+
for (const entry of entries) {
|
|
3599
|
+
try {
|
|
3600
|
+
const { response, body } = await requestJson(entry.port, '/api/vinci/tunnel/status');
|
|
3601
|
+
if (!response.ok) {
|
|
3602
|
+
results.push({ port: entry.port, error: body?.error || `status ${response.status}` });
|
|
3603
|
+
continue;
|
|
3604
|
+
}
|
|
3605
|
+
results.push({ port: entry.port, status: body });
|
|
3606
|
+
} catch (error) {
|
|
3607
|
+
results.push({ port: entry.port, error: error instanceof Error ? error.message : String(error) });
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
if (isJsonMode(options)) {
|
|
3612
|
+
printJson({ instances: results });
|
|
3613
|
+
return;
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
if (isQuietMode(options)) {
|
|
3617
|
+
for (const result of results) {
|
|
3618
|
+
if (result.error) {
|
|
3619
|
+
process.stderr.write(`port ${result.port} failed: ${result.error}\n`);
|
|
3620
|
+
continue;
|
|
3621
|
+
}
|
|
3622
|
+
const active = Boolean(result.status?.active);
|
|
3623
|
+
const provider = result.status?.provider || 'unknown';
|
|
3624
|
+
const mode = result.status?.mode || 'unknown';
|
|
3625
|
+
const url = result.status?.url || 'n/a';
|
|
3626
|
+
process.stdout.write(`port ${result.port} ${active ? 'active' : 'inactive'} ${provider}/${mode} ${url}\n`);
|
|
3627
|
+
}
|
|
3628
|
+
return;
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
clackIntro('Tunnel Status');
|
|
3632
|
+
for (const result of results) {
|
|
3633
|
+
if (result.error) {
|
|
3634
|
+
logStatus('error', `port ${result.port} failed`, result.error);
|
|
3635
|
+
continue;
|
|
3636
|
+
}
|
|
3637
|
+
const sl = formatTunnelStatusLine(result.status, result.port);
|
|
3638
|
+
logStatus(sl.status, sl.line, sl.detail);
|
|
3639
|
+
}
|
|
3640
|
+
clackOutro(`${results.length} instance(s)`);
|
|
3641
|
+
return;
|
|
3642
|
+
}
|
|
3643
|
+
case 'doctor': {
|
|
3644
|
+
const doctorSpin = createSpinner(options);
|
|
3645
|
+
doctorSpin?.start('Running tunnel diagnostics...');
|
|
3646
|
+
|
|
3647
|
+
// Phase 1: Port discovery
|
|
3648
|
+
const { statuses: portStatuses, availableEntries } = await resolveDoctorPortStatuses(options);
|
|
3649
|
+
|
|
3650
|
+
// Phase 2: Provider diagnostics via the doctor endpoint
|
|
3651
|
+
doctorSpin?.message('Checking provider...');
|
|
3652
|
+
let providerOption = typeof options.provider === 'string' && options.provider.trim().length > 0
|
|
3653
|
+
? options.provider.trim().toLowerCase()
|
|
3654
|
+
: '';
|
|
3655
|
+
|
|
3656
|
+
let doctorProfile = null;
|
|
3657
|
+
let doctorHostnameOverride = typeof options.hostname === 'string' ? options.hostname.trim() : '';
|
|
3658
|
+
const explicitHostnameProvided = doctorHostnameOverride.length > 0;
|
|
3659
|
+
const explicitTokenProvided = Boolean(options.tokenStdin)
|
|
3660
|
+
|| (typeof options.token === 'string' && options.token.trim().length > 0)
|
|
3661
|
+
|| (typeof options.tokenFile === 'string' && options.tokenFile.trim().length > 0);
|
|
3662
|
+
let doctorTokenValue = resolveToken(options);
|
|
3663
|
+
let hasSavedManagedRemoteProfile = false;
|
|
3664
|
+
const normalizedMode = typeof options.mode === 'string' ? options.mode.trim().toLowerCase() : '';
|
|
3665
|
+
|
|
3666
|
+
if (typeof options.profile === 'string' && options.profile.trim().length > 0) {
|
|
3667
|
+
const store = ensureTunnelProfilesMigrated();
|
|
3668
|
+
const resolved = resolveProfileByName(store.profiles, options.profile, providerOption || options.provider);
|
|
3669
|
+
if (!resolved.profile) {
|
|
3670
|
+
throw new Error(resolved.error);
|
|
3671
|
+
}
|
|
3672
|
+
doctorProfile = resolved.profile;
|
|
3673
|
+
} else if (!doctorHostnameOverride && !explicitTokenProvided && (!normalizedMode || normalizedMode === 'managed-remote')) {
|
|
3674
|
+
const store = ensureTunnelProfilesMigrated();
|
|
3675
|
+
const remoteProfiles = store.profiles.filter((entry) => {
|
|
3676
|
+
if (entry.mode !== 'managed-remote') return false;
|
|
3677
|
+
if (!providerOption) return true;
|
|
3678
|
+
return entry.provider === providerOption;
|
|
3679
|
+
});
|
|
3680
|
+
hasSavedManagedRemoteProfile = remoteProfiles.some((entry) => {
|
|
3681
|
+
const savedHostname = normalizeProfileHostname(entry.hostname);
|
|
3682
|
+
const savedToken = normalizeProfileToken(entry.token);
|
|
3683
|
+
return Boolean(savedHostname && savedToken);
|
|
3684
|
+
});
|
|
3685
|
+
if (remoteProfiles.length === 1) {
|
|
3686
|
+
doctorProfile = remoteProfiles[0];
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
if (doctorProfile) {
|
|
3691
|
+
providerOption = providerOption || doctorProfile.provider;
|
|
3692
|
+
if (!doctorHostnameOverride && typeof doctorProfile.hostname === 'string') {
|
|
3693
|
+
doctorHostnameOverride = doctorProfile.hostname.trim();
|
|
3694
|
+
}
|
|
3695
|
+
if ((!doctorTokenValue || doctorTokenValue.trim().length === 0) && typeof doctorProfile.token === 'string') {
|
|
3696
|
+
doctorTokenValue = doctorProfile.token.trim();
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
let doctorResult = null;
|
|
3701
|
+
let doctorError = null;
|
|
3702
|
+
const diagnosticsEntries = [...availableEntries].sort((a, b) => b.mtime - a.mtime);
|
|
3703
|
+
if (diagnosticsEntries.length > 0) {
|
|
3704
|
+
const query = new URLSearchParams();
|
|
3705
|
+
if (providerOption) query.set('provider', providerOption);
|
|
3706
|
+
if (typeof options.mode === 'string' && options.mode.trim().length > 0) {
|
|
3707
|
+
query.set('mode', options.mode.trim().toLowerCase());
|
|
3708
|
+
}
|
|
3709
|
+
if (typeof options.configPath === 'string') query.set('configPath', options.configPath);
|
|
3710
|
+
if (doctorHostnameOverride.length > 0) {
|
|
3711
|
+
query.set('managedRemoteTunnelHostname', doctorHostnameOverride);
|
|
3712
|
+
}
|
|
3713
|
+
if (hasSavedManagedRemoteProfile) {
|
|
3714
|
+
query.set('hasSavedManagedRemoteProfile', '1');
|
|
3715
|
+
}
|
|
3716
|
+
const doctorBody = {};
|
|
3717
|
+
doctorBody.managedRemoteTunnelTokenProvided = explicitTokenProvided;
|
|
3718
|
+
doctorBody.managedRemoteTunnelHostnameProvided = explicitHostnameProvided;
|
|
3719
|
+
if (typeof doctorTokenValue === 'string' && doctorTokenValue.trim().length > 0) {
|
|
3720
|
+
doctorBody.managedRemoteTunnelToken = doctorTokenValue;
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
const failedPorts = [];
|
|
3724
|
+
for (const diagnosticsEntry of diagnosticsEntries) {
|
|
3725
|
+
try {
|
|
3726
|
+
doctorSpin?.message(`Diagnosing provider on port ${diagnosticsEntry.port}...`);
|
|
3727
|
+
const doctorFetchOptions = { timeoutMs: 10000 };
|
|
3728
|
+
if (Object.keys(doctorBody).length > 0) {
|
|
3729
|
+
doctorFetchOptions.method = 'POST';
|
|
3730
|
+
doctorFetchOptions.body = JSON.stringify(doctorBody);
|
|
3731
|
+
}
|
|
3732
|
+
const { response, body } = await requestJson(
|
|
3733
|
+
diagnosticsEntry.port,
|
|
3734
|
+
`/api/vinci/tunnel/doctor?${query.toString()}`,
|
|
3735
|
+
doctorFetchOptions,
|
|
3736
|
+
);
|
|
3737
|
+
if (response.ok && body?.ok && isValidTunnelDoctorResponse(body)) {
|
|
3738
|
+
doctorResult = body;
|
|
3739
|
+
doctorError = null;
|
|
3740
|
+
break;
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
const looksIncompatible = response.ok && (!body || typeof body !== 'object' || !body.ok);
|
|
3744
|
+
const fallbackError = looksIncompatible
|
|
3745
|
+
? `port ${diagnosticsEntry.port}: doctor endpoint unavailable or incompatible (restart this CLI instance)`
|
|
3746
|
+
: `port ${diagnosticsEntry.port}: ${body?.error || `doctor ${response.status}`}`;
|
|
3747
|
+
failedPorts.push(fallbackError);
|
|
3748
|
+
} catch (error) {
|
|
3749
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3750
|
+
failedPorts.push(`port ${diagnosticsEntry.port}: ${message}`);
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
if (!doctorResult) {
|
|
3755
|
+
doctorError = failedPorts.length > 0
|
|
3756
|
+
? failedPorts[0]
|
|
3757
|
+
: 'No compatible CLI instance found for tunnel doctor.';
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
doctorSpin?.clear();
|
|
3762
|
+
|
|
3763
|
+
// JSON output
|
|
3764
|
+
if (isJsonMode(options)) {
|
|
3765
|
+
const cliPorts = portStatuses
|
|
3766
|
+
.filter((s) => s.available)
|
|
3767
|
+
.map((s) => ({ port: s.port, type: 'cli', available: true }));
|
|
3768
|
+
const desktopPorts = portStatuses
|
|
3769
|
+
.filter((s) => !s.available)
|
|
3770
|
+
.map((s) => ({ port: s.port, type: 'desktop', available: false }));
|
|
3771
|
+
printJson({
|
|
3772
|
+
ports: [...cliPorts, ...desktopPorts],
|
|
3773
|
+
provider: doctorResult ? {
|
|
3774
|
+
id: doctorResult.provider,
|
|
3775
|
+
checks: doctorResult.providerChecks || [],
|
|
3776
|
+
} : null,
|
|
3777
|
+
modes: doctorResult?.modes || [],
|
|
3778
|
+
error: doctorError || undefined,
|
|
3779
|
+
});
|
|
3780
|
+
return;
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
if (isQuietMode(options)) {
|
|
3784
|
+
const cliPorts = portStatuses.filter((s) => s.available).map((s) => s.port);
|
|
3785
|
+
process.stdout.write(`cli-ports ${cliPorts.join(',') || 'none'}\n`);
|
|
3786
|
+
if (doctorError) {
|
|
3787
|
+
process.stderr.write(`doctor-error ${doctorError}\n`);
|
|
3788
|
+
return;
|
|
3789
|
+
}
|
|
3790
|
+
if (!doctorResult) {
|
|
3791
|
+
process.stdout.write('doctor unavailable\n');
|
|
3792
|
+
return;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
const providerLabel = doctorResult.provider || providerOption || 'unknown';
|
|
3796
|
+
process.stdout.write(`provider ${providerLabel}\n`);
|
|
3797
|
+
const modes = Array.isArray(doctorResult.modes) ? doctorResult.modes : [];
|
|
3798
|
+
for (const modeEntry of modes) {
|
|
3799
|
+
const ready = modeEntry.ready === true || modeEntry.summary?.ready === true;
|
|
3800
|
+
if (ready) {
|
|
3801
|
+
process.stdout.write(`mode ${modeEntry.mode} ready\n`);
|
|
3802
|
+
continue;
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
const blockers = Array.isArray(modeEntry.blockers)
|
|
3806
|
+
? modeEntry.blockers
|
|
3807
|
+
: (Array.isArray(modeEntry.checks)
|
|
3808
|
+
? modeEntry.checks
|
|
3809
|
+
.filter((c) => c?.status === 'fail' && c?.id !== 'startup_readiness')
|
|
3810
|
+
.map((c) => c.detail || c.label || c.id)
|
|
3811
|
+
: []);
|
|
3812
|
+
process.stdout.write(`mode ${modeEntry.mode} not-ready ${blockers.length || 0}\n`);
|
|
3813
|
+
for (const blocker of blockers) {
|
|
3814
|
+
process.stdout.write(`blocker ${modeEntry.mode} ${String(blocker)}\n`);
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
return;
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
// ── Section 1: Ports ──────────────────────────────────────
|
|
3821
|
+
const cliPorts = portStatuses.filter((s) => s.available);
|
|
3822
|
+
const unavailablePorts = portStatuses.filter((s) => !s.available);
|
|
3823
|
+
|
|
3824
|
+
clackIntro(boldText('Ports'));
|
|
3825
|
+
for (const entry of cliPorts) {
|
|
3826
|
+
logStatus('success', `port ${entry.port} — CLI (available)`);
|
|
3827
|
+
}
|
|
3828
|
+
const desktopUnavailablePorts = [];
|
|
3829
|
+
for (const entry of unavailablePorts) {
|
|
3830
|
+
const isDesktop = typeof entry?.line === 'string' && entry.line.includes('desktop runtime');
|
|
3831
|
+
if (isDesktop) {
|
|
3832
|
+
desktopUnavailablePorts.push(entry.port);
|
|
3833
|
+
logStatus('error', `port ${entry.port} — Desktop (tunneling not supported)`);
|
|
3834
|
+
continue;
|
|
3835
|
+
}
|
|
3836
|
+
logStatus('error', `port ${entry.port} — No running instance`);
|
|
3837
|
+
}
|
|
3838
|
+
if (desktopUnavailablePorts.length > 0) {
|
|
3839
|
+
clackLog.message('Only CLI instances (vinci serve) support tunneling.');
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
if (cliPorts.length === 0 && unavailablePorts.length === 0) {
|
|
3843
|
+
logStatus('warning', 'No running instances found', 'Start one with `vinci serve`.');
|
|
3844
|
+
clackOutro('No ports available');
|
|
3845
|
+
return;
|
|
3846
|
+
}
|
|
3847
|
+
if (cliPorts.length === 0) {
|
|
3848
|
+
logStatus('warning', 'No CLI instances available for tunneling', 'Start one with `vinci serve`.');
|
|
3849
|
+
clackOutro('No CLI ports available');
|
|
3850
|
+
return;
|
|
3851
|
+
}
|
|
3852
|
+
clackOutro(`${cliPorts.length} CLI ${cliPorts.length === 1 ? 'port' : 'ports'} available`);
|
|
3853
|
+
console.log('');
|
|
3854
|
+
|
|
3855
|
+
if (doctorProfile) {
|
|
3856
|
+
logStatus('info', 'Using saved profile for managed-remote checks', `${doctorProfile.name} (${doctorProfile.provider}/${doctorProfile.mode})`);
|
|
3857
|
+
console.log('');
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3860
|
+
// ── Section 2: Provider ─────────────────────────────────
|
|
3861
|
+
if (doctorError) {
|
|
3862
|
+
clackIntro(boldText('Provider'));
|
|
3863
|
+
logStatus('error', 'Provider diagnostics failed', doctorError);
|
|
3864
|
+
clackOutro('Failed');
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
if (!doctorResult) {
|
|
3868
|
+
clackIntro(boldText('Provider'));
|
|
3869
|
+
logStatus('warning', 'Could not reach a running instance for diagnostics');
|
|
3870
|
+
clackOutro('Unavailable');
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
const providerLabel = clackFormatProviderWithIcon(doctorResult.provider || 'unknown');
|
|
3875
|
+
clackIntro(boldText(`Provider: ${providerLabel}`));
|
|
3876
|
+
|
|
3877
|
+
let providerPassCount = 0;
|
|
3878
|
+
for (const check of (doctorResult.providerChecks || [])) {
|
|
3879
|
+
const passed = check.status === 'pass';
|
|
3880
|
+
if (passed) {
|
|
3881
|
+
providerPassCount++;
|
|
3882
|
+
logStatus('success', `${check.label}${check.detail ? ` — ${check.detail}` : ''}`);
|
|
3883
|
+
} else {
|
|
3884
|
+
logStatus('error', check.label, check.detail || undefined);
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
const depCheck = (doctorResult.providerChecks || []).find(
|
|
3889
|
+
(c) => c.id === 'dependency' || c.id === 'provider_dependency',
|
|
3890
|
+
);
|
|
3891
|
+
if (depCheck && depCheck.status !== 'pass') {
|
|
3892
|
+
clackOutro('1 blocker — resolve before checking modes');
|
|
3893
|
+
return;
|
|
3894
|
+
}
|
|
3895
|
+
clackOutro(`${providerPassCount} ${providerPassCount === 1 ? 'check' : 'checks'} passed`);
|
|
3896
|
+
console.log('');
|
|
3897
|
+
|
|
3898
|
+
// ── Section 3: Modes ────────────────────────────────────
|
|
3899
|
+
const DOCTOR_NOISE_CHECK_IDS = new Set(['startup_readiness', 'quick_mode_prerequisites']);
|
|
3900
|
+
const modes = doctorResult.modes || [];
|
|
3901
|
+
if (modes.length === 0) {
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
clackIntro(boldText('Modes'));
|
|
3906
|
+
let totalBlockers = 0;
|
|
3907
|
+
const troubleshootingHints = [];
|
|
3908
|
+
for (const modeEntry of modes) {
|
|
3909
|
+
const isReady = modeEntry.ready === true || modeEntry.summary?.ready === true;
|
|
3910
|
+
if (isReady) {
|
|
3911
|
+
const passDetail = Array.isArray(modeEntry.checks)
|
|
3912
|
+
? modeEntry.checks.find((c) => c?.status === 'pass' && !DOCTOR_NOISE_CHECK_IDS.has(c?.id))?.detail
|
|
3913
|
+
: null;
|
|
3914
|
+
logStatus('success', `${modeEntry.mode} — Ready${passDetail ? ` (${passDetail})` : ''}`);
|
|
3915
|
+
} else {
|
|
3916
|
+
const blockers = Array.isArray(modeEntry.blockers)
|
|
3917
|
+
? modeEntry.blockers
|
|
3918
|
+
: (Array.isArray(modeEntry.checks)
|
|
3919
|
+
? modeEntry.checks
|
|
3920
|
+
.filter((c) => c?.status === 'fail' && c?.id !== 'startup_readiness')
|
|
3921
|
+
.map((c) => c.detail || c.label || c.id)
|
|
3922
|
+
: []);
|
|
3923
|
+
totalBlockers += blockers.length;
|
|
3924
|
+
const blockerCount = blockers.length;
|
|
3925
|
+
const blockerWord = blockerCount === 1 ? 'blocker' : 'blockers';
|
|
3926
|
+
logStatus('error', `${modeEntry.mode} — Not ready${blockerCount > 0 ? ` (${blockerCount} ${blockerWord})` : ''}`);
|
|
3927
|
+
for (const blocker of blockers) {
|
|
3928
|
+
clackLog.message(` ${blocker}`);
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
const normalizedBlockers = blockers.map((blocker) => String(blocker).toLowerCase());
|
|
3932
|
+
const isManagedRemote = modeEntry.mode === 'managed-remote';
|
|
3933
|
+
const hasTokenIssue = normalizedBlockers.some((line) => line.includes('token')
|
|
3934
|
+
|| line.includes('unauthorized')
|
|
3935
|
+
|| line.includes('forbidden')
|
|
3936
|
+
|| line.includes('authentication')
|
|
3937
|
+
|| line.includes('auth'));
|
|
3938
|
+
const hasPortOrOriginIssue = normalizedBlockers.some((line) => line.includes('port')
|
|
3939
|
+
|| line.includes('localhost')
|
|
3940
|
+
|| line.includes('127.0.0.1')
|
|
3941
|
+
|| line.includes('connection refused')
|
|
3942
|
+
|| line.includes('dial tcp'));
|
|
3943
|
+
|
|
3944
|
+
if (isManagedRemote && (hasPortOrOriginIssue || hasTokenIssue)) {
|
|
3945
|
+
troubleshootingHints.push({
|
|
3946
|
+
key: 'managed-remote-port',
|
|
3947
|
+
code: '[PORT_MISMATCH]',
|
|
3948
|
+
lines: [
|
|
3949
|
+
'Cloudflare target must match the active Vinci CLI port.',
|
|
3950
|
+
'Example: `http://127.0.0.1:<port>`',
|
|
3951
|
+
'If CLI picked a different port, update Cloudflare or run `vinci serve --port <port>`.',
|
|
3952
|
+
],
|
|
3953
|
+
});
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3956
|
+
if (isManagedRemote && hasTokenIssue) {
|
|
3957
|
+
troubleshootingHints.push({
|
|
3958
|
+
key: 'managed-remote-token',
|
|
3959
|
+
code: '[QR_PREFETCH_TOKEN]',
|
|
3960
|
+
lines: [
|
|
3961
|
+
'Some QR readers pre-fetch scanned URLs.',
|
|
3962
|
+
'Pre-fetch can consume one-time bootstrap tokens.',
|
|
3963
|
+
'If validation fails, generate a fresh token/QR and use it immediately in one browser/device.',
|
|
3964
|
+
],
|
|
3965
|
+
});
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
clackOutro(totalBlockers > 0 ? `Done (${totalBlockers} ${totalBlockers === 1 ? 'issue' : 'issues'})` : 'All modes ready');
|
|
3970
|
+
|
|
3971
|
+
const dedupedHints = [];
|
|
3972
|
+
const seenHintKeys = new Set();
|
|
3973
|
+
for (const hint of troubleshootingHints) {
|
|
3974
|
+
if (!seenHintKeys.has(hint.key)) {
|
|
3975
|
+
seenHintKeys.add(hint.key);
|
|
3976
|
+
dedupedHints.push(hint);
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
if (dedupedHints.length > 0) {
|
|
3981
|
+
console.log('');
|
|
3982
|
+
clackIntro(boldText('Suggestion notes'));
|
|
3983
|
+
for (const hint of dedupedHints) {
|
|
3984
|
+
const lines = Array.isArray(hint.lines) ? hint.lines : [];
|
|
3985
|
+
const detail = lines.length > 0 ? lines.map((line) => ` ${line}`).join('\n') : undefined;
|
|
3986
|
+
logStatus('info', hint.code || '[NOTE]', detail);
|
|
3987
|
+
}
|
|
3988
|
+
clackOutro(`${dedupedHints.length} ${dedupedHints.length === 1 ? 'suggestion' : 'suggestions'}`);
|
|
3989
|
+
}
|
|
3990
|
+
return;
|
|
3991
|
+
}
|
|
3992
|
+
case 'start': {
|
|
3993
|
+
let provider = typeof options.provider === 'string' && options.provider.trim().length > 0
|
|
3994
|
+
? options.provider.trim().toLowerCase()
|
|
3995
|
+
: '';
|
|
3996
|
+
let mode = typeof options.mode === 'string' && options.mode.trim().length > 0
|
|
3997
|
+
? options.mode.trim().toLowerCase()
|
|
3998
|
+
: '';
|
|
3999
|
+
let resolvedTokenValue = resolveToken(options);
|
|
4000
|
+
let token = typeof resolvedTokenValue === 'string' ? resolvedTokenValue : undefined;
|
|
4001
|
+
let hostname = typeof options.hostname === 'string' ? options.hostname : undefined;
|
|
4002
|
+
let selectedProfile = null;
|
|
4003
|
+
|
|
4004
|
+
if (options.explicitPort) {
|
|
4005
|
+
assertSafeBrowserPort(options.port, { context: 'Tunnel start' });
|
|
4006
|
+
}
|
|
4007
|
+
|
|
4008
|
+
if (typeof options.profile === 'string' && options.profile.trim().length > 0) {
|
|
4009
|
+
const store = ensureTunnelProfilesMigrated();
|
|
4010
|
+
const resolved = resolveProfileByName(store.profiles, options.profile, provider || options.provider);
|
|
4011
|
+
if (!resolved.profile) {
|
|
4012
|
+
throw new Error(resolved.error);
|
|
4013
|
+
}
|
|
4014
|
+
selectedProfile = resolved.profile;
|
|
4015
|
+
provider = provider || selectedProfile.provider;
|
|
4016
|
+
mode = mode || selectedProfile.mode;
|
|
4017
|
+
token = (typeof token === 'string' && token.trim().length > 0) ? token : selectedProfile.token;
|
|
4018
|
+
hostname = typeof options.hostname === 'string' && options.hostname.trim().length > 0 ? options.hostname : selectedProfile.hostname;
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
// Interactive profile selection when no profile/mode specified in TTY
|
|
4022
|
+
if (!selectedProfile && !mode && canPrompt(options)) {
|
|
4023
|
+
const store = ensureTunnelProfilesMigrated();
|
|
4024
|
+
if (store.profiles.length > 0) {
|
|
4025
|
+
const profileChoice = await clackSelect({
|
|
4026
|
+
message: 'Start from a saved profile or choose a mode?',
|
|
4027
|
+
options: [
|
|
4028
|
+
{ value: '__mode__', label: 'Choose a mode manually' },
|
|
4029
|
+
...store.profiles.map((p) => ({
|
|
4030
|
+
value: p.id,
|
|
4031
|
+
label: `${p.name} (${p.provider}/${p.mode})`,
|
|
4032
|
+
hint: p.hostname,
|
|
4033
|
+
})),
|
|
4034
|
+
],
|
|
4035
|
+
});
|
|
4036
|
+
if (clackIsCancel(profileChoice)) {
|
|
4037
|
+
clackCancel('Tunnel start cancelled.');
|
|
4038
|
+
return;
|
|
4039
|
+
}
|
|
4040
|
+
if (profileChoice !== '__mode__') {
|
|
4041
|
+
selectedProfile = store.profiles.find((p) => p.id === profileChoice);
|
|
4042
|
+
if (selectedProfile) {
|
|
4043
|
+
provider = provider || selectedProfile.provider;
|
|
4044
|
+
mode = mode || selectedProfile.mode;
|
|
4045
|
+
token = (typeof token === 'string' && token.trim().length > 0) ? token : selectedProfile.token;
|
|
4046
|
+
hostname = typeof options.hostname === 'string' && options.hostname.trim().length > 0 ? options.hostname : selectedProfile.hostname;
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
provider = provider || 'cloudflare';
|
|
4053
|
+
|
|
4054
|
+
// Interactive mode selection when mode not yet resolved in TTY
|
|
4055
|
+
if (!mode && canPrompt(options)) {
|
|
4056
|
+
const providerCaps = DEFAULT_TUNNEL_PROVIDER_CAPABILITIES.find(
|
|
4057
|
+
(cap) => cap.provider === provider
|
|
4058
|
+
);
|
|
4059
|
+
const modes = providerCaps?.modes || [];
|
|
4060
|
+
if (modes.length > 1) {
|
|
4061
|
+
const modeChoice = await clackSelect({
|
|
4062
|
+
message: `Select tunnel mode for ${clackFormatProviderWithIcon(provider)}`,
|
|
4063
|
+
options: modes.map((m) => ({
|
|
4064
|
+
value: m.key,
|
|
4065
|
+
label: `${m.key} — ${m.label}`,
|
|
4066
|
+
hint: formatModeRequirements(m) !== 'none' ? `requires: ${formatModeRequirements(m)}` : undefined,
|
|
4067
|
+
})),
|
|
4068
|
+
});
|
|
4069
|
+
if (clackIsCancel(modeChoice)) {
|
|
4070
|
+
clackCancel('Tunnel start cancelled.');
|
|
4071
|
+
return;
|
|
4072
|
+
}
|
|
4073
|
+
mode = modeChoice;
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
mode = mode || 'quick';
|
|
4078
|
+
if (mode === 'managed-remote') {
|
|
4079
|
+
if (!(typeof hostname === 'string' && hostname.trim().length > 0)) {
|
|
4080
|
+
if (canPrompt(options)) {
|
|
4081
|
+
const profilesStore = ensureTunnelProfilesMigrated();
|
|
4082
|
+
const lastManagedRemoteProfile = [...profilesStore.profiles]
|
|
4083
|
+
.filter((entry) => entry.provider === provider && entry.mode === 'managed-remote')
|
|
4084
|
+
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))[0];
|
|
4085
|
+
const suggestedHostname = normalizeProfileHostname(lastManagedRemoteProfile?.hostname) || 'app.example.com';
|
|
4086
|
+
const enteredHostname = await clackText({
|
|
4087
|
+
message: 'Enter managed-remote tunnel hostname',
|
|
4088
|
+
placeholder: suggestedHostname,
|
|
4089
|
+
initialValue: suggestedHostname,
|
|
4090
|
+
validate(value) {
|
|
4091
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
4092
|
+
return 'Hostname is required.';
|
|
4093
|
+
}
|
|
4094
|
+
return undefined;
|
|
4095
|
+
},
|
|
4096
|
+
});
|
|
4097
|
+
if (clackIsCancel(enteredHostname)) {
|
|
4098
|
+
clackCancel('Tunnel start cancelled.');
|
|
4099
|
+
return;
|
|
4100
|
+
}
|
|
4101
|
+
hostname = enteredHostname.trim();
|
|
4102
|
+
} else {
|
|
4103
|
+
throw new Error('Managed-remote mode requires --hostname <hostname>.');
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
if (!(typeof token === 'string' && token.trim().length > 0)) {
|
|
4108
|
+
if (canPrompt(options)) {
|
|
4109
|
+
const entered = await clackPassword({
|
|
4110
|
+
message: 'Enter managed-remote tunnel token',
|
|
4111
|
+
});
|
|
4112
|
+
if (clackIsCancel(entered) || !entered || !entered.trim()) {
|
|
4113
|
+
clackCancel('Tunnel start cancelled.');
|
|
4114
|
+
return;
|
|
4115
|
+
}
|
|
4116
|
+
token = entered.trim();
|
|
4117
|
+
} else {
|
|
4118
|
+
throw new Error('Managed-remote mode requires a token (--token, --token-file, or --token-stdin).');
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4122
|
+
if (!selectedProfile && canPrompt(options)) {
|
|
4123
|
+
const runChoice = await clackSelect({
|
|
4124
|
+
message: 'Run once, or save profile and run?',
|
|
4125
|
+
options: [
|
|
4126
|
+
{ value: 'run', label: 'Run once', hint: 'Do not save profile' },
|
|
4127
|
+
{ value: 'save-run', label: 'Save profile and run', hint: 'Reuse with --profile later' },
|
|
4128
|
+
],
|
|
4129
|
+
});
|
|
4130
|
+
if (clackIsCancel(runChoice)) {
|
|
4131
|
+
clackCancel('Tunnel start cancelled.');
|
|
4132
|
+
return;
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
if (runChoice === 'save-run') {
|
|
4136
|
+
const suggestedName = suggestProfileNameFromHostname(hostname);
|
|
4137
|
+
const enteredProfileName = await clackText({
|
|
4138
|
+
message: 'Profile name',
|
|
4139
|
+
placeholder: suggestedName,
|
|
4140
|
+
initialValue: suggestedName,
|
|
4141
|
+
validate(value) {
|
|
4142
|
+
return normalizeProfileName(value).length > 0 ? undefined : 'Profile name is required.';
|
|
4143
|
+
},
|
|
4144
|
+
});
|
|
4145
|
+
if (clackIsCancel(enteredProfileName)) {
|
|
4146
|
+
clackCancel('Tunnel start cancelled.');
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
|
|
4150
|
+
const desiredName = normalizeProfileName(enteredProfileName);
|
|
4151
|
+
const store = ensureTunnelProfilesMigrated();
|
|
4152
|
+
const existingIndex = store.profiles.findIndex(
|
|
4153
|
+
(entry) => entry.provider === provider && entry.name.toLowerCase() === desiredName.toLowerCase()
|
|
4154
|
+
);
|
|
4155
|
+
|
|
4156
|
+
if (existingIndex >= 0) {
|
|
4157
|
+
const shouldOverwrite = await clackConfirm({
|
|
4158
|
+
message: `Profile '${desiredName}' already exists. Overwrite and run?`,
|
|
4159
|
+
initialValue: true,
|
|
4160
|
+
});
|
|
4161
|
+
if (clackIsCancel(shouldOverwrite) || !shouldOverwrite) {
|
|
4162
|
+
clackCancel('Tunnel start cancelled.');
|
|
4163
|
+
return;
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
|
|
4167
|
+
const now = Date.now();
|
|
4168
|
+
const nextProfiles = [...store.profiles];
|
|
4169
|
+
if (existingIndex >= 0) {
|
|
4170
|
+
const current = nextProfiles[existingIndex];
|
|
4171
|
+
nextProfiles[existingIndex] = {
|
|
4172
|
+
...current,
|
|
4173
|
+
mode: 'managed-remote',
|
|
4174
|
+
hostname,
|
|
4175
|
+
token,
|
|
4176
|
+
updatedAt: now,
|
|
4177
|
+
};
|
|
4178
|
+
} else {
|
|
4179
|
+
nextProfiles.push({
|
|
4180
|
+
id: crypto.randomUUID(),
|
|
4181
|
+
name: desiredName,
|
|
4182
|
+
provider,
|
|
4183
|
+
mode: 'managed-remote',
|
|
4184
|
+
hostname,
|
|
4185
|
+
token,
|
|
4186
|
+
createdAt: now,
|
|
4187
|
+
updatedAt: now,
|
|
4188
|
+
});
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
const persisted = { version: TUNNEL_PROFILES_VERSION, profiles: nextProfiles };
|
|
4192
|
+
writeTunnelProfilesToDisk(persisted);
|
|
4193
|
+
writeManagedRemotePairsToDiskFromProfiles(persisted);
|
|
4194
|
+
|
|
4195
|
+
selectedProfile = persisted.profiles.find(
|
|
4196
|
+
(entry) => entry.provider === provider && entry.name.toLowerCase() === desiredName.toLowerCase()
|
|
4197
|
+
) || null;
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
if (typeof options.token === 'string' && !options.tokenFile && !options.tokenStdin && canPrompt(options)) {
|
|
4202
|
+
clackBox(
|
|
4203
|
+
'Token passed via --token is visible in your shell history and process list.\n' +
|
|
4204
|
+
'Consider using --token-file or --token-stdin for better security.',
|
|
4205
|
+
'Security Warning',
|
|
4206
|
+
);
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4210
|
+
if (mode === 'managed-local') {
|
|
4211
|
+
const hasConfigPath = typeof options.configPath === 'string' && options.configPath.trim().length > 0;
|
|
4212
|
+
if (!hasConfigPath && canPrompt(options)) {
|
|
4213
|
+
const lastConfigPath = readLastManagedLocalConfigPath();
|
|
4214
|
+
const defaultConfigPath = getDefaultCloudflaredConfigPath();
|
|
4215
|
+
const suggestedConfigPath = lastConfigPath || defaultConfigPath;
|
|
4216
|
+
const suggestedConfigFound = isReadableRegularFile(suggestedConfigPath);
|
|
4217
|
+
const defaultConfigFound = isReadableRegularFile(defaultConfigPath);
|
|
4218
|
+
|
|
4219
|
+
if (suggestedConfigFound || defaultConfigFound) {
|
|
4220
|
+
const foundPath = suggestedConfigFound ? suggestedConfigPath : defaultConfigPath;
|
|
4221
|
+
const configChoice = await clackSelect({
|
|
4222
|
+
message: 'Managed-local config',
|
|
4223
|
+
options: [
|
|
4224
|
+
{
|
|
4225
|
+
value: 'default',
|
|
4226
|
+
label: 'Use found config',
|
|
4227
|
+
hint: foundPath,
|
|
4228
|
+
},
|
|
4229
|
+
{
|
|
4230
|
+
value: 'custom',
|
|
4231
|
+
label: 'Enter config path',
|
|
4232
|
+
},
|
|
4233
|
+
],
|
|
4234
|
+
});
|
|
4235
|
+
if (clackIsCancel(configChoice)) {
|
|
4236
|
+
clackCancel('Tunnel start cancelled.');
|
|
4237
|
+
return;
|
|
4238
|
+
}
|
|
4239
|
+
if (configChoice === 'default') {
|
|
4240
|
+
options.configPath = foundPath;
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
|
|
4244
|
+
if (!(typeof options.configPath === 'string' && options.configPath.trim().length > 0)) {
|
|
4245
|
+
const enteredPath = await clackText({
|
|
4246
|
+
message: 'Enter managed-local config path',
|
|
4247
|
+
placeholder: suggestedConfigPath,
|
|
4248
|
+
initialValue: suggestedConfigPath,
|
|
4249
|
+
validate(value) {
|
|
4250
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
4251
|
+
return 'Config path is required.';
|
|
4252
|
+
}
|
|
4253
|
+
return undefined;
|
|
4254
|
+
},
|
|
4255
|
+
});
|
|
4256
|
+
if (clackIsCancel(enteredPath)) {
|
|
4257
|
+
clackCancel('Tunnel start cancelled.');
|
|
4258
|
+
return;
|
|
4259
|
+
}
|
|
4260
|
+
options.configPath = enteredPath.trim();
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4263
|
+
if (typeof options.configPath === 'string' && options.configPath.trim().length > 0) {
|
|
4264
|
+
writeLastManagedLocalConfigPath(options.configPath);
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
|
|
4269
|
+
const ttlOverrides = await resolveTunnelTtlOverrides(options);
|
|
4270
|
+
if (ttlOverrides === null) {
|
|
4271
|
+
return;
|
|
4272
|
+
}
|
|
4273
|
+
const { connectTtlMs, sessionTtlMs } = ttlOverrides;
|
|
4274
|
+
|
|
4275
|
+
if (options.dryRun) {
|
|
4276
|
+
const dryRunResult = {
|
|
4277
|
+
ok: true,
|
|
4278
|
+
dryRun: true,
|
|
4279
|
+
provider,
|
|
4280
|
+
mode,
|
|
4281
|
+
hostname: hostname || null,
|
|
4282
|
+
hasToken: typeof token === 'string' && token.trim().length > 0,
|
|
4283
|
+
profile: selectedProfile ? selectedProfile.name : null,
|
|
4284
|
+
configPath: options.configPath || null,
|
|
4285
|
+
connectTtlMs: connectTtlMs ?? null,
|
|
4286
|
+
sessionTtlMs: sessionTtlMs ?? null,
|
|
4287
|
+
};
|
|
4288
|
+
if (isJsonMode(options)) {
|
|
4289
|
+
printJson(dryRunResult);
|
|
4290
|
+
} else if (!isQuietMode(options)) {
|
|
4291
|
+
clackIntro('Tunnel Start (dry-run)');
|
|
4292
|
+
logStatus('info', `Would start ${clackFormatProviderWithIcon(provider)}/${mode}`, hostname || '(ephemeral URL)');
|
|
4293
|
+
clackOutro('dry-run complete (no changes applied)');
|
|
4294
|
+
}
|
|
4295
|
+
return;
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4298
|
+
if (!options.explicitPort && canPrompt(options)) {
|
|
4299
|
+
const runningInstances = await discoverRunningInstances();
|
|
4300
|
+
if (runningInstances.length > 1) {
|
|
4301
|
+
const safeInstances = runningInstances.filter((entry) => !isUnsafeBrowserPort(entry.port));
|
|
4302
|
+
if (safeInstances.length === 0) {
|
|
4303
|
+
throw new TunnelCliError(
|
|
4304
|
+
'All discovered Vinci instance ports are browser-unsafe. Start or target a safe port (3000, 5173, 8080, or high ephemeral).',
|
|
4305
|
+
EXIT_CODE.USAGE_ERROR,
|
|
4306
|
+
);
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4309
|
+
const attachabilityResults = await Promise.all(
|
|
4310
|
+
safeInstances.map(async (entry) => ({
|
|
4311
|
+
entry,
|
|
4312
|
+
attachability: await inspectTunnelAttachability(entry.port, { requireHealthy: true }),
|
|
4313
|
+
}))
|
|
4314
|
+
);
|
|
4315
|
+
const attachableSafeInstances = attachabilityResults
|
|
4316
|
+
.filter((item) => item.attachability.attachable)
|
|
4317
|
+
.map((item) => item.entry);
|
|
4318
|
+
|
|
4319
|
+
if (attachableSafeInstances.length === 0) {
|
|
4320
|
+
throw new TunnelCliError(
|
|
4321
|
+
'No attachable Vinci CLI instances found on safe ports. Start one with `vinci serve --port 3000`.',
|
|
4322
|
+
EXIT_CODE.USAGE_ERROR,
|
|
4323
|
+
);
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4326
|
+
const selectedPort = await clackSelect({
|
|
4327
|
+
message: 'Select Vinci instance port',
|
|
4328
|
+
options: attachableSafeInstances.map((entry) => ({
|
|
4329
|
+
value: entry.port,
|
|
4330
|
+
label: `port ${entry.port}`,
|
|
4331
|
+
})),
|
|
4332
|
+
});
|
|
4333
|
+
if (clackIsCancel(selectedPort)) {
|
|
4334
|
+
clackCancel('Tunnel start cancelled.');
|
|
4335
|
+
return;
|
|
4336
|
+
}
|
|
4337
|
+
options.port = Number(selectedPort);
|
|
4338
|
+
options.explicitPort = true;
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
const instance = await resolveTargetInstance({ options, allowAutoStart: true, rejectDesktopRuntime: true });
|
|
4343
|
+
if (instance?.autoStarted && shouldRenderHumanOutput(options)) {
|
|
4344
|
+
logStatus(
|
|
4345
|
+
'info',
|
|
4346
|
+
`Using auto-started instance on port ${instance.port}`,
|
|
4347
|
+
`logs: vinci logs -p ${instance.port}`,
|
|
4348
|
+
);
|
|
4349
|
+
}
|
|
4350
|
+
|
|
4351
|
+
if (instance?.autoStarted) {
|
|
4352
|
+
setCancelCleanup(async () => {
|
|
4353
|
+
try {
|
|
4354
|
+
await commands.stop({ explicitPort: true, port: instance.port });
|
|
4355
|
+
} catch {
|
|
4356
|
+
}
|
|
4357
|
+
});
|
|
4358
|
+
}
|
|
4359
|
+
|
|
4360
|
+
if (instance?.autoStarted) {
|
|
4361
|
+
const healthProgress = await createProgress(options, { max: 60 });
|
|
4362
|
+
healthProgress?.start(`Waiting for Vinci on port ${instance.port} to become healthy (up to 60s)...`);
|
|
4363
|
+
let progressedSeconds = 0;
|
|
4364
|
+
const healthy = await waitForServerHealth(instance.port, {
|
|
4365
|
+
timeoutMs: 60000,
|
|
4366
|
+
intervalMs: 250,
|
|
4367
|
+
onTick({ elapsedMs, complete }) {
|
|
4368
|
+
if (!healthProgress) return;
|
|
4369
|
+
const elapsedSeconds = Math.min(60, Math.floor(elapsedMs / 1000));
|
|
4370
|
+
const delta = elapsedSeconds - progressedSeconds;
|
|
4371
|
+
if (delta > 0) {
|
|
4372
|
+
healthProgress.advance(delta);
|
|
4373
|
+
progressedSeconds = elapsedSeconds;
|
|
4374
|
+
healthProgress.message(`Waiting for Vinci health (${progressedSeconds}s / 60s)...`);
|
|
4375
|
+
}
|
|
4376
|
+
if (complete && progressedSeconds < 60) {
|
|
4377
|
+
const remaining = 60 - progressedSeconds;
|
|
4378
|
+
if (remaining > 0) {
|
|
4379
|
+
healthProgress.advance(remaining);
|
|
4380
|
+
progressedSeconds = 60;
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
},
|
|
4384
|
+
});
|
|
4385
|
+
if (!healthy) {
|
|
4386
|
+
healthProgress?.stop('Vinci is still starting');
|
|
4387
|
+
throw new Error(
|
|
4388
|
+
`Vinci on port ${instance.port} is still starting after 60s. Startup time can vary by machine performance. ` +
|
|
4389
|
+
`Wait another minute, then check health with \`curl -fsS ${buildLocalUrl(instance.port, '/health')}\`. ` +
|
|
4390
|
+
`If health is OK, retry tunnel start with \`vinci tunnel start --port ${instance.port}\`. ` +
|
|
4391
|
+
`For diagnostics run \`vinci logs -p ${instance.port}\`.`
|
|
4392
|
+
);
|
|
4393
|
+
}
|
|
4394
|
+
healthProgress?.stop(`Instance ${instance.port} is healthy`);
|
|
4395
|
+
}
|
|
4396
|
+
|
|
4397
|
+
if (selectedProfile && mode === 'managed-remote') {
|
|
4398
|
+
const tokenSyncPayload = {
|
|
4399
|
+
presetId: selectedProfile.id,
|
|
4400
|
+
presetName: selectedProfile.name,
|
|
4401
|
+
managedRemoteTunnelHostname: hostname,
|
|
4402
|
+
managedRemoteTunnelToken: token,
|
|
4403
|
+
};
|
|
4404
|
+
const { response: presetResponse, body: presetBody } = await requestJson(instance.port, '/api/vinci/tunnel/managed-remote-token', {
|
|
4405
|
+
method: 'PUT',
|
|
4406
|
+
body: JSON.stringify(tokenSyncPayload),
|
|
4407
|
+
});
|
|
4408
|
+
if (!presetResponse.ok || !presetBody?.ok) {
|
|
4409
|
+
throw new Error(presetBody?.error || `Failed to sync tunnel profile token (${presetResponse.status})`);
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
4412
|
+
|
|
4413
|
+
const payload = {
|
|
4414
|
+
provider,
|
|
4415
|
+
mode,
|
|
4416
|
+
...(typeof connectTtlMs === 'number' ? { connectTtlMs } : {}),
|
|
4417
|
+
...(typeof sessionTtlMs === 'number' ? { sessionTtlMs } : {}),
|
|
4418
|
+
...(options.configPath === null ? { configPath: null } : {}),
|
|
4419
|
+
...(typeof options.configPath === 'string' ? { configPath: options.configPath } : {}),
|
|
4420
|
+
...(typeof token === 'string' ? { token } : {}),
|
|
4421
|
+
...(typeof hostname === 'string' ? { hostname } : {}),
|
|
4422
|
+
...(selectedProfile ? {
|
|
4423
|
+
managedRemoteTunnelPresetId: selectedProfile.id,
|
|
4424
|
+
managedRemoteTunnelPresetName: selectedProfile.name,
|
|
4425
|
+
} : {}),
|
|
4426
|
+
};
|
|
4427
|
+
|
|
4428
|
+
const spin = createSpinner(options);
|
|
4429
|
+
spin?.start(`Starting ${clackFormatProviderWithIcon(provider)}/${mode} tunnel...`);
|
|
4430
|
+
|
|
4431
|
+
let response;
|
|
4432
|
+
let body;
|
|
4433
|
+
try {
|
|
4434
|
+
({ response, body } = await requestJson(instance.port, '/api/vinci/tunnel/start', {
|
|
4435
|
+
method: 'POST',
|
|
4436
|
+
body: JSON.stringify(payload),
|
|
4437
|
+
timeoutMs: 60000,
|
|
4438
|
+
}));
|
|
4439
|
+
} catch (error) {
|
|
4440
|
+
if (error instanceof Error && /\/api\/vinci\/tunnel\/start/.test(error.message) && /timed out/.test(error.message)) {
|
|
4441
|
+
spin?.error('Tunnel start timed out');
|
|
4442
|
+
throw new Error(
|
|
4443
|
+
`Tunnel start timed out after 60s. cloudflared may still be starting; check with \`vinci tunnel status --port ${instance.port}\`. Run \`vinci logs -p ${instance.port}\` for details.`
|
|
4444
|
+
);
|
|
4445
|
+
}
|
|
4446
|
+
spin?.error('Tunnel start failed');
|
|
4447
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4448
|
+
throw new Error(`${message} Run \`vinci logs -p ${instance.port}\` for details.`);
|
|
4449
|
+
}
|
|
4450
|
+
|
|
4451
|
+
if (!response.ok || !body?.ok) {
|
|
4452
|
+
spin?.error('Tunnel start failed');
|
|
4453
|
+
const baseError = body?.error || `Tunnel start failed (${response.status})`;
|
|
4454
|
+
const isCloudflareTimeout = /context deadline exceeded|Client\.Timeout exceeded while awaiting headers|failed to request quick Tunnel/i.test(baseError);
|
|
4455
|
+
const userError = isCloudflareTimeout
|
|
4456
|
+
? `Cloudflare quick tunnel request timed out. ${baseError}`
|
|
4457
|
+
: baseError;
|
|
4458
|
+
throw new Error(`${userError} Run \`vinci logs -p ${instance.port}\` for details.`);
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4461
|
+
// Avoid duplicate "Tunnel started" lines: spinner completion is implied by
|
|
4462
|
+
// the subsequent structured success section.
|
|
4463
|
+
spin?.clear();
|
|
4464
|
+
|
|
4465
|
+
const replayCommand = buildTunnelStartReplayCommand({
|
|
4466
|
+
port: instance.port,
|
|
4467
|
+
provider,
|
|
4468
|
+
mode,
|
|
4469
|
+
profileName: selectedProfile?.name,
|
|
4470
|
+
configPath: options.configPath,
|
|
4471
|
+
hostname,
|
|
4472
|
+
connectTtlMs,
|
|
4473
|
+
sessionTtlMs,
|
|
4474
|
+
qr: options.qr === true,
|
|
4475
|
+
noQr: options.noQr === true,
|
|
4476
|
+
includeTokenPlaceholder: !selectedProfile && mode === 'managed-remote' && typeof token === 'string' && token.trim().length > 0,
|
|
4477
|
+
tokenViaStdin: options.tokenStdin === true,
|
|
4478
|
+
tokenFileProvided: typeof options.tokenFile === 'string' && options.tokenFile.trim().length > 0,
|
|
4479
|
+
});
|
|
4480
|
+
|
|
4481
|
+
if (isJsonMode(options)) {
|
|
4482
|
+
printJson({ port: instance.port, replayCommand, ...body });
|
|
4483
|
+
} else if (isQuietMode(options)) {
|
|
4484
|
+
const quietUrl = body.connectUrl || body.url || 'n/a';
|
|
4485
|
+
process.stdout.write(`port ${instance.port} ${quietUrl}\n`);
|
|
4486
|
+
} else {
|
|
4487
|
+
console.log('');
|
|
4488
|
+
clackIntro(boldText('Tunnel Started'));
|
|
4489
|
+
logStatus('success', `port ${instance.port} ${clackFormatProviderWithIcon(body.provider)}/${body.mode}`);
|
|
4490
|
+
logStatus('success', body.connectUrl || body.url || 'n/a');
|
|
4491
|
+
if (body.replacedTunnel) {
|
|
4492
|
+
const revokedBootstrapCount = Number.isFinite(body.revokedBootstrapCount) ? body.revokedBootstrapCount : 0;
|
|
4493
|
+
const invalidatedSessionCount = Number.isFinite(body.invalidatedSessionCount) ? body.invalidatedSessionCount : 0;
|
|
4494
|
+
const previousMode = typeof body?.replaced?.mode === 'string' ? body.replaced.mode : 'unknown';
|
|
4495
|
+
logStatus(
|
|
4496
|
+
'warning',
|
|
4497
|
+
`replaced previous ${previousMode} tunnel`,
|
|
4498
|
+
`revoked ${revokedBootstrapCount}, invalidated ${invalidatedSessionCount}`,
|
|
4499
|
+
);
|
|
4500
|
+
}
|
|
4501
|
+
clackOutro('');
|
|
4502
|
+
|
|
4503
|
+
const optionalTips = [
|
|
4504
|
+
{ line: 'Check status', detail: 'vinci tunnel status' },
|
|
4505
|
+
{ line: 'Stop tunnel', detail: 'vinci tunnel stop' },
|
|
4506
|
+
{ line: 'If needed, repeat with same settings', detail: replayCommand },
|
|
4507
|
+
];
|
|
4508
|
+
|
|
4509
|
+
if (!selectedProfile && mode === 'managed-remote' && typeof hostname === 'string' && hostname.trim().length > 0) {
|
|
4510
|
+
const profileSaveCommand = buildTunnelProfileAddCommand({ provider, hostname });
|
|
4511
|
+
optionalTips.push({ line: 'Optional: save reusable profile (stores hostname + token locally)', detail: profileSaveCommand });
|
|
4512
|
+
optionalTips.push({ line: 'Start from saved profile', detail: 'vinci tunnel start --profile <name>' });
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4515
|
+
console.log('');
|
|
4516
|
+
clackIntro('Optional Tips');
|
|
4517
|
+
for (const tip of optionalTips) {
|
|
4518
|
+
logStatus('info', tip.line, tip.detail);
|
|
4519
|
+
}
|
|
4520
|
+
clackOutro('');
|
|
4521
|
+
}
|
|
4522
|
+
|
|
4523
|
+
setCancelCleanup(null);
|
|
4524
|
+
|
|
4525
|
+
if (shouldDisplayTunnelQr(options)) {
|
|
4526
|
+
const url = body.connectUrl || body.url;
|
|
4527
|
+
if (typeof url === 'string' && url.length > 0) {
|
|
4528
|
+
await displayTunnelQrCode(url);
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
return;
|
|
4532
|
+
}
|
|
4533
|
+
case 'stop': {
|
|
4534
|
+
let entries;
|
|
4535
|
+
if (options.all) {
|
|
4536
|
+
entries = await resolveTargetInstance({ options, allowAutoStart: false, requireAll: true });
|
|
4537
|
+
if (entries.length > 1 && !options.force && canPrompt(options)) {
|
|
4538
|
+
const shouldStop = await clackConfirm({
|
|
4539
|
+
message: `Stop tunnels on all ${entries.length} instances?`,
|
|
4540
|
+
});
|
|
4541
|
+
if (clackIsCancel(shouldStop) || !shouldStop) {
|
|
4542
|
+
clackCancel('Tunnel stop cancelled.');
|
|
4543
|
+
return;
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
} else {
|
|
4547
|
+
entries = [await resolveTargetInstance({ options, allowAutoStart: false })];
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
const results = [];
|
|
4551
|
+
for (const entry of entries) {
|
|
4552
|
+
const tunnelStopSpin = shouldRenderHumanOutput(options) ? createSpinner(options) : null;
|
|
4553
|
+
tunnelStopSpin?.start(`Stopping tunnel on port ${entry.port}...`);
|
|
4554
|
+
try {
|
|
4555
|
+
const { response, body } = await requestJson(entry.port, '/api/vinci/tunnel/stop', {
|
|
4556
|
+
method: 'POST',
|
|
4557
|
+
});
|
|
4558
|
+
if (!response.ok) {
|
|
4559
|
+
tunnelStopSpin?.error(`Failed to stop tunnel on port ${entry.port}`);
|
|
4560
|
+
results.push({ port: entry.port, error: body?.error || `stop ${response.status}` });
|
|
4561
|
+
continue;
|
|
4562
|
+
}
|
|
4563
|
+
tunnelStopSpin?.stop(`Stopped tunnel on port ${entry.port}`);
|
|
4564
|
+
results.push({ port: entry.port, result: body });
|
|
4565
|
+
} catch (error) {
|
|
4566
|
+
tunnelStopSpin?.error(`Failed to stop tunnel on port ${entry.port}`);
|
|
4567
|
+
results.push({ port: entry.port, error: error instanceof Error ? error.message : String(error) });
|
|
4568
|
+
}
|
|
4569
|
+
}
|
|
4570
|
+
|
|
4571
|
+
if (isJsonMode(options)) {
|
|
4572
|
+
printJson({ instances: results });
|
|
4573
|
+
return;
|
|
4574
|
+
}
|
|
4575
|
+
|
|
4576
|
+
if (isQuietMode(options)) {
|
|
4577
|
+
for (const result of results) {
|
|
4578
|
+
if (result.error) {
|
|
4579
|
+
process.stderr.write(`port ${result.port} failed: ${result.error}\n`);
|
|
4580
|
+
continue;
|
|
4581
|
+
}
|
|
4582
|
+
process.stdout.write(`port ${result.port} stopped\n`);
|
|
4583
|
+
}
|
|
4584
|
+
return;
|
|
4585
|
+
}
|
|
4586
|
+
|
|
4587
|
+
clackIntro('Tunnel Stop');
|
|
4588
|
+
for (const result of results) {
|
|
4589
|
+
if (result.error) {
|
|
4590
|
+
logStatus('error', `port ${result.port} failed`, result.error);
|
|
4591
|
+
continue;
|
|
4592
|
+
}
|
|
4593
|
+
logStatus('success', `port ${result.port} stopped`, `revoked ${result.result?.revokedBootstrapCount || 0}, invalidated ${result.result?.invalidatedSessionCount || 0}`);
|
|
4594
|
+
}
|
|
4595
|
+
clackOutro(`${results.length} instance(s)`);
|
|
4596
|
+
return;
|
|
4597
|
+
}
|
|
4598
|
+
case 'completion': {
|
|
4599
|
+
const shell = action || 'bash';
|
|
4600
|
+
const completionScript = generateCompletionScript(shell);
|
|
4601
|
+
if (!completionScript) {
|
|
4602
|
+
throw new TunnelCliError(
|
|
4603
|
+
`Unsupported shell '${shell}'. Supported: bash, zsh, fish.`,
|
|
4604
|
+
EXIT_CODE.USAGE_ERROR
|
|
4605
|
+
);
|
|
4606
|
+
}
|
|
4607
|
+
process.stdout.write(completionScript);
|
|
4608
|
+
return;
|
|
4609
|
+
}
|
|
4610
|
+
default: {
|
|
4611
|
+
const knownTunnelSubcommands = ['help', 'providers', 'ready', 'doctor', 'status', 'start', 'stop', 'profile', 'completion'];
|
|
4612
|
+
const suggestion = findClosestMatch(subcommand, knownTunnelSubcommands);
|
|
4613
|
+
const hint = suggestion ? ` Did you mean '${suggestion}'?` : '';
|
|
4614
|
+
throw new TunnelCliError(
|
|
4615
|
+
`Unknown tunnel subcommand '${subcommand}'.${hint} Use 'vinci tunnel help'.`,
|
|
4616
|
+
EXIT_CODE.USAGE_ERROR
|
|
4617
|
+
);
|
|
4618
|
+
}
|
|
4619
|
+
}
|
|
4620
|
+
},
|
|
4621
|
+
|
|
4622
|
+
async logs(options) {
|
|
4623
|
+
const showFrames = shouldRenderHumanOutput(options);
|
|
4624
|
+
const shouldPrefixLines = options.all || !showFrames;
|
|
4625
|
+
let targets = [];
|
|
4626
|
+
const running = await discoverRunningInstances();
|
|
4627
|
+
|
|
4628
|
+
if (options.all) {
|
|
4629
|
+
targets = running;
|
|
4630
|
+
if (targets.length === 0) {
|
|
4631
|
+
throw new Error('No running Vinci instance found.');
|
|
4632
|
+
}
|
|
4633
|
+
} else if (options.explicitPort) {
|
|
4634
|
+
const found = running.find((entry) => entry.port === options.port);
|
|
4635
|
+
if (!found) {
|
|
4636
|
+
throw new Error(`No running Vinci instance found on port ${options.port}.`);
|
|
4637
|
+
}
|
|
4638
|
+
targets = [found];
|
|
4639
|
+
} else {
|
|
4640
|
+
const latest = getLatestInstance(running);
|
|
4641
|
+
if (!latest) {
|
|
4642
|
+
throw new Error('No running Vinci instance found.');
|
|
4643
|
+
}
|
|
4644
|
+
targets = [latest];
|
|
4645
|
+
if (shouldRenderHumanOutput(options)) {
|
|
4646
|
+
logStatus('info', `no port specified; using latest started instance on port ${latest.port}`);
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
|
|
4650
|
+
if (isJsonMode(options)) {
|
|
4651
|
+
if (options.follow) {
|
|
4652
|
+
throw new Error('`vinci logs --json` requires `--no-follow` for deterministic JSON output.');
|
|
4653
|
+
}
|
|
4654
|
+
const entries = targets.map((target) => {
|
|
4655
|
+
const logPath = getLogFilePath(target.port);
|
|
4656
|
+
return {
|
|
4657
|
+
port: target.port,
|
|
4658
|
+
logPath,
|
|
4659
|
+
lines: readTailLines(logPath, options.lines),
|
|
4660
|
+
};
|
|
4661
|
+
});
|
|
4662
|
+
printJson({ entries });
|
|
4663
|
+
return;
|
|
4664
|
+
}
|
|
4665
|
+
|
|
4666
|
+
if (showFrames) {
|
|
4667
|
+
clackIntro('Vinci Logs');
|
|
4668
|
+
}
|
|
4669
|
+
|
|
4670
|
+
for (const target of targets) {
|
|
4671
|
+
const logPath = getLogFilePath(target.port);
|
|
4672
|
+
const lines = readTailLines(logPath, options.lines);
|
|
4673
|
+
if (showFrames) {
|
|
4674
|
+
logStatus('info', `port ${target.port}`, logPath);
|
|
4675
|
+
}
|
|
4676
|
+
|
|
4677
|
+
for (const line of lines) {
|
|
4678
|
+
if (shouldPrefixLines) {
|
|
4679
|
+
console.log(`[${target.port}] ${line}`);
|
|
4680
|
+
} else {
|
|
4681
|
+
console.log(line);
|
|
4682
|
+
}
|
|
4683
|
+
}
|
|
4684
|
+
}
|
|
4685
|
+
|
|
4686
|
+
if (showFrames) {
|
|
4687
|
+
clackOutro(options.follow ? 'following (Ctrl+C to stop)' : 'tail complete');
|
|
4688
|
+
}
|
|
4689
|
+
|
|
4690
|
+
if (!options.follow) {
|
|
4691
|
+
return;
|
|
4692
|
+
}
|
|
4693
|
+
|
|
4694
|
+
const unsubs = targets.map((target) => {
|
|
4695
|
+
const logPath = getLogFilePath(target.port);
|
|
4696
|
+
return followFile(logPath, (line) => {
|
|
4697
|
+
if (shouldPrefixLines) {
|
|
4698
|
+
console.log(`[${target.port}] ${line}`);
|
|
4699
|
+
} else {
|
|
4700
|
+
console.log(line);
|
|
4701
|
+
}
|
|
4702
|
+
});
|
|
4703
|
+
});
|
|
4704
|
+
|
|
4705
|
+
await new Promise((resolve) => {
|
|
4706
|
+
const onSignal = () => {
|
|
4707
|
+
for (const unsub of unsubs) {
|
|
4708
|
+
unsub();
|
|
4709
|
+
}
|
|
4710
|
+
process.off('SIGINT', onSignal);
|
|
4711
|
+
process.off('SIGTERM', onSignal);
|
|
4712
|
+
resolve();
|
|
4713
|
+
};
|
|
4714
|
+
process.on('SIGINT', onSignal);
|
|
4715
|
+
process.on('SIGTERM', onSignal);
|
|
4716
|
+
});
|
|
4717
|
+
},
|
|
4718
|
+
};
|
|
4719
|
+
|
|
4720
|
+
async function main() {
|
|
4721
|
+
const parsed = parseArgs();
|
|
4722
|
+
const { command, subcommand, tunnelAction, options, removedFlagErrors, helpRequested, versionRequested } = parsed;
|
|
4723
|
+
activeCommandOptions = options;
|
|
4724
|
+
|
|
4725
|
+
if (versionRequested) {
|
|
4726
|
+
if (isJsonMode(options)) {
|
|
4727
|
+
printJson({ version: PACKAGE_JSON.version });
|
|
4728
|
+
} else {
|
|
4729
|
+
console.log(PACKAGE_JSON.version);
|
|
4730
|
+
}
|
|
4731
|
+
return;
|
|
4732
|
+
}
|
|
4733
|
+
|
|
4734
|
+
if (removedFlagErrors.length > 0) {
|
|
4735
|
+
if (isJsonMode(options)) {
|
|
4736
|
+
printJson({
|
|
4737
|
+
status: 'error',
|
|
4738
|
+
error: {
|
|
4739
|
+
message: removedFlagErrors[0],
|
|
4740
|
+
details: removedFlagErrors,
|
|
4741
|
+
},
|
|
4742
|
+
});
|
|
4743
|
+
} else {
|
|
4744
|
+
for (const error of removedFlagErrors) {
|
|
4745
|
+
console.error(`Error: ${error}`);
|
|
4746
|
+
}
|
|
4747
|
+
}
|
|
4748
|
+
process.exit(1);
|
|
4749
|
+
}
|
|
4750
|
+
|
|
4751
|
+
if (helpRequested) {
|
|
4752
|
+
if (command === 'tunnel') {
|
|
4753
|
+
showTunnelHelp();
|
|
4754
|
+
} else {
|
|
4755
|
+
showHelp();
|
|
4756
|
+
}
|
|
4757
|
+
return;
|
|
4758
|
+
}
|
|
4759
|
+
|
|
4760
|
+
if (command === 'tunnel') {
|
|
4761
|
+
await commands.tunnel(options, subcommand, tunnelAction);
|
|
4762
|
+
return;
|
|
4763
|
+
}
|
|
4764
|
+
|
|
4765
|
+
if (!commands[command]) {
|
|
4766
|
+
const knownCommands = ['serve', 'stop', 'restart', 'status', 'tunnel', 'logs'];
|
|
4767
|
+
const suggestion = findClosestMatch(command, knownCommands);
|
|
4768
|
+
const hint = suggestion ? ` Did you mean '${suggestion}'?` : '';
|
|
4769
|
+
if (isJsonMode(options)) {
|
|
4770
|
+
printJson({
|
|
4771
|
+
status: 'error',
|
|
4772
|
+
error: {
|
|
4773
|
+
message: `Unknown command '${command}'.${hint}`,
|
|
4774
|
+
},
|
|
4775
|
+
messages: [{ level: 'info', code: 'USAGE_HELP', message: 'Use --help to see available commands' }],
|
|
4776
|
+
});
|
|
4777
|
+
} else {
|
|
4778
|
+
console.error(`Error: Unknown command '${command}'.${hint}`);
|
|
4779
|
+
console.error('Use --help to see available commands');
|
|
4780
|
+
}
|
|
4781
|
+
process.exit(EXIT_CODE.USAGE_ERROR);
|
|
4782
|
+
}
|
|
4783
|
+
|
|
4784
|
+
await commands[command](options);
|
|
4785
|
+
}
|
|
4786
|
+
|
|
4787
|
+
const isCliExecution = isModuleCliExecution(process.argv[1], import.meta.url, fs.realpathSync, 'vinci');
|
|
4788
|
+
|
|
4789
|
+
if (isCliExecution) {
|
|
4790
|
+
let isHandlingSigint = false;
|
|
4791
|
+
process.on('SIGINT', () => {
|
|
4792
|
+
if (isHandlingSigint) {
|
|
4793
|
+
return;
|
|
4794
|
+
}
|
|
4795
|
+
if (foregroundServerActive) {
|
|
4796
|
+
if (typeof foregroundShutdown === 'function') {
|
|
4797
|
+
void foregroundShutdown('SIGINT');
|
|
4798
|
+
}
|
|
4799
|
+
return;
|
|
4800
|
+
}
|
|
4801
|
+
isHandlingSigint = true;
|
|
4802
|
+
(async () => {
|
|
4803
|
+
clackCancel('Operation cancelled.');
|
|
4804
|
+
if (onCancelCleanup) {
|
|
4805
|
+
try {
|
|
4806
|
+
await onCancelCleanup();
|
|
4807
|
+
} catch {
|
|
4808
|
+
} finally {
|
|
4809
|
+
setCancelCleanup(null);
|
|
4810
|
+
}
|
|
4811
|
+
}
|
|
4812
|
+
process.exit(130);
|
|
4813
|
+
})();
|
|
4814
|
+
});
|
|
4815
|
+
|
|
4816
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
4817
|
+
if (isJsonMode(activeCommandOptions)) {
|
|
4818
|
+
printJson({
|
|
4819
|
+
status: 'error',
|
|
4820
|
+
error: {
|
|
4821
|
+
message: `Unhandled rejection: ${String(reason)}`,
|
|
4822
|
+
},
|
|
4823
|
+
});
|
|
4824
|
+
} else {
|
|
4825
|
+
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
4826
|
+
}
|
|
4827
|
+
process.exit(1);
|
|
4828
|
+
});
|
|
4829
|
+
|
|
4830
|
+
process.on('uncaughtException', (error) => {
|
|
4831
|
+
if (isJsonMode(activeCommandOptions)) {
|
|
4832
|
+
printJson({
|
|
4833
|
+
status: 'error',
|
|
4834
|
+
error: {
|
|
4835
|
+
message: `Uncaught exception: ${error instanceof Error ? error.message : String(error)}`,
|
|
4836
|
+
},
|
|
4837
|
+
});
|
|
4838
|
+
} else {
|
|
4839
|
+
console.error('Uncaught Exception:', error);
|
|
4840
|
+
}
|
|
4841
|
+
process.exit(1);
|
|
4842
|
+
});
|
|
4843
|
+
|
|
4844
|
+
main().catch((error) => {
|
|
4845
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4846
|
+
if (isJsonMode(activeCommandOptions)) {
|
|
4847
|
+
printJson({
|
|
4848
|
+
status: 'error',
|
|
4849
|
+
error: {
|
|
4850
|
+
message,
|
|
4851
|
+
},
|
|
4852
|
+
});
|
|
4853
|
+
} else if (process.stdout?.isTTY && !HAS_PLAIN_FLAG) {
|
|
4854
|
+
clackIntro(boldText('Error'));
|
|
4855
|
+
logStatus('error', message);
|
|
4856
|
+
clackOutro('failed');
|
|
4857
|
+
} else {
|
|
4858
|
+
console.error(`Error: ${message}`);
|
|
4859
|
+
}
|
|
4860
|
+
const exitCode = error instanceof TunnelCliError ? error.exitCode : EXIT_CODE.GENERAL_ERROR;
|
|
4861
|
+
process.exit(exitCode);
|
|
4862
|
+
});
|
|
4863
|
+
}
|
|
4864
|
+
|
|
4865
|
+
export {
|
|
4866
|
+
commands,
|
|
4867
|
+
parseArgs,
|
|
4868
|
+
hasUiPasswordConfigured,
|
|
4869
|
+
shouldDisplayTunnelQr,
|
|
4870
|
+
isValidTunnelDoctorResponse,
|
|
4871
|
+
readDesktopLocalPortFromSettings,
|
|
4872
|
+
getPidFilePath,
|
|
4873
|
+
resolveTunnelProviders,
|
|
4874
|
+
fetchTunnelProvidersFromPort,
|
|
4875
|
+
fetchSystemInfoFromPort,
|
|
4876
|
+
discoverRunningInstances,
|
|
4877
|
+
ensureTunnelProfilesMigrated,
|
|
4878
|
+
resolveToken,
|
|
4879
|
+
redactProfileForOutput,
|
|
4880
|
+
redactProfilesForOutput,
|
|
4881
|
+
maskToken,
|
|
4882
|
+
findClosestMatch,
|
|
4883
|
+
generateCompletionScript,
|
|
4884
|
+
TunnelCliError,
|
|
4885
|
+
EXIT_CODE,
|
|
4886
|
+
warnIfUnsafeFilePermissions,
|
|
4887
|
+
};
|