Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 152 additions & 29 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
assertSupportedNode();

Expand All @@ -28,7 +41,7 @@ async function main(argv: string[]): Promise<void> {
switch (command) {
case "serve":
await ensureConfigured();
await serve();
await serve(args);
return;
case "init":
await runInit({ force: args.includes("--force") });
Expand Down Expand Up @@ -105,30 +118,66 @@ async function runInit({ force }: { force: boolean }): Promise<void> {
});
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://<random>.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(),
Expand All @@ -142,6 +191,9 @@ async function runInit({ force }: { force: boolean }): Promise<void> {
`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(
Expand All @@ -152,7 +204,11 @@ async function runInit({ force }: { force: boolean }): Promise<void> {
].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");
Expand All @@ -162,7 +218,7 @@ async function runInit({ force }: { force: boolean }): Promise<void> {
}
}

async function serve(): Promise<void> {
async function serve(args: string[] = []): Promise<void> {
const sqliteStatus = checkSqliteNative();
if (sqliteStatus !== "ok") {
throw new Error(
Expand All @@ -176,26 +232,80 @@ async function serve(): Promise<void> {
);
}

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<void> {
Expand Down Expand Up @@ -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 <url|null>",
"",
"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"),
);
Expand All @@ -289,6 +406,12 @@ type TextPromptOptions = Omit<Parameters<typeof prompts.text>[0], "validate"> &
validate?: (value: string | undefined) => string | Error | undefined;
};

async function selectPrompt<T>(options: Parameters<typeof prompts.select<T>>[0]): Promise<T> {
const result = await prompts.select<T>(options);
if (prompts.isCancel(result)) throw new SetupCancelledError();
return result as T;
}

async function textPrompt(options: TextPromptOptions): Promise<string> {
const result = await prompts.text({
...options,
Expand Down
Loading