From f57ca42145526d5b2b2bb1e73df54b06231e641e Mon Sep 17 00:00:00 2001 From: prestonlogan <242876076+prestonlogan@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:40:44 -0400 Subject: [PATCH] feat(serve): automatic Cloudflare quick tunnel + styled startup UI DevSpace currently requires the user to stand up their own public tunnel or reverse proxy and paste the resulting URL during `devspace init`. This adds an opt-in "Automatic Cloudflare quick tunnel" mode so `devspace serve` can expose itself publicly on its own, plus a clack-styled startup summary that matches the existing setup screens. What's new: - src/cloudflare-tunnel.ts: locate `cloudflared` (PATH, then ~/.devspace/bin), auto-install the official release when missing, start `cloudflared tunnel --url`, and scrape the https://*.trycloudflare.com URL from its output. Honors CLOUDFLARED_BIN; all process spawns use shell:false. - `devspace init` now asks how the host should be reached: automatic Cloudflare tunnel (persists `tunnel: "cloudflare"`) or a manual public URL (unchanged). - `devspace serve` starts the tunnel when enabled, sets DEVSPACE_PUBLIC_BASE_URL so the tunnel hostname is added to the Host allowlist, prints a styled summary box, and tears the tunnel down on SIGINT/SIGTERM/exit. - Per-run overrides: `--tunnel` / `--no-tunnel` flags and DEVSPACE_TUNNEL env. - Help text and config type (`DevspaceUserConfig.tunnel`) updated. Security note: the cloudflared binary is downloaded over HTTPS from the official cloudflare/cloudflared releases and smoke-tested with `--version`, but is not checksum-pinned (trust-on-first-use, same model as running it from PATH). Happy to add SHA-256 verification if preferred. Verified: `npm run typecheck`, `npm run build`, and `npm test` all pass; tunnel start/URL-capture/teardown exercised end to end. --- src/cli.ts | 181 +++++++++++++++++++++++----- src/cloudflare-tunnel.ts | 249 +++++++++++++++++++++++++++++++++++++++ src/user-config.ts | 4 + 3 files changed, 405 insertions(+), 29 deletions(-) create mode 100644 src/cloudflare-tunnel.ts diff --git a/src/cli.ts b/src/cli.ts index 0e0147b..18a6dfa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,11 +14,24 @@ import { type DevspaceUserConfig, } from "./user-config.js"; import { expandHomePath } from "./roots.js"; +import { startQuickTunnel, type QuickTunnel } from "./cloudflare-tunnel.js"; type Command = "serve" | "init" | "doctor" | "config" | "help"; const require = createRequire(import.meta.url); const SUPPORTED_NODE_RANGE = ">=20.12 <27"; +const useColor = Boolean(output.isTTY) && !process.env.NO_COLOR; +const paint = (code: string) => (value: string) => + useColor ? `\x1b[${code}m${value}\x1b[0m` : String(value); +const c = { + bold: paint("1"), + dim: paint("2"), + cyan: paint("36"), + green: paint("32"), + yellow: paint("33"), + magenta: paint("35"), +}; + async function main(argv: string[]): Promise { assertSupportedNode(); @@ -28,7 +41,7 @@ async function main(argv: string[]): Promise { switch (command) { case "serve": await ensureConfigured(); - await serve(); + await serve(args); return; case "init": await runInit({ force: args.includes("--force") }); @@ -105,30 +118,66 @@ async function runInit({ force }: { force: boolean }): Promise { }); const port = Number(portAnswer); - prompts.note( - [ - "DevSpace needs a public base URL so ChatGPT or Claude can reach this MCP server.", - "Create a tunnel or reverse proxy with Cloudflare Tunnel, ngrok, Pinggy, Tailscale Funnel, or your own HTTPS proxy.", - "Paste the public origin here, without /mcp.", - "", - "Example: https://your-tunnel-host.example.com", - ].join("\n"), - "Public URL required", - ); - const publicBaseUrl = normalizePublicBaseUrl(await textPrompt({ - message: files.config.publicBaseUrl - ? `What is the public base URL? Press Enter to keep ${files.config.publicBaseUrl}` - : "What is the public base URL?", - placeholder: files.config.publicBaseUrl ?? "https://your-tunnel-host.example.com", - defaultValue: files.config.publicBaseUrl ?? "", - validate: validateRequiredPublicBaseUrl, - })); + const defaultTunnel = + files.config.tunnel === "cloudflare" || !files.config.publicBaseUrl ? "cloudflare" : "manual"; + const tunnelChoice = await selectPrompt<"cloudflare" | "manual">({ + message: "How should ChatGPT or Claude reach this MCP server?", + initialValue: defaultTunnel, + options: [ + { + value: "cloudflare", + label: "Automatic Cloudflare quick tunnel (recommended)", + hint: "devspace launches cloudflared and gets a fresh https URL each run", + }, + { + value: "manual", + label: "Manual public URL", + hint: "paste a URL from your own tunnel or reverse proxy", + }, + ], + }); + + let tunnel: DevspaceUserConfig["tunnel"]; + let publicBaseUrl: string | null = null; + if (tunnelChoice === "cloudflare") { + tunnel = "cloudflare"; + prompts.note( + [ + "DevSpace will install cloudflared (if needed) and open a Cloudflare", + "quick tunnel automatically every time you run `devspace serve`.", + "A new https://.trycloudflare.com URL is minted on each run.", + "", + "Override per run with: devspace serve --no-tunnel", + ].join("\n"), + "Automatic Cloudflare tunnel", + ); + } else { + prompts.note( + [ + "DevSpace needs a public base URL so ChatGPT or Claude can reach this MCP server.", + "Create a tunnel or reverse proxy with Cloudflare Tunnel, ngrok, Pinggy, Tailscale Funnel, or your own HTTPS proxy.", + "Paste the public origin here, without /mcp.", + "", + "Example: https://your-tunnel-host.example.com", + ].join("\n"), + "Public URL required", + ); + publicBaseUrl = normalizePublicBaseUrl(await textPrompt({ + message: files.config.publicBaseUrl + ? `What is the public base URL? Press Enter to keep ${files.config.publicBaseUrl}` + : "What is the public base URL?", + placeholder: files.config.publicBaseUrl ?? "https://your-tunnel-host.example.com", + defaultValue: files.config.publicBaseUrl ?? "", + validate: validateRequiredPublicBaseUrl, + })); + } const config: DevspaceUserConfig = { host: files.config.host ?? "127.0.0.1", port, allowedRoots, publicBaseUrl, + ...(tunnel ? { tunnel } : {}), }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), @@ -142,6 +191,9 @@ async function runInit({ force }: { force: boolean }): Promise { `Auth: ${authPath}`, `Local MCP URL: http://${config.host}:${config.port}/mcp`, ...(publicBaseUrl ? [`Public MCP URL: ${publicBaseUrl}/mcp`] : []), + ...(tunnel === "cloudflare" + ? ["Public MCP URL: printed by `devspace serve` once the Cloudflare tunnel opens"] + : []), ]; prompts.note(lines.join("\n"), "DevSpace configured"); prompts.note( @@ -152,7 +204,11 @@ async function runInit({ force }: { force: boolean }): Promise { ].join("\n"), "Owner password", ); - prompts.outro("Run `devspace serve` to start the MCP server."); + prompts.outro( + tunnel === "cloudflare" + ? "Run `devspace serve` to start the server and open the Cloudflare tunnel. It keeps running in the foreground (Ctrl+C to stop) — use a new terminal tab to do other work." + : "Run `devspace serve` to start the MCP server. It keeps running in the foreground (Ctrl+C to stop).", + ); } catch (error) { if (error instanceof SetupCancelledError) { prompts.cancel("Setup cancelled"); @@ -162,7 +218,7 @@ async function runInit({ force }: { force: boolean }): Promise { } } -async function serve(): Promise { +async function serve(args: string[] = []): Promise { const sqliteStatus = checkSqliteNative(); if (sqliteStatus !== "ok") { throw new Error( @@ -176,26 +232,80 @@ async function serve(): Promise { ); } + prompts.intro(c.bold(c.magenta("DevSpace"))); + + let tunnel: QuickTunnel | null = null; + if (shouldUseCloudflareTunnel(args)) { + const files = loadDevspaceFiles(); + const host = process.env.HOST ?? files.config.host ?? "127.0.0.1"; + const port = Number(process.env.PORT ?? files.config.port ?? 7676); + const tunnelHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host; + const localBaseUrl = `http://${tunnelHost}:${port}`; + const spin = prompts.spinner(); + spin.start("Opening Cloudflare quick tunnel"); + try { + tunnel = await startQuickTunnel(localBaseUrl, { quiet: true }); + // Make loadConfig() pick up the freshly minted public URL so the tunnel + // hostname is also added to the Host header allowlist. + process.env.DEVSPACE_PUBLIC_BASE_URL = tunnel.publicBaseUrl; + spin.stop(`Cloudflare tunnel ready ${c.cyan(tunnel.publicBaseUrl)}`); + } catch (error) { + spin.stop("Cloudflare tunnel failed to start"); + prompts.log.warn( + `${error instanceof Error ? error.message : String(error)}\nFalling back to the configured public base URL.`, + ); + } + } + const { createServer } = await import("./server.js"); const config = loadConfig(); const { app } = createServer(config); const httpServer = app.listen(config.port, config.host, () => { - console.log(`devspace listening on http://${config.host}:${config.port}/mcp`); - console.log(`public base url: ${config.publicBaseUrl}`); - console.log(`allowed roots: ${config.allowedRoots.join(", ")}`); - console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`); + const localUrl = `http://${config.host}:${config.port}/mcp`; + const publicUrl = `${config.publicBaseUrl}/mcp`; + const label = (text: string) => c.dim(text.padEnd(7)); + const noteLines = [ + `${label("Local")} ${localUrl}`, + `${label("Public")} ${c.cyan(publicUrl)}`, + `${label("Roots")} ${config.allowedRoots.join(", ")}`, + `${label("Hosts")} ${config.allowedHosts.join(", ")}`, + `${label("Auth")} Owner password approval required`, + `${label("Logs")} ${config.logging.level} ${config.logging.format}`, + ]; + prompts.note( + noteLines.join("\n"), + c.green(tunnel ? "Server running (Cloudflare tunnel live)" : "Server running"), + ); if (config.allowedHosts.includes("*")) { - console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); + prompts.log.warn("Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); } - console.log("auth: Owner password approval required"); - console.log(`logging: ${config.logging.level} ${config.logging.format}`); + prompts.outro( + c.dim( + "Press Ctrl+C to stop. Keep this terminal open while you use DevSpace — open a new tab for other work.", + ), + ); }); const shutdown = () => { + tunnel?.stop(); httpServer.close(() => process.exit(0)); }; process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); + process.once("exit", () => tunnel?.stop()); +} + +function shouldUseCloudflareTunnel(args: string[] = []): boolean { + if (args.includes("--no-tunnel")) return false; + if (args.includes("--tunnel") || args.includes("--tunnel=cloudflare")) return true; + + const envTunnel = process.env.DEVSPACE_TUNNEL?.trim().toLowerCase(); + if (envTunnel === "cloudflare" || envTunnel === "quick") return true; + if (envTunnel === "none" || envTunnel === "off") return false; + + const files = loadDevspaceFiles(); + const configured = String(files.config.tunnel ?? "").trim().toLowerCase(); + return configured === "cloudflare" || configured === "quick"; } async function runDoctor(): Promise { @@ -257,12 +367,19 @@ function printHelp(): void { "Usage:", " devspace Run first-time setup if needed, then start the server", " devspace serve Start the server", + " devspace serve --tunnel Start the server with an automatic Cloudflare quick tunnel", + " devspace serve --no-tunnel Start the server without the configured tunnel", " devspace init Create or update ~/.devspace/config.json and auth.json", " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", " devspace config set publicBaseUrl ", "", - "For temporary tunnels:", + "Automatic Cloudflare quick tunnel:", + " Choose it during `devspace init`, or force it per run:", + " DEVSPACE_TUNNEL=cloudflare devspace serve (or: devspace serve --tunnel)", + " cloudflared is auto-installed to ~/.devspace/bin when missing.", + "", + "For a fixed temporary tunnel URL:", " DEVSPACE_PUBLIC_BASE_URL=https://example.trycloudflare.com devspace serve", ].join("\n"), ); @@ -289,6 +406,12 @@ type TextPromptOptions = Omit[0], "validate"> & validate?: (value: string | undefined) => string | Error | undefined; }; +async function selectPrompt(options: Parameters>[0]): Promise { + const result = await prompts.select(options); + if (prompts.isCancel(result)) throw new SetupCancelledError(); + return result as T; +} + async function textPrompt(options: TextPromptOptions): Promise { const result = await prompts.text({ ...options, diff --git a/src/cloudflare-tunnel.ts b/src/cloudflare-tunnel.ts new file mode 100644 index 0000000..793a3e1 --- /dev/null +++ b/src/cloudflare-tunnel.ts @@ -0,0 +1,249 @@ +// Automatic Cloudflare quick-tunnel support for DevSpace. +// +// Locates a `cloudflared` binary (PATH, then a local install under +// ~/.devspace/bin), auto-installing the official release when missing, then +// starts a quick tunnel and scrapes the generated https://*.trycloudflare.com +// URL from cloudflared's output. This lets `devspace serve` expose itself +// publicly without the user having to run a separate tunnel command. +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +// When DevSpace drives the tunnel behind a clack spinner it sets quiet mode so +// our own progress lines don't fight the spinner animation. +let QUIET = false; +function status(message: string): void { + if (!QUIET) console.log(message); +} + +function cloudflaredBinName(): string { + return process.platform === "win32" ? "cloudflared.exe" : "cloudflared"; +} + +function devspaceHome(): string { + return process.env.DEVSPACE_CONFIG_DIR + ? process.env.DEVSPACE_CONFIG_DIR + : join(homedir(), ".devspace"); +} + +function localCloudflaredPath(): string { + return join(devspaceHome(), "bin", cloudflaredBinName()); +} + +interface ReleaseAsset { + file: string; + archive: boolean; +} + +function cloudflaredReleaseAsset(): ReleaseAsset { + const platform = process.platform; + const arch = process.arch; + if (platform === "darwin") { + if (arch === "arm64") return { file: "cloudflared-darwin-arm64.tgz", archive: true }; + if (arch === "x64") return { file: "cloudflared-darwin-amd64.tgz", archive: true }; + } + if (platform === "linux") { + if (arch === "arm64") return { file: "cloudflared-linux-arm64", archive: false }; + if (arch === "arm") return { file: "cloudflared-linux-arm", archive: false }; + if (arch === "x64") return { file: "cloudflared-linux-amd64", archive: false }; + if (arch === "ia32") return { file: "cloudflared-linux-386", archive: false }; + } + if (platform === "win32") { + if (arch === "x64") return { file: "cloudflared-windows-amd64.exe", archive: false }; + if (arch === "ia32") return { file: "cloudflared-windows-386.exe", archive: false }; + } + throw new Error( + `Automatic cloudflared install is not supported on ${platform}/${arch}. ` + + "Install cloudflared manually and set CLOUDFLARED_BIN, or use a manual public URL.", + ); +} + +function commandExists(command: string): boolean { + const probe = process.platform === "win32" ? "where" : "which"; + const result = spawnSync(probe, [command], { stdio: "ignore", shell: false }); + return result.status === 0; +} + +function verifyCloudflared(binaryPath: string): boolean { + const result = spawnSync(binaryPath, ["--version"], { + stdio: "ignore", + shell: false, + timeout: 15000, + }); + return result.status === 0; +} + +function findFileByName(root: string, fileName: string): string { + for (const entry of readdirSync(root, { withFileTypes: true })) { + const fullPath = join(root, entry.name); + if (entry.isFile() && entry.name === fileName) return fullPath; + if (entry.isDirectory()) { + const found = findFileByName(fullPath, fileName); + if (found) return found; + } + } + return ""; +} + +async function downloadFile(url: string, destination: string): Promise { + const response = await fetch(url, { headers: { "user-agent": "devspace-launcher" } }); + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + writeFileSync(destination, buffer, { mode: 0o755 }); +} + +async function installCloudflaredLocal(): Promise { + const asset = cloudflaredReleaseAsset(); + const installPath = localCloudflaredPath(); + const binDir = dirname(installPath); + const tmpRoot = mkdtempSync(join(tmpdir(), "devspace-cloudflared-")); + const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset.file}`; + + mkdirSync(binDir, { recursive: true, mode: 0o700 }); + status(`devspace: installing cloudflared locally at ${installPath}`); + status(`devspace: downloading official Cloudflare release ${asset.file}`); + + try { + if (asset.archive) { + const archivePath = join(tmpRoot, asset.file); + const extractDir = join(tmpRoot, "extract"); + mkdirSync(extractDir, { recursive: true }); + await downloadFile(url, archivePath); + const tar = spawnSync("tar", ["-xzf", archivePath, "-C", extractDir], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + shell: false, + }); + if (tar.status !== 0) { + throw new Error( + `Failed to extract ${asset.file}: ${tar.stderr || tar.stdout || `exit ${tar.status}`}`, + ); + } + const extracted = findFileByName(extractDir, "cloudflared"); + if (!extracted) throw new Error(`Could not find cloudflared inside ${asset.file}`); + copyFileSync(extracted, installPath); + } else { + const tmpBinary = join(tmpRoot, cloudflaredBinName()); + await downloadFile(url, tmpBinary); + copyFileSync(tmpBinary, installPath); + } + spawnSync("chmod", ["+x", installPath], { stdio: "ignore", shell: false }); + if (!verifyCloudflared(installPath)) { + throw new Error(`Downloaded cloudflared, but ${installPath} --version failed.`); + } + status("devspace: cloudflared installed successfully."); + return installPath; + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + } +} + +// Resolve a usable cloudflared binary, installing one when necessary. +export async function resolveCloudflared(): Promise { + const explicit = process.env.CLOUDFLARED_BIN?.trim(); + if (explicit) { + if (verifyCloudflared(explicit)) return explicit; + throw new Error(`CLOUDFLARED_BIN is set to ${explicit}, but it failed --version.`); + } + if (commandExists("cloudflared") && verifyCloudflared("cloudflared")) { + return "cloudflared"; + } + const localPath = localCloudflaredPath(); + if (existsSync(localPath) && verifyCloudflared(localPath)) { + return localPath; + } + return installCloudflaredLocal(); +} + +const TRYCLOUDFLARE_RE = /https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/g; + +function waitForCloudflareUrl(child: ChildProcess, timeoutMs = 45000): Promise { + let buffer = ""; + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Timed out waiting for cloudflared public URL.")), + timeoutMs, + ); + timer.unref?.(); + const cleanup = () => { + child.stdout?.off("data", onData); + child.stderr?.off("data", onData); + child.off("exit", onExit); + }; + const onData = (chunk: Buffer | string) => { + buffer += String(chunk); + const match = buffer.match(TRYCLOUDFLARE_RE); + if (match?.[0]) { + clearTimeout(timer); + cleanup(); + resolve(match[0]); + } + }; + const onExit = (code: number | null) => { + clearTimeout(timer); + cleanup(); + reject(new Error(`cloudflared exited before a URL was found (code=${code}).`)); + }; + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + child.on("exit", onExit); + }); +} + +export interface QuickTunnel { + publicBaseUrl: string; + child: ChildProcess; + stop: () => void; +} + +export interface StartQuickTunnelOptions { + quiet?: boolean; +} + +// Start a Cloudflare quick tunnel pointing at the given local origin. +export async function startQuickTunnel( + localBaseUrl: string, + options: StartQuickTunnelOptions = {}, +): Promise { + QUIET = options.quiet === true; + const cloudflaredPath = await resolveCloudflared(); + status("devspace: opening Cloudflare quick tunnel..."); + const child = spawn(cloudflaredPath, ["tunnel", "--url", localBaseUrl, "--no-autoupdate"], { + stdio: ["ignore", "pipe", "pipe"], + }); + child.on("error", (error) => { + console.error( + `devspace: cloudflared failed to start: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + const publicBaseUrl = await waitForCloudflareUrl(child); + const stop = () => { + if (child.killed) return; + try { + child.kill("SIGTERM"); + } catch { + // ignore + } + setTimeout(() => { + if (!child.killed) { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + } + }, 1500).unref?.(); + }; + return { publicBaseUrl, child, stop }; +} diff --git a/src/user-config.ts b/src/user-config.ts index 0b79c51..866e31e 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -9,6 +9,8 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; +export type TunnelMode = "cloudflare"; + export interface DevspaceUserConfig { host?: string; port?: number; @@ -18,6 +20,8 @@ export interface DevspaceUserConfig { stateDir?: string; worktreeRoot?: string; agentDir?: string; + /** When set, `devspace serve` opens a public tunnel automatically. */ + tunnel?: TunnelMode; } export interface DevspaceAuthConfig {