From 84d3179ffb0527c6335cb8bcce610df5e3521557 Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Wed, 24 Jun 2026 05:41:43 +0000 Subject: [PATCH 01/13] feat(cli): add configuration management commands --- docs/configuration.md | 26 ++++- docs/setup.md | 2 +- package.json | 2 +- src/cli.test.ts | 61 ++++++++++-- src/cli.ts | 138 ++++++++++++++++++++----- src/config-operations.test.ts | 76 ++++++++++++++ src/config-operations.ts | 183 ++++++++++++++++++++++++++++++++++ src/config.ts | 13 ++- src/oauth-store.ts | 9 ++ 9 files changed, 473 insertions(+), 37 deletions(-) create mode 100644 src/config-operations.test.ts create mode 100644 src/config-operations.ts diff --git a/docs/configuration.md b/docs/configuration.md index 71073380..306b5854 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,10 +22,32 @@ DEVSPACE_CONFIG_DIR=/path/to/config npx @waishnav/devspace serve npx @waishnav/devspace init npx @waishnav/devspace serve npx @waishnav/devspace doctor -npx @waishnav/devspace config get -npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com + +# Show effective settings. Owner passwords are always masked. +npx @waishnav/devspace config show + +# Persist local server settings. +npx @waishnav/devspace config host 127.0.0.1 +npx @waishnav/devspace config port 7676 +npx @waishnav/devspace config domain devspace.example.com + +# Rotate the Owner password and revoke persisted OAuth clients and tokens. +npx @waishnav/devspace config key ``` +`config host`, `config port`, and `config domain` persist changes in +`~/.devspace/config.json`. Restart DevSpace after changing them. `config domain` +accepts a bare domain or an `http`/`https` URL; it also accepts a trailing +`/mcp` and stores the corresponding origin. + +`config key` prints the new Owner password once, stores it in `auth.json`, and +clears persisted OAuth clients and tokens. Restart DevSpace before using the new +password. It cannot rotate a password supplied through +`DEVSPACE_OAUTH_OWNER_TOKEN`; unset that environment variable first. + +For backward compatibility, `config get` prints the persisted JSON and +`config set publicBaseUrl ` remains available. + ## Core Environment Variables | Variable | Purpose | diff --git a/docs/setup.md b/docs/setup.md index e332f216..18bad1b4 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -91,7 +91,7 @@ DEVSPACE_PUBLIC_BASE_URL="https://new-tunnel.example.com" npx @waishnav/devspace For a stable public URL, persist it: ```bash -npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com +npx @waishnav/devspace config domain devspace.example.com npx @waishnav/devspace serve ``` diff --git a/package.json b/package.json index 7962ae98..492fa24c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/config-operations.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli.test.ts b/src/cli.test.ts index 96dfd55c..f2e0df2c 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,16 +1,65 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; }; for (const flag of ["-v", "--version"]) { - const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", flag], { - encoding: "utf8", - env: { ...process.env, DEVSPACE_CONFIG_DIR: "/tmp/devspace-cli-version-test" }, - }).trim(); - + const output = runCli([flag], { DEVSPACE_CONFIG_DIR: "/tmp/devspace-cli-version-test" }).trim(); assert.equal(output, packageJson.version); } + +const topLevelHelp = runCli(["--help"]); +assert.match(topLevelHelp, /^usage: devspace \[--version\] \[--help\] \[]$/m); +assert.match(topLevelHelp, /start and connect a local MCP server/); +assert.match(topLevelHelp, /manage persistent DevSpace settings/); +assert.match(topLevelHelp, /Use `devspace config` to view configuration commands\./); + +const configHelp = runCli(["config"]); +assert.match(configHelp, /^usage: devspace config \[]$/m); +assert.match(configHelp, /inspect effective settings/); +assert.match(configHelp, /change persistent server settings/); +assert.match(configHelp, /key\s+Rotate the Owner password and revoke saved OAuth sessions/); +assert.equal(runCli(["help", "config"]), configHelp); +assert.equal(runCli(["config", "--help"]), configHelp); + +const root = mkdtempSync(join(tmpdir(), "devspace-cli-config-test-")); +try { + const env = { + DEVSPACE_CONFIG_DIR: join(root, "config"), + DEVSPACE_STATE_DIR: join(root, "state"), + }; + + assert.match(runCli(["config", "host", "127.0.0.1"], env), /Updated local bind host/); + assert.match(runCli(["config", "port", "8787"], env), /Updated local bind port/); + assert.match(runCli(["config", "domain", "devspace.example.com/mcp"], env), /public base URL/); + + const shown = JSON.parse(runCli(["config", "show", "--json"], env)) as { + host: string; + port: number; + publicUrl: string; + accessKey: string; + }; + assert.equal(shown.host, "127.0.0.1"); + assert.equal(shown.port, 8787); + assert.equal(shown.publicUrl, "https://devspace.example.com/mcp"); + assert.equal(shown.accessKey, "(not configured)"); + + const keyOutput = runCli(["config", "key"], env); + assert.match(keyOutput, /Owner password rotated/); + assert.match(keyOutput, /New Owner password: /); + assert.match(runCli(["config", "show"], env), /Owner password: .{3}\*+/); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +function runCli(args: string[], overrides: NodeJS.ProcessEnv = {}): string { + return execFileSync("node", ["--import", "tsx", "src/cli.ts", ...args], { + encoding: "utf8", + env: { ...process.env, ...overrides }, + }); +} diff --git a/src/cli.ts b/src/cli.ts index 87ba662d..fe889c13 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,15 @@ import * as prompts from "@clack/prompts"; import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; import { loadConfig } from "./config.js"; +import { + buildConfigShowResult, + resetConfigKey, + setConfigDomain, + setConfigHost, + setConfigPort, + setConfigPublicBaseUrl, + type ConfigUpdateResult, +} from "./config-operations.js"; import { generateOwnerToken, loadDevspaceFiles, @@ -40,7 +49,11 @@ async function main(argv: string[]): Promise { runConfigCommand(args); return; case "help": - printHelp(); + if (args[0] === "config") { + printConfigHelp(); + } else { + printHelp(); + } return; case "version": printVersion(); @@ -232,11 +245,63 @@ function runConfigCommand(args: string[]): void { const [subcommand, key, ...rest] = args; const files = loadDevspaceFiles(); - if (!subcommand || subcommand === "get") { + if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") { + printConfigHelp(); + return; + } + + if (subcommand === "get") { console.log(JSON.stringify(files.config, null, 2)); return; } + if (subcommand === "show") { + const show = buildConfigShowResult(); + if (args.includes("--json")) { + console.log(JSON.stringify(show, null, 2)); + return; + } + + console.log([ + `bind host: ${show.host}`, + `port: ${show.port}`, + `public base URL: ${show.publicBaseUrl}`, + `public MCP URL: ${show.publicUrl}`, + `allowed hosts: ${show.allowedHosts.join(", ")}`, + `Owner password: ${show.accessKey}`, + `config file: ${show.configPath}`, + `auth file: ${show.authPath}`, + ].join("\n")); + return; + } + + if (subcommand === "port") { + printConfigUpdate(setConfigPort(key ?? "")); + return; + } + + if (subcommand === "host") { + printConfigUpdate(setConfigHost(key ?? "")); + return; + } + + if (subcommand === "domain") { + const value = [key, ...rest].join(" ").trim(); + printConfigUpdate(setConfigDomain(value)); + return; + } + + if (subcommand === "key") { + const result = resetConfigKey(); + console.log([ + "Owner password rotated. Existing OAuth clients and tokens were cleared.", + `New Owner password: ${result.ownerToken}`, + `Saved to: ${result.authPath}`, + "Restart DevSpace for the new Owner password to take effect.", + ].join("\n")); + return; + } + if (subcommand !== "set") { throw new Error(`Unknown config command: ${subcommand}`); } @@ -249,29 +314,59 @@ function runConfigCommand(args: string[]): void { throw new Error("Missing publicBaseUrl value."); } - writeDevspaceConfig({ - ...files.config, - publicBaseUrl: normalizeOptionalPublicBaseUrl(value), - }); - console.log(`Updated ${files.configPath}`); + printConfigUpdate(setConfigPublicBaseUrl(value)); +} + +function printConfigUpdate(result: ConfigUpdateResult): void { + console.log([result.warning, result.message].filter(Boolean).join("\n")); } function printHelp(): void { console.log( [ - "DevSpace", + "usage: devspace [--version] [--help] []", + "", + "These are common DevSpace commands used in various situations:", + "", + "start and connect a local MCP server", + " init Create or update local configuration", + " serve Start the MCP server", + " doctor Check configuration, runtime, and native dependencies", + "", + "manage persistent DevSpace settings", + " config Show configuration command help", "", - "Usage:", - " devspace Run first-time setup if needed, then start the server", - " devspace serve Start the server", - " 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 ", - " devspace -v, --version Print the installed version", + "get help and version information", + " help Show this help, or `devspace help config`", + " version Print the installed version", "", - "For temporary tunnels:", - " DEVSPACE_PUBLIC_BASE_URL=https://example.trycloudflare.com devspace serve", + "Use `devspace config` to view configuration commands.", + "Use `devspace serve` with DEVSPACE_PUBLIC_BASE_URL for a one-run tunnel override.", + ].join("\n"), + ); +} + +function printConfigHelp(): void { + console.log( + [ + "usage: devspace config []", + "", + "These are common DevSpace configuration commands:", + "", + "inspect effective settings", + " show [--json] Show effective server settings and masked Owner password", + " get Print persisted config JSON (legacy-compatible)", + "", + "change persistent server settings", + " host Set the local bind host", + " port Set the local bind port", + " domain Set the public origin; a trailing /mcp is accepted", + " key Rotate the Owner password and revoke saved OAuth sessions", + "", + "compatibility command", + " set publicBaseUrl Set or clear the persisted public base URL", + "", + "Configuration changes are saved locally. Restart DevSpace for them to take effect.", ].join("\n"), ); } @@ -285,13 +380,6 @@ function printVersion(): void { console.log(packageJson.version); } -function normalizeOptionalPublicBaseUrl(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed || trimmed === "null" || trimmed === "none") return null; - - return normalizePublicBaseUrl(trimmed); -} - function normalizePublicBaseUrl(value: string): string { const trimmed = value.trim(); const parsed = new URL(trimmed); diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts new file mode 100644 index 00000000..bba18622 --- /dev/null +++ b/src/config-operations.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildConfigShowResult, + resetConfigKey, + setConfigDomain, + setConfigHost, + setConfigPort, + setConfigPublicBaseUrl, +} from "./config-operations.js"; +import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js"; +import { loadDevspaceFiles, writeDevspaceAuth } from "./user-config.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-config-operations-test-")); +process.env.DEVSPACE_CONFIG_DIR = join(root, "config"); +process.env.DEVSPACE_STATE_DIR = join(root, "state"); + +try { + const initial = buildConfigShowResult(); + assert.equal(initial.host, "127.0.0.1"); + assert.equal(initial.port, 7676); + assert.equal(initial.publicUrl, "http://127.0.0.1:7676/mcp"); + assert.equal(initial.accessKey, "(not configured)"); + + assert.match(setConfigPort("8787").message, /8787/); + assert.equal(loadDevspaceFiles().config.port, 8787); + assert.throws(() => setConfigPort("0"), /between 1 and 65535/); + + const hostResult = setConfigHost("0.0.0.0"); + assert.equal(loadDevspaceFiles().config.host, "0.0.0.0"); + assert.match(hostResult.warning ?? "", /may expose DevSpace/); + assert.throws(() => setConfigHost("https://example.com"), /Invalid host/); + + const domainResult = setConfigDomain("devspace.example.com/mcp"); + assert.equal(loadDevspaceFiles().config.publicBaseUrl, "https://devspace.example.com"); + assert.equal(domainResult.warning, undefined); + assert.match(setConfigDomain("http://devspace.example.com").warning ?? "", /Prefer HTTPS/); + assert.throws(() => setConfigDomain("https://devspace.example.com/custom-mcp"), /origin/); + assert.throws(() => setConfigDomain("ftp://devspace.example.com"), /http or https/); + + setConfigPublicBaseUrl("none"); + assert.equal(loadDevspaceFiles().config.publicBaseUrl, null); + + writeDevspaceAuth({ ownerToken: "old-owner-token-that-is-long-enough" }); + const store = new SqliteOAuthStore(process.env.DEVSPACE_STATE_DIR); + const client = new SqliteOAuthClientsStore(store, ["chatgpt.com"]).registerClient({ + redirect_uris: ["https://chatgpt.com/connector_platform_oauth_redirect"], + }); + store.close(); + + const reset = resetConfigKey(); + assert.notEqual(reset.ownerToken, "old-owner-token-that-is-long-enough"); + assert.equal(loadDevspaceFiles().auth.ownerToken, reset.ownerToken); + + const clearedStore = new SqliteOAuthStore(process.env.DEVSPACE_STATE_DIR); + try { + assert.equal(clearedStore.getClient(client.client_id), undefined); + } finally { + clearedStore.close(); + } + + const shown = buildConfigShowResult(); + assert.notEqual(shown.accessKey, reset.ownerToken); + assert.match(shown.accessKey, /^.{3}\*+/); + + assert.throws( + () => resetConfigKey({ ...process.env, DEVSPACE_OAUTH_OWNER_TOKEN: "environment-owner-token" }), + /Cannot rotate the persisted Owner password/, + ); +} finally { + rmSync(root, { recursive: true, force: true }); + delete process.env.DEVSPACE_CONFIG_DIR; + delete process.env.DEVSPACE_STATE_DIR; +} diff --git a/src/config-operations.ts b/src/config-operations.ts new file mode 100644 index 00000000..b23c23ce --- /dev/null +++ b/src/config-operations.ts @@ -0,0 +1,183 @@ +import { isIP } from "node:net"; +import { loadServerSettings } from "./config.js"; +import { SqliteOAuthStore } from "./oauth-store.js"; +import { + generateOwnerToken, + loadDevspaceFiles, + writeDevspaceAuth, + writeDevspaceConfig, +} from "./user-config.js"; + +const MCP_PATH = "/mcp"; + +export interface ConfigShowResult { + host: string; + port: number; + publicBaseUrl: string; + publicUrl: string; + allowedHosts: string[]; + accessKey: string; + configPath: string; + authPath: string; +} + +export interface ConfigUpdateResult { + message: string; + warning?: string; +} + +export interface ConfigKeyResetResult { + ownerToken: string; + authPath: string; +} + +export function buildConfigShowResult(env: NodeJS.ProcessEnv = process.env): ConfigShowResult { + const settings = loadServerSettings(env); + const files = loadDevspaceFiles(env); + const ownerToken = env.DEVSPACE_OAUTH_OWNER_TOKEN?.trim() || files.auth.ownerToken; + + return { + host: settings.host, + port: settings.port, + publicBaseUrl: settings.publicBaseUrl, + publicUrl: new URL(MCP_PATH, settings.publicBaseUrl).toString(), + allowedHosts: settings.allowedHosts, + accessKey: maskSecret(ownerToken), + configPath: files.configPath, + authPath: files.authPath, + }; +} + +export function setConfigPort( + value: string | number, + env: NodeJS.ProcessEnv = process.env, +): ConfigUpdateResult { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Port must be an integer between 1 and 65535."); + } + + const files = loadDevspaceFiles(env); + writeDevspaceConfig({ ...files.config, port }, env); + return { + message: `Updated local bind port to ${port}. Restart DevSpace for the change to take effect.`, + }; +} + +export function setConfigHost(value: string, env: NodeJS.ProcessEnv = process.env): ConfigUpdateResult { + const host = validateHost(value); + const files = loadDevspaceFiles(env); + writeDevspaceConfig({ ...files.config, host }, env); + + return { + message: `Updated local bind host to ${host}. Restart DevSpace for the change to take effect.`, + warning: isPublicHost(host) + ? "Warning: this host may expose DevSpace beyond localhost. Ensure HTTPS, authentication, and firewall rules are configured." + : undefined, + }; +} + +export function setConfigDomain(value: string, env: NodeJS.ProcessEnv = process.env): ConfigUpdateResult { + const publicBaseUrl = normalizeDomainLikeInput(value); + const files = loadDevspaceFiles(env); + writeDevspaceConfig({ ...files.config, publicBaseUrl }, env); + + return { + message: `Updated public base URL to ${publicBaseUrl}. Restart DevSpace for the change to take effect.`, + warning: publicBaseUrl.startsWith("http://") + ? "Warning: public URL uses HTTP. Prefer HTTPS for remote MCP access." + : undefined, + }; +} + +export function setConfigPublicBaseUrl( + value: string, + env: NodeJS.ProcessEnv = process.env, +): ConfigUpdateResult { + const trimmed = value.trim(); + const files = loadDevspaceFiles(env); + + if (!trimmed || trimmed === "null" || trimmed === "none") { + writeDevspaceConfig({ ...files.config, publicBaseUrl: null }, env); + return { + message: "Cleared the persisted public base URL. Restart DevSpace for the change to take effect.", + }; + } + + return setConfigDomain(trimmed, env); +} + +export function resetConfigKey(env: NodeJS.ProcessEnv = process.env): ConfigKeyResetResult { + if (env.DEVSPACE_OAUTH_OWNER_TOKEN?.trim()) { + throw new Error( + "Cannot rotate the persisted Owner password while DEVSPACE_OAUTH_OWNER_TOKEN is set. Unset it first, then run `devspace config key` again.", + ); + } + + const ownerToken = generateOwnerToken(); + const authPath = writeDevspaceAuth({ ownerToken }, env); + const stateDir = loadServerSettings(env).stateDir; + const store = new SqliteOAuthStore(stateDir); + + try { + store.clearAll(); + } finally { + store.close(); + } + + return { ownerToken, authPath }; +} + +function validateHost(value: string): string { + const host = value.trim(); + if (!host) throw new Error("Host is required."); + if (host.includes("://") || host.includes("/") || /\s/.test(host)) { + throw new Error(`Invalid host: ${value}`); + } + if (isIP(host) !== 0 || host === "localhost") return host; + if (!/^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(host)) { + throw new Error(`Invalid host: ${value}`); + } + return host; +} + +function isPublicHost(host: string): boolean { + return !["127.0.0.1", "localhost", "::1"].includes(host); +} + +function normalizeDomainLikeInput(value: string): string { + const trimmed = value.trim(); + if (!trimmed) throw new Error("Domain or URL is required."); + + const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed) ? trimmed : `https://${trimmed}`; + let parsed: URL; + try { + parsed = new URL(withScheme); + } catch { + throw new Error(`Invalid domain or URL: ${value}`); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Public URL must use http or https."); + } + if (parsed.username || parsed.password) { + throw new Error("Public URL must not include a username or password."); + } + if (parsed.search || parsed.hash) { + throw new Error("Public URL must not include a query string or fragment."); + } + + const pathname = parsed.pathname.replace(/\/+$/, ""); + if (pathname && pathname !== MCP_PATH) { + throw new Error("Public URL must be an origin, optionally ending in /mcp."); + } + + parsed.pathname = ""; + return parsed.toString().replace(/\/$/, ""); +} + +function maskSecret(secret: string | undefined): string { + if (!secret) return "(not configured)"; + if (secret.length <= 6) return "*".repeat(secret.length); + return `${secret.slice(0, 3)}${"*".repeat(Math.max(8, secret.length - 5))}${secret.slice(-2)}`; +} diff --git a/src/config.ts b/src/config.ts index bb0526c4..bc2d4d9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,6 +28,8 @@ export interface ServerConfig { logging: LoggingConfig; } +export type ServerSettings = Omit; + function parsePort(value: string | number | undefined): number { if (value === undefined || value === "") return 7676; @@ -204,7 +206,7 @@ function defaultAgentDir(): string { return join(homedir(), ".codex"); } -export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { +export function loadServerSettings(env: NodeJS.ProcessEnv = process.env): ServerSettings { const files = loadDevspaceFiles(env); const host = env.HOST ?? files.config.host ?? "127.0.0.1"; const port = parsePort(env.PORT ?? files.config.port); @@ -223,7 +225,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { return { host, port, - oauth: parseOAuthConfig(env, files.auth.ownerToken), allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, @@ -239,6 +240,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { }; } +export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { + const files = loadDevspaceFiles(env); + return { + ...loadServerSettings(env), + oauth: parseOAuthConfig(env, files.auth.ownerToken), + }; +} + function parsePublicBaseUrl(value: string): string { const parsed = new URL(value); parsed.hash = ""; diff --git a/src/oauth-store.ts b/src/oauth-store.ts index 2567a40e..b9ee484e 100644 --- a/src/oauth-store.ts +++ b/src/oauth-store.ts @@ -177,6 +177,15 @@ export class SqliteOAuthStore { this.database.sqlite.prepare("delete from oauth_refresh_tokens where token_hash = ?").run(tokenHash); } + clearAll(): void { + const clear = this.database.sqlite.transaction(() => { + this.database.sqlite.prepare("delete from oauth_access_tokens").run(); + this.database.sqlite.prepare("delete from oauth_refresh_tokens").run(); + this.database.sqlite.prepare("delete from oauth_clients").run(); + }); + clear.immediate(); + } + close(): void { this.database.close(); } From 3b4b79b602ecfb4041ca02fa55fe611ad11e9a2b Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Wed, 24 Jun 2026 05:59:15 +0000 Subject: [PATCH 02/13] fix(cli): show effective config by default --- src/cli.test.ts | 12 +++++++++--- src/cli.ts | 48 ++++++++++++++++++++++++++++-------------------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index f2e0df2c..daf49d35 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -17,15 +17,15 @@ const topLevelHelp = runCli(["--help"]); assert.match(topLevelHelp, /^usage: devspace \[--version\] \[--help\] \[]$/m); assert.match(topLevelHelp, /start and connect a local MCP server/); assert.match(topLevelHelp, /manage persistent DevSpace settings/); -assert.match(topLevelHelp, /Use `devspace config` to view configuration commands\./); +assert.match(topLevelHelp, /Use `devspace config` to show current settings/); -const configHelp = runCli(["config"]); +const configHelp = runCli(["config", "--help"]); assert.match(configHelp, /^usage: devspace config \[]$/m); assert.match(configHelp, /inspect effective settings/); assert.match(configHelp, /change persistent server settings/); assert.match(configHelp, /key\s+Rotate the Owner password and revoke saved OAuth sessions/); assert.equal(runCli(["help", "config"]), configHelp); -assert.equal(runCli(["config", "--help"]), configHelp); +assert.equal(runCli(["config", "-h"]), configHelp); const root = mkdtempSync(join(tmpdir(), "devspace-cli-config-test-")); try { @@ -38,6 +38,12 @@ try { assert.match(runCli(["config", "port", "8787"], env), /Updated local bind port/); assert.match(runCli(["config", "domain", "devspace.example.com/mcp"], env), /public base URL/); + const defaultShow = runCli(["config"], env); + assert.ok(defaultShow.includes("bind host: 127.0.0.1")); + assert.ok(defaultShow.includes("port: 8787")); + assert.ok(defaultShow.includes("public MCP URL: https://devspace.example.com/mcp")); + assert.ok(defaultShow.includes("Owner password: (not configured)")); + const shown = JSON.parse(runCli(["config", "show", "--json"], env)) as { host: string; port: number; diff --git a/src/cli.ts b/src/cli.ts index fe889c13..8da445ac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -243,35 +243,24 @@ async function runDoctor(): Promise { function runConfigCommand(args: string[]): void { const [subcommand, key, ...rest] = args; - const files = loadDevspaceFiles(); - if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") { + if (!subcommand) { + printConfigShow(); + return; + } + + if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") { printConfigHelp(); return; } if (subcommand === "get") { - console.log(JSON.stringify(files.config, null, 2)); + console.log(JSON.stringify(loadDevspaceFiles().config, null, 2)); return; } if (subcommand === "show") { - const show = buildConfigShowResult(); - if (args.includes("--json")) { - console.log(JSON.stringify(show, null, 2)); - return; - } - - console.log([ - `bind host: ${show.host}`, - `port: ${show.port}`, - `public base URL: ${show.publicBaseUrl}`, - `public MCP URL: ${show.publicUrl}`, - `allowed hosts: ${show.allowedHosts.join(", ")}`, - `Owner password: ${show.accessKey}`, - `config file: ${show.configPath}`, - `auth file: ${show.authPath}`, - ].join("\n")); + printConfigShow(args.includes("--json")); return; } @@ -317,6 +306,25 @@ function runConfigCommand(args: string[]): void { printConfigUpdate(setConfigPublicBaseUrl(value)); } +function printConfigShow(json = false): void { + const show = buildConfigShowResult(); + if (json) { + console.log(JSON.stringify(show, null, 2)); + return; + } + + console.log([ + `bind host: ${show.host}`, + `port: ${show.port}`, + `public base URL: ${show.publicBaseUrl}`, + `public MCP URL: ${show.publicUrl}`, + `allowed hosts: ${show.allowedHosts.join(", ")}`, + `Owner password: ${show.accessKey}`, + `config file: ${show.configPath}`, + `auth file: ${show.authPath}`, + ].join("\n")); +} + function printConfigUpdate(result: ConfigUpdateResult): void { console.log([result.warning, result.message].filter(Boolean).join("\n")); } @@ -340,7 +348,7 @@ function printHelp(): void { " help Show this help, or `devspace help config`", " version Print the installed version", "", - "Use `devspace config` to view configuration commands.", + "Use `devspace config` to show current settings, or `devspace config --help` for configuration commands.", "Use `devspace serve` with DEVSPACE_PUBLIC_BASE_URL for a one-run tunnel override.", ].join("\n"), ); From 8f20bdf3357627e0e5798fce8d778a397c4948cf Mon Sep 17 00:00:00 2001 From: hermes-echo Date: Wed, 24 Jun 2026 14:58:39 +0800 Subject: [PATCH 03/13] fix(cli): avoid partial owner password rotation --- src/config-operations.test.ts | 22 +++++++++++++++++++++- src/config-operations.ts | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts index bba18622..0310e4e3 100644 --- a/src/config-operations.test.ts +++ b/src/config-operations.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -69,6 +69,26 @@ try { () => resetConfigKey({ ...process.env, DEVSPACE_OAUTH_OWNER_TOKEN: "environment-owner-token" }), /Cannot rotate the persisted Owner password/, ); + + const brokenStateRoot = mkdtempSync(join(tmpdir(), "devspace-config-broken-state-")); + try { + const brokenStatePath = join(brokenStateRoot, "state-file"); + writeFileSync(brokenStatePath, "{}"); + + const brokenEnv = { + ...process.env, + DEVSPACE_CONFIG_DIR: process.env.DEVSPACE_CONFIG_DIR, + DEVSPACE_STATE_DIR: brokenStatePath, + }; + + writeDevspaceAuth({ ownerToken: "persisted-owner-token-before-failure" }, brokenEnv); + const authBeforeFailure = readFileSync(loadDevspaceFiles(brokenEnv).authPath, "utf8"); + + assert.throws(() => resetConfigKey(brokenEnv), /EEXIST/); + assert.equal(readFileSync(loadDevspaceFiles(brokenEnv).authPath, "utf8"), authBeforeFailure); + } finally { + rmSync(brokenStateRoot, { recursive: true, force: true }); + } } finally { rmSync(root, { recursive: true, force: true }); delete process.env.DEVSPACE_CONFIG_DIR; diff --git a/src/config-operations.ts b/src/config-operations.ts index b23c23ce..4458f07d 100644 --- a/src/config-operations.ts +++ b/src/config-operations.ts @@ -115,7 +115,6 @@ export function resetConfigKey(env: NodeJS.ProcessEnv = process.env): ConfigKeyR } const ownerToken = generateOwnerToken(); - const authPath = writeDevspaceAuth({ ownerToken }, env); const stateDir = loadServerSettings(env).stateDir; const store = new SqliteOAuthStore(stateDir); @@ -125,6 +124,7 @@ export function resetConfigKey(env: NodeJS.ProcessEnv = process.env): ConfigKeyR store.close(); } + const authPath = writeDevspaceAuth({ ownerToken }, env); return { ownerToken, authPath }; } From c44cd9016d3a928aa9249f8dedbdcf3d51353d4d Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Wed, 24 Jun 2026 06:03:50 +0000 Subject: [PATCH 04/13] fix(cli): unify command help syntax --- src/cli.test.ts | 6 +++--- src/cli.ts | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index daf49d35..b6395ce1 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -18,14 +18,14 @@ assert.match(topLevelHelp, /^usage: devspace \[--version\] \[--help\] assert.match(topLevelHelp, /start and connect a local MCP server/); assert.match(topLevelHelp, /manage persistent DevSpace settings/); assert.match(topLevelHelp, /Use `devspace config` to show current settings/); +assert.match(topLevelHelp, /Use `devspace --help config` for configuration commands/); -const configHelp = runCli(["config", "--help"]); +const configHelp = runCli(["--help", "config"]); assert.match(configHelp, /^usage: devspace config \[]$/m); assert.match(configHelp, /inspect effective settings/); assert.match(configHelp, /change persistent server settings/); assert.match(configHelp, /key\s+Rotate the Owner password and revoke saved OAuth sessions/); -assert.equal(runCli(["help", "config"]), configHelp); -assert.equal(runCli(["config", "-h"]), configHelp); +assert.equal(runCli(["-h", "config"]), configHelp); const root = mkdtempSync(join(tmpdir(), "devspace-cli-config-test-")); try { diff --git a/src/cli.ts b/src/cli.ts index 8da445ac..8e55781e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -49,12 +49,15 @@ async function main(argv: string[]): Promise { runConfigCommand(args); return; case "help": - if (args[0] === "config") { + if ((rawCommand === "--help" || rawCommand === "-h") && args.length === 1 && args[0] === "config") { printConfigHelp(); - } else { + return; + } + if (args.length === 0) { printHelp(); + return; } - return; + throw new Error(`Unknown help target: ${args.join(" ")}. Use \`devspace --help \`.`); case "version": printVersion(); return; @@ -250,8 +253,7 @@ function runConfigCommand(args: string[]): void { } if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") { - printConfigHelp(); - return; + throw new Error(`Unknown config command: ${subcommand}. Use \`devspace --help config\`.`); } if (subcommand === "get") { @@ -342,13 +344,14 @@ function printHelp(): void { " doctor Check configuration, runtime, and native dependencies", "", "manage persistent DevSpace settings", - " config Show configuration command help", + " config Show current effective settings", "", "get help and version information", - " help Show this help, or `devspace help config`", + " help Show this help", " version Print the installed version", "", - "Use `devspace config` to show current settings, or `devspace config --help` for configuration commands.", + "Use `devspace config` to show current settings.", + "Use `devspace --help config` for configuration commands.", "Use `devspace serve` with DEVSPACE_PUBLIC_BASE_URL for a one-run tunnel override.", ].join("\n"), ); From 68a197b10c8e0c40cc3b4c510a35e82f8cb8d99b Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Wed, 24 Jun 2026 06:53:22 +0000 Subject: [PATCH 05/13] fix(cli): show help when no command is provided --- src/cli.test.ts | 1 + src/cli.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index b6395ce1..869d05b7 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -14,6 +14,7 @@ for (const flag of ["-v", "--version"]) { } const topLevelHelp = runCli(["--help"]); +assert.equal(runCli([]), topLevelHelp); assert.match(topLevelHelp, /^usage: devspace \[--version\] \[--help\] \[]$/m); assert.match(topLevelHelp, /start and connect a local MCP server/); assert.match(topLevelHelp, /manage persistent DevSpace settings/); diff --git a/src/cli.ts b/src/cli.ts index 8e55781e..205a6477 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -65,7 +65,8 @@ async function main(argv: string[]): Promise { } function normalizeCommand(command: string | undefined): Command { - if (!command || command === "serve" || command === "start") return "serve"; + if (!command) return "help"; + if (command === "serve" || command === "start") return "serve"; if (command === "init" || command === "doctor" || command === "config") return command; if (command === "help" || command === "--help" || command === "-h") return "help"; if (command === "version" || command === "--version" || command === "-v") return "version"; From 9b254696dbcf721f0f222345e0a68345795d2dcb Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Wed, 24 Jun 2026 07:33:54 +0000 Subject: [PATCH 06/13] feat(cli): set key explicitly --- docs/configuration.md | 11 ++++++----- src/cli.test.ts | 9 +++++---- src/cli.ts | 10 +++++----- src/config-operations.test.ts | 19 +++++++++++-------- src/config-operations.ts | 26 +++++++++++++++++++------- 5 files changed, 46 insertions(+), 29 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 306b5854..0bb6b40e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,8 +31,8 @@ npx @waishnav/devspace config host 127.0.0.1 npx @waishnav/devspace config port 7676 npx @waishnav/devspace config domain devspace.example.com -# Rotate the Owner password and revoke persisted OAuth clients and tokens. -npx @waishnav/devspace config key +# Set the Owner password and revoke persisted OAuth clients and tokens. +npx @waishnav/devspace config key "your-new-owner-password" ``` `config host`, `config port`, and `config domain` persist changes in @@ -40,9 +40,10 @@ npx @waishnav/devspace config key accepts a bare domain or an `http`/`https` URL; it also accepts a trailing `/mcp` and stores the corresponding origin. -`config key` prints the new Owner password once, stores it in `auth.json`, and -clears persisted OAuth clients and tokens. Restart DevSpace before using the new -password. It cannot rotate a password supplied through +`config key ` stores the supplied Owner password in `auth.json` and +clears persisted OAuth clients and tokens. The value must be at least 16 +characters and is never printed by DevSpace. Restart DevSpace before using the +new password. It cannot update a password supplied through `DEVSPACE_OAUTH_OWNER_TOKEN`; unset that environment variable first. For backward compatibility, `config get` prints the persisted JSON and diff --git a/src/cli.test.ts b/src/cli.test.ts index 869d05b7..9b5d627a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -25,7 +25,7 @@ const configHelp = runCli(["--help", "config"]); assert.match(configHelp, /^usage: devspace config \[]$/m); assert.match(configHelp, /inspect effective settings/); assert.match(configHelp, /change persistent server settings/); -assert.match(configHelp, /key\s+Rotate the Owner password and revoke saved OAuth sessions/); +assert.match(configHelp, /key \s+Set the Owner password and revoke saved OAuth sessions/); assert.equal(runCli(["-h", "config"]), configHelp); const root = mkdtempSync(join(tmpdir(), "devspace-cli-config-test-")); @@ -56,9 +56,10 @@ try { assert.equal(shown.publicUrl, "https://devspace.example.com/mcp"); assert.equal(shown.accessKey, "(not configured)"); - const keyOutput = runCli(["config", "key"], env); - assert.match(keyOutput, /Owner password rotated/); - assert.match(keyOutput, /New Owner password: /); + const newOwnerPassword = "cli-owner-password-for-test"; + const keyOutput = runCli(["config", "key", newOwnerPassword], env); + assert.match(keyOutput, /Owner password updated/); + assert.ok(!keyOutput.includes(newOwnerPassword)); assert.match(runCli(["config", "show"], env), /Owner password: .{3}\*+/); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/src/cli.ts b/src/cli.ts index 205a6477..00f50889 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,7 @@ import { satisfies } from "semver"; import { loadConfig } from "./config.js"; import { buildConfigShowResult, - resetConfigKey, + setConfigKey, setConfigDomain, setConfigHost, setConfigPort, @@ -284,10 +284,10 @@ function runConfigCommand(args: string[]): void { } if (subcommand === "key") { - const result = resetConfigKey(); + const value = [key, ...rest].join(" ").trim(); + const result = setConfigKey(value); console.log([ - "Owner password rotated. Existing OAuth clients and tokens were cleared.", - `New Owner password: ${result.ownerToken}`, + "Owner password updated. Existing OAuth clients and tokens were cleared.", `Saved to: ${result.authPath}`, "Restart DevSpace for the new Owner password to take effect.", ].join("\n")); @@ -373,7 +373,7 @@ function printConfigHelp(): void { " host Set the local bind host", " port Set the local bind port", " domain Set the public origin; a trailing /mcp is accepted", - " key Rotate the Owner password and revoke saved OAuth sessions", + " key Set the Owner password and revoke saved OAuth sessions", "", "compatibility command", " set publicBaseUrl Set or clear the persisted public base URL", diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts index 0310e4e3..2a6f95d3 100644 --- a/src/config-operations.test.ts +++ b/src/config-operations.test.ts @@ -4,9 +4,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildConfigShowResult, - resetConfigKey, setConfigDomain, setConfigHost, + setConfigKey, setConfigPort, setConfigPublicBaseUrl, } from "./config-operations.js"; @@ -50,9 +50,10 @@ try { }); store.close(); - const reset = resetConfigKey(); - assert.notEqual(reset.ownerToken, "old-owner-token-that-is-long-enough"); - assert.equal(loadDevspaceFiles().auth.ownerToken, reset.ownerToken); + const newOwnerPassword = "new-owner-password-for-test"; + const update = setConfigKey(newOwnerPassword); + assert.ok(update.authPath.endsWith("auth.json")); + assert.equal(loadDevspaceFiles().auth.ownerToken, newOwnerPassword); const clearedStore = new SqliteOAuthStore(process.env.DEVSPACE_STATE_DIR); try { @@ -62,12 +63,14 @@ try { } const shown = buildConfigShowResult(); - assert.notEqual(shown.accessKey, reset.ownerToken); + assert.notEqual(shown.accessKey, newOwnerPassword); assert.match(shown.accessKey, /^.{3}\*+/); + assert.throws(() => setConfigKey(""), /Owner password is required/); + assert.throws(() => setConfigKey("too-short"), /at least 16 characters/); assert.throws( - () => resetConfigKey({ ...process.env, DEVSPACE_OAUTH_OWNER_TOKEN: "environment-owner-token" }), - /Cannot rotate the persisted Owner password/, + () => setConfigKey("new-owner-password-for-test", { ...process.env, DEVSPACE_OAUTH_OWNER_TOKEN: "environment-owner-token" }), + /Cannot update the persisted Owner password/, ); const brokenStateRoot = mkdtempSync(join(tmpdir(), "devspace-config-broken-state-")); @@ -84,7 +87,7 @@ try { writeDevspaceAuth({ ownerToken: "persisted-owner-token-before-failure" }, brokenEnv); const authBeforeFailure = readFileSync(loadDevspaceFiles(brokenEnv).authPath, "utf8"); - assert.throws(() => resetConfigKey(brokenEnv), /EEXIST/); + assert.throws(() => setConfigKey("new-owner-password-for-test", brokenEnv), /EEXIST/); assert.equal(readFileSync(loadDevspaceFiles(brokenEnv).authPath, "utf8"), authBeforeFailure); } finally { rmSync(brokenStateRoot, { recursive: true, force: true }); diff --git a/src/config-operations.ts b/src/config-operations.ts index 4458f07d..04fe5082 100644 --- a/src/config-operations.ts +++ b/src/config-operations.ts @@ -2,7 +2,6 @@ import { isIP } from "node:net"; import { loadServerSettings } from "./config.js"; import { SqliteOAuthStore } from "./oauth-store.js"; import { - generateOwnerToken, loadDevspaceFiles, writeDevspaceAuth, writeDevspaceConfig, @@ -26,8 +25,7 @@ export interface ConfigUpdateResult { warning?: string; } -export interface ConfigKeyResetResult { - ownerToken: string; +export interface ConfigKeyUpdateResult { authPath: string; } @@ -107,14 +105,17 @@ export function setConfigPublicBaseUrl( return setConfigDomain(trimmed, env); } -export function resetConfigKey(env: NodeJS.ProcessEnv = process.env): ConfigKeyResetResult { +export function setConfigKey( + value: string, + env: NodeJS.ProcessEnv = process.env, +): ConfigKeyUpdateResult { if (env.DEVSPACE_OAUTH_OWNER_TOKEN?.trim()) { throw new Error( - "Cannot rotate the persisted Owner password while DEVSPACE_OAUTH_OWNER_TOKEN is set. Unset it first, then run `devspace config key` again.", + "Cannot update the persisted Owner password while DEVSPACE_OAUTH_OWNER_TOKEN is set. Unset it first, then run `devspace config key ` again.", ); } - const ownerToken = generateOwnerToken(); + const ownerToken = validateOwnerToken(value); const stateDir = loadServerSettings(env).stateDir; const store = new SqliteOAuthStore(stateDir); @@ -125,7 +126,18 @@ export function resetConfigKey(env: NodeJS.ProcessEnv = process.env): ConfigKeyR } const authPath = writeDevspaceAuth({ ownerToken }, env); - return { ownerToken, authPath }; + return { authPath }; +} + +function validateOwnerToken(value: string): string { + const ownerToken = value.trim(); + if (!ownerToken) { + throw new Error("Owner password is required. Use `devspace config key `."); + } + if (ownerToken.length < 16) { + throw new Error("Owner password must be at least 16 characters long."); + } + return ownerToken; } function validateHost(value: string): string { From dad4c7d28ef1c571e56aa0a9d028128803fadb82 Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Wed, 24 Jun 2026 07:46:53 +0000 Subject: [PATCH 07/13] fix(cli): unify public URL normalization --- docs/setup.md | 2 +- src/cli.ts | 28 +++++++--------------------- src/config-operations.test.ts | 3 +++ src/config-operations.ts | 6 +++--- 4 files changed, 14 insertions(+), 25 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index 18bad1b4..b9bb0eef 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -62,7 +62,7 @@ at: http://127.0.0.1:7676 ``` -Enter the public origin without `/mcp`: +Enter the public origin. A trailing `/mcp` is accepted and normalized: ```text https://your-tunnel-host.example.com diff --git a/src/cli.ts b/src/cli.ts index 00f50889..ed4fea91 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { satisfies } from "semver"; import { loadConfig } from "./config.js"; import { buildConfigShowResult, + normalizePublicBaseUrlInput, setConfigKey, setConfigDomain, setConfigHost, @@ -130,13 +131,13 @@ async function runInit({ force }: { force: boolean }): Promise { [ "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.", + "Paste the public origin here. A trailing /mcp is accepted.", "", "Example: https://your-tunnel-host.example.com", ].join("\n"), "Public URL required", ); - const publicBaseUrl = normalizePublicBaseUrl(await textPrompt({ + const publicBaseUrl = normalizePublicBaseUrlInput(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?", @@ -392,15 +393,6 @@ function printVersion(): void { console.log(packageJson.version); } -function normalizePublicBaseUrl(value: string): string { - const trimmed = value.trim(); - const parsed = new URL(trimmed); - parsed.hash = ""; - parsed.search = ""; - parsed.pathname = parsed.pathname.replace(/\/+$/, ""); - return parsed.toString().replace(/\/$/, ""); -} - type TextPromptOptions = Omit[0], "validate"> & { defaultValue: string; validate?: (value: string | undefined) => string | Error | undefined; @@ -426,18 +418,12 @@ function validatePort(value: string | undefined): string | undefined { function validateRequiredPublicBaseUrl(value: string | undefined): string | undefined { const trimmed = value?.trim() ?? ""; if (!trimmed) return "Enter the public URL from your tunnel or reverse proxy."; - if (trimmed.endsWith("/mcp")) return "Enter the base URL only, without /mcp."; - return validatePublicBaseUrl(trimmed); -} -function validatePublicBaseUrl(value: string): string | undefined { try { - const parsed = new URL(value); - return parsed.protocol === "http:" || parsed.protocol === "https:" - ? undefined - : "Use an http or https URL."; - } catch { - return "Enter a valid URL, for example https://your-tunnel-host.example.com."; + normalizePublicBaseUrlInput(trimmed); + return undefined; + } catch (error) { + return error instanceof Error ? error.message : "Enter a valid public URL."; } } diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts index 2a6f95d3..76d94b33 100644 --- a/src/config-operations.test.ts +++ b/src/config-operations.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildConfigShowResult, + normalizePublicBaseUrlInput, setConfigDomain, setConfigHost, setConfigKey, @@ -35,6 +36,8 @@ try { const domainResult = setConfigDomain("devspace.example.com/mcp"); assert.equal(loadDevspaceFiles().config.publicBaseUrl, "https://devspace.example.com"); + assert.equal(normalizePublicBaseUrlInput("localhost:8443"), "https://localhost:8443"); + assert.equal(normalizePublicBaseUrlInput("https://devspace.example.com/mcp"), "https://devspace.example.com"); assert.equal(domainResult.warning, undefined); assert.match(setConfigDomain("http://devspace.example.com").warning ?? "", /Prefer HTTPS/); assert.throws(() => setConfigDomain("https://devspace.example.com/custom-mcp"), /origin/); diff --git a/src/config-operations.ts b/src/config-operations.ts index 04fe5082..1a1e94f7 100644 --- a/src/config-operations.ts +++ b/src/config-operations.ts @@ -76,7 +76,7 @@ export function setConfigHost(value: string, env: NodeJS.ProcessEnv = process.en } export function setConfigDomain(value: string, env: NodeJS.ProcessEnv = process.env): ConfigUpdateResult { - const publicBaseUrl = normalizeDomainLikeInput(value); + const publicBaseUrl = normalizePublicBaseUrlInput(value); const files = loadDevspaceFiles(env); writeDevspaceConfig({ ...files.config, publicBaseUrl }, env); @@ -157,11 +157,11 @@ function isPublicHost(host: string): boolean { return !["127.0.0.1", "localhost", "::1"].includes(host); } -function normalizeDomainLikeInput(value: string): string { +export function normalizePublicBaseUrlInput(value: string): string { const trimmed = value.trim(); if (!trimmed) throw new Error("Domain or URL is required."); - const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed) ? trimmed : `https://${trimmed}`; + const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`; let parsed: URL; try { parsed = new URL(withScheme); From 4ed5b2989491ef4a7d6d879380c6ffff3d7971af Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Wed, 24 Jun 2026 07:55:45 +0000 Subject: [PATCH 08/13] fix(cli): simplify domain configuration --- docs/configuration.md | 13 ++++---- docs/setup.md | 2 +- src/cli.test.ts | 21 ++++++------ src/cli.ts | 60 ++++++++++++++++------------------- src/config-operations.test.ts | 11 +++---- src/config-operations.ts | 41 ++++++------------------ 6 files changed, 57 insertions(+), 91 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0bb6b40e..b5f92762 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,8 +23,8 @@ npx @waishnav/devspace init npx @waishnav/devspace serve npx @waishnav/devspace doctor -# Show effective settings. Owner passwords are always masked. -npx @waishnav/devspace config show +# Show effective settings as JSON. Owner passwords are always masked. +npx @waishnav/devspace config # Persist local server settings. npx @waishnav/devspace config host 127.0.0.1 @@ -35,10 +35,11 @@ npx @waishnav/devspace config domain devspace.example.com npx @waishnav/devspace config key "your-new-owner-password" ``` -`config host`, `config port`, and `config domain` persist changes in -`~/.devspace/config.json`. Restart DevSpace after changing them. `config domain` -accepts a bare domain or an `http`/`https` URL; it also accepts a trailing -`/mcp` and stores the corresponding origin. +`config` prints effective settings as JSON. `config host`, `config port`, and +`config domain` persist changes in `~/.devspace/config.json`. Restart DevSpace +after changing them. `config domain` accepts a hostname such as +`devspace.example.com`, stores `https://devspace.example.com`, and DevSpace +automatically uses `/mcp` as the MCP endpoint. `config key ` stores the supplied Owner password in `auth.json` and clears persisted OAuth clients and tokens. The value must be at least 16 diff --git a/docs/setup.md b/docs/setup.md index b9bb0eef..18bad1b4 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -62,7 +62,7 @@ at: http://127.0.0.1:7676 ``` -Enter the public origin. A trailing `/mcp` is accepted and normalized: +Enter the public origin without `/mcp`: ```text https://your-tunnel-host.example.com diff --git a/src/cli.test.ts b/src/cli.test.ts index 9b5d627a..37e869d0 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -22,9 +22,9 @@ assert.match(topLevelHelp, /Use `devspace config` to show current settings/); assert.match(topLevelHelp, /Use `devspace --help config` for configuration commands/); const configHelp = runCli(["--help", "config"]); -assert.match(configHelp, /^usage: devspace config \[]$/m); -assert.match(configHelp, /inspect effective settings/); -assert.match(configHelp, /change persistent server settings/); +assert.match(configHelp, /^usage: devspace config \[ \[\]\]$/m); +assert.match(configHelp, /\(no command\)\s+Print effective settings as JSON/); +assert.match(configHelp, /domain \s+Set the public domain; MCP uses \/mcp automatically/); assert.match(configHelp, /key \s+Set the Owner password and revoke saved OAuth sessions/); assert.equal(runCli(["-h", "config"]), configHelp); @@ -37,22 +37,18 @@ try { assert.match(runCli(["config", "host", "127.0.0.1"], env), /Updated local bind host/); assert.match(runCli(["config", "port", "8787"], env), /Updated local bind port/); - assert.match(runCli(["config", "domain", "devspace.example.com/mcp"], env), /public base URL/); + assert.match(runCli(["config", "domain", "devspace.example.com"], env), /public domain/); - const defaultShow = runCli(["config"], env); - assert.ok(defaultShow.includes("bind host: 127.0.0.1")); - assert.ok(defaultShow.includes("port: 8787")); - assert.ok(defaultShow.includes("public MCP URL: https://devspace.example.com/mcp")); - assert.ok(defaultShow.includes("Owner password: (not configured)")); - - const shown = JSON.parse(runCli(["config", "show", "--json"], env)) as { + const shown = JSON.parse(runCli(["config"], env)) as { host: string; port: number; + publicBaseUrl: string; publicUrl: string; accessKey: string; }; assert.equal(shown.host, "127.0.0.1"); assert.equal(shown.port, 8787); + assert.equal(shown.publicBaseUrl, "https://devspace.example.com"); assert.equal(shown.publicUrl, "https://devspace.example.com/mcp"); assert.equal(shown.accessKey, "(not configured)"); @@ -60,7 +56,8 @@ try { const keyOutput = runCli(["config", "key", newOwnerPassword], env); assert.match(keyOutput, /Owner password updated/); assert.ok(!keyOutput.includes(newOwnerPassword)); - assert.match(runCli(["config", "show"], env), /Owner password: .{3}\*+/); + const updated = JSON.parse(runCli(["config"], env)) as { accessKey: string }; + assert.match(updated.accessKey, /^.{3}\*+/); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/cli.ts b/src/cli.ts index ed4fea91..c0741ae2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,6 @@ import { satisfies } from "semver"; import { loadConfig } from "./config.js"; import { buildConfigShowResult, - normalizePublicBaseUrlInput, setConfigKey, setConfigDomain, setConfigHost, @@ -131,13 +130,13 @@ async function runInit({ force }: { force: boolean }): Promise { [ "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. A trailing /mcp is accepted.", + "Paste the public origin here, without /mcp.", "", "Example: https://your-tunnel-host.example.com", ].join("\n"), "Public URL required", ); - const publicBaseUrl = normalizePublicBaseUrlInput(await textPrompt({ + 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?", @@ -263,11 +262,6 @@ function runConfigCommand(args: string[]): void { return; } - if (subcommand === "show") { - printConfigShow(args.includes("--json")); - return; - } - if (subcommand === "port") { printConfigUpdate(setConfigPort(key ?? "")); return; @@ -310,23 +304,8 @@ function runConfigCommand(args: string[]): void { printConfigUpdate(setConfigPublicBaseUrl(value)); } -function printConfigShow(json = false): void { - const show = buildConfigShowResult(); - if (json) { - console.log(JSON.stringify(show, null, 2)); - return; - } - - console.log([ - `bind host: ${show.host}`, - `port: ${show.port}`, - `public base URL: ${show.publicBaseUrl}`, - `public MCP URL: ${show.publicUrl}`, - `allowed hosts: ${show.allowedHosts.join(", ")}`, - `Owner password: ${show.accessKey}`, - `config file: ${show.configPath}`, - `auth file: ${show.authPath}`, - ].join("\n")); +function printConfigShow(): void { + console.log(JSON.stringify(buildConfigShowResult(), null, 2)); } function printConfigUpdate(result: ConfigUpdateResult): void { @@ -362,19 +341,19 @@ function printHelp(): void { function printConfigHelp(): void { console.log( [ - "usage: devspace config []", + "usage: devspace config [ []]", "", "These are common DevSpace configuration commands:", "", "inspect effective settings", - " show [--json] Show effective server settings and masked Owner password", + " (no command) Print effective settings as JSON", " get Print persisted config JSON (legacy-compatible)", "", "change persistent server settings", " host Set the local bind host", " port Set the local bind port", - " domain Set the public origin; a trailing /mcp is accepted", - " key Set the Owner password and revoke saved OAuth sessions", + " domain Set the public domain; MCP uses /mcp automatically", + " key Set the Owner password and revoke saved OAuth sessions", "", "compatibility command", " set publicBaseUrl Set or clear the persisted public base URL", @@ -384,6 +363,15 @@ function printConfigHelp(): void { ); } +function normalizePublicBaseUrl(value: string): string { + const trimmed = value.trim(); + const parsed = new URL(trimmed); + parsed.hash = ""; + parsed.search = ""; + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + return parsed.toString().replace(/\/$/, ""); +} + function printVersion(): void { const packageJson = require("../package.json") as { version?: unknown }; if (typeof packageJson.version !== "string") { @@ -418,12 +406,18 @@ function validatePort(value: string | undefined): string | undefined { function validateRequiredPublicBaseUrl(value: string | undefined): string | undefined { const trimmed = value?.trim() ?? ""; if (!trimmed) return "Enter the public URL from your tunnel or reverse proxy."; + if (trimmed.endsWith("/mcp")) return "Enter the base URL only, without /mcp."; + return validatePublicBaseUrl(trimmed); +} +function validatePublicBaseUrl(value: string): string | undefined { try { - normalizePublicBaseUrlInput(trimmed); - return undefined; - } catch (error) { - return error instanceof Error ? error.message : "Enter a valid public URL."; + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:" + ? undefined + : "Use an http or https URL."; + } catch { + return "Enter a valid URL, for example https://your-tunnel-host.example.com."; } } diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts index 76d94b33..abeef7f6 100644 --- a/src/config-operations.test.ts +++ b/src/config-operations.test.ts @@ -4,7 +4,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildConfigShowResult, - normalizePublicBaseUrlInput, setConfigDomain, setConfigHost, setConfigKey, @@ -34,14 +33,12 @@ try { assert.match(hostResult.warning ?? "", /may expose DevSpace/); assert.throws(() => setConfigHost("https://example.com"), /Invalid host/); - const domainResult = setConfigDomain("devspace.example.com/mcp"); + const domainResult = setConfigDomain("devspace.example.com"); assert.equal(loadDevspaceFiles().config.publicBaseUrl, "https://devspace.example.com"); - assert.equal(normalizePublicBaseUrlInput("localhost:8443"), "https://localhost:8443"); - assert.equal(normalizePublicBaseUrlInput("https://devspace.example.com/mcp"), "https://devspace.example.com"); assert.equal(domainResult.warning, undefined); - assert.match(setConfigDomain("http://devspace.example.com").warning ?? "", /Prefer HTTPS/); - assert.throws(() => setConfigDomain("https://devspace.example.com/custom-mcp"), /origin/); - assert.throws(() => setConfigDomain("ftp://devspace.example.com"), /http or https/); + assert.throws(() => setConfigDomain("devspace.example.com/mcp"), /Domain must be a hostname/); + assert.throws(() => setConfigDomain("localhost:8443"), /Domain must be a hostname/); + assert.throws(() => setConfigDomain("https://devspace.example.com"), /Domain must be a hostname/); setConfigPublicBaseUrl("none"); assert.equal(loadDevspaceFiles().config.publicBaseUrl, null); diff --git a/src/config-operations.ts b/src/config-operations.ts index 1a1e94f7..41ebbd41 100644 --- a/src/config-operations.ts +++ b/src/config-operations.ts @@ -76,15 +76,12 @@ export function setConfigHost(value: string, env: NodeJS.ProcessEnv = process.en } export function setConfigDomain(value: string, env: NodeJS.ProcessEnv = process.env): ConfigUpdateResult { - const publicBaseUrl = normalizePublicBaseUrlInput(value); + const publicBaseUrl = normalizeConfiguredDomain(value); const files = loadDevspaceFiles(env); writeDevspaceConfig({ ...files.config, publicBaseUrl }, env); return { - message: `Updated public base URL to ${publicBaseUrl}. Restart DevSpace for the change to take effect.`, - warning: publicBaseUrl.startsWith("http://") - ? "Warning: public URL uses HTTP. Prefer HTTPS for remote MCP access." - : undefined, + message: `Updated public domain to ${new URL(publicBaseUrl).hostname}. MCP URL: ${new URL(MCP_PATH, publicBaseUrl).toString()}. Restart DevSpace for the change to take effect.`, }; } @@ -157,35 +154,15 @@ function isPublicHost(host: string): boolean { return !["127.0.0.1", "localhost", "::1"].includes(host); } -export function normalizePublicBaseUrlInput(value: string): string { - const trimmed = value.trim(); - if (!trimmed) throw new Error("Domain or URL is required."); - - const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`; - let parsed: URL; - try { - parsed = new URL(withScheme); - } catch { - throw new Error(`Invalid domain or URL: ${value}`); - } - - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("Public URL must use http or https."); - } - if (parsed.username || parsed.password) { - throw new Error("Public URL must not include a username or password."); - } - if (parsed.search || parsed.hash) { - throw new Error("Public URL must not include a query string or fragment."); - } - - const pathname = parsed.pathname.replace(/\/+$/, ""); - if (pathname && pathname !== MCP_PATH) { - throw new Error("Public URL must be an origin, optionally ending in /mcp."); +function normalizeConfiguredDomain(value: string): string { + const domain = value.trim(); + if (!domain) throw new Error("Domain is required."); + if (/[/:?#@]/.test(domain) || /\s/.test(domain)) { + throw new Error("Domain must be a hostname without a protocol, port, path, query string, or fragment."); } - parsed.pathname = ""; - return parsed.toString().replace(/\/$/, ""); + const host = validateHost(domain); + return `https://${host}`; } function maskSecret(secret: string | undefined): string { From c85059d2061eea66f9e5067e30c2402d4ff3c9d2 Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Wed, 24 Jun 2026 08:36:00 +0000 Subject: [PATCH 09/13] test: isolate CI env --- src/config-operations.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts index abeef7f6..cdfe6761 100644 --- a/src/config-operations.test.ts +++ b/src/config-operations.test.ts @@ -14,6 +14,8 @@ import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js"; import { loadDevspaceFiles, writeDevspaceAuth } from "./user-config.js"; const root = mkdtempSync(join(tmpdir(), "devspace-config-operations-test-")); +const originalOwnerToken = process.env.DEVSPACE_OAUTH_OWNER_TOKEN; +delete process.env.DEVSPACE_OAUTH_OWNER_TOKEN; process.env.DEVSPACE_CONFIG_DIR = join(root, "config"); process.env.DEVSPACE_STATE_DIR = join(root, "state"); From 8314bff2d44bf165cec2ccb6e45e6ec5c7f71937 Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Wed, 24 Jun 2026 08:41:39 +0000 Subject: [PATCH 10/13] test: isolate configuration overrides --- src/cli.test.ts | 14 +++++++++++++- src/config-operations.test.ts | 12 ++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 37e869d0..443c9cbf 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -63,8 +63,20 @@ try { } function runCli(args: string[], overrides: NodeJS.ProcessEnv = {}): string { + const env = { ...process.env }; + for (const key of [ + "HOST", + "PORT", + "DEVSPACE_ALLOWED_ROOTS", + "DEVSPACE_ALLOWED_HOSTS", + "DEVSPACE_OAUTH_OWNER_TOKEN", + "DEVSPACE_PUBLIC_BASE_URL", + ]) { + delete env[key]; + } + return execFileSync("node", ["--import", "tsx", "src/cli.ts", ...args], { encoding: "utf8", - env: { ...process.env, ...overrides }, + env: { ...env, ...overrides }, }); } diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts index cdfe6761..134899de 100644 --- a/src/config-operations.test.ts +++ b/src/config-operations.test.ts @@ -14,8 +14,16 @@ import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js"; import { loadDevspaceFiles, writeDevspaceAuth } from "./user-config.js"; const root = mkdtempSync(join(tmpdir(), "devspace-config-operations-test-")); -const originalOwnerToken = process.env.DEVSPACE_OAUTH_OWNER_TOKEN; -delete process.env.DEVSPACE_OAUTH_OWNER_TOKEN; +for (const key of [ + "HOST", + "PORT", + "DEVSPACE_ALLOWED_ROOTS", + "DEVSPACE_ALLOWED_HOSTS", + "DEVSPACE_OAUTH_OWNER_TOKEN", + "DEVSPACE_PUBLIC_BASE_URL", +]) { + delete process.env[key]; +} process.env.DEVSPACE_CONFIG_DIR = join(root, "config"); process.env.DEVSPACE_STATE_DIR = join(root, "state"); From fa1d24a258513cd2a8ad4b086fae2e7f03bf4fcd Mon Sep 17 00:00:00 2001 From: hermes-echo Date: Wed, 24 Jun 2026 09:46:02 +0000 Subject: [PATCH 11/13] docs: document configuration commands --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/README.md b/README.md index 3605847c..4d52c0d5 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,57 @@ the Owner password printed by `devspace init`. It is also stored in: Keep that password private. +## Configuration Management + +Use `devspace config` to inspect the effective settings after DevSpace merges +persisted configuration, environment variables, and defaults: + +```bash +devspace config +``` + +Update persistent settings without running setup again: + +```bash +# Change the local listening port +devspace config port 7676 + +# Change the local bind host +devspace config host 127.0.0.1 + +# Set the public domain; DevSpace automatically uses /mcp +devspace config domain devspace.example.com + +# Set a new Owner password and revoke saved OAuth sessions +devspace config key "your-new-owner-password" +``` + +`config domain` accepts a hostname or domain only. Do not include a protocol, +port, path, or `/mcp`; DevSpace stores `https://devspace.example.com` and derives +`https://devspace.example.com/mcp` automatically. + +Configuration changes are saved locally. Restart `devspace serve` for host, +port, domain, or Owner password changes to take effect. + +## CLI Help and Version + +```bash +# Show the installed version +devspace --version +devspace -v + +# Show top-level help +devspace --help +devspace -h + +# Show configuration command help +devspace --help config +devspace -h config +``` + +Running `devspace` without arguments prints top-level help. Use `devspace serve` +to start the MCP server. + ## Connect Your MCP Client The default local endpoint is: From c4c0df32744c8681086dfc61f18f7ba1d2446a5f Mon Sep 17 00:00:00 2001 From: hermes-echo Date: Wed, 24 Jun 2026 10:35:18 +0000 Subject: [PATCH 12/13] fix(cli): address configuration review feedback --- README.md | 11 ++++++++--- src/cli.test.ts | 6 ++++-- src/cli.ts | 24 +++++++++++++++++++----- src/config-operations.test.ts | 12 ++++++++++-- src/config-operations.ts | 34 +++++++++++++++++++++++++++++----- 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4d52c0d5..71f96a3b 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,10 @@ devspace config host 127.0.0.1 # Set the public domain; DevSpace automatically uses /mcp devspace config domain devspace.example.com -# Set a new Owner password and revoke saved OAuth sessions +# Recommended: set a new Owner password through a hidden prompt +devspace config key + +# Non-interactive automation can supply the password explicitly devspace config key "your-new-owner-password" ``` @@ -132,8 +135,10 @@ devspace config key "your-new-owner-password" port, path, or `/mcp`; DevSpace stores `https://devspace.example.com` and derives `https://devspace.example.com/mcp` automatically. -Configuration changes are saved locally. Restart `devspace serve` for host, -port, domain, or Owner password changes to take effect. +Configuration changes are saved locally. `devspace config key` opens a hidden +prompt in an interactive terminal; use an explicit password only for +non-interactive automation. Restart `devspace serve` for host, port, domain, or +Owner password changes to take effect. ## CLI Help and Version diff --git a/src/cli.test.ts b/src/cli.test.ts index 443c9cbf..1310b182 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -25,8 +25,10 @@ const configHelp = runCli(["--help", "config"]); assert.match(configHelp, /^usage: devspace config \[ \[\]\]$/m); assert.match(configHelp, /\(no command\)\s+Print effective settings as JSON/); assert.match(configHelp, /domain \s+Set the public domain; MCP uses \/mcp automatically/); -assert.match(configHelp, /key \s+Set the Owner password and revoke saved OAuth sessions/); +assert.match(configHelp, /key \[key\]\s+Set the Owner password and revoke saved OAuth sessions/); +assert.match(configHelp, /Omit to enter it in a hidden prompt/); assert.equal(runCli(["-h", "config"]), configHelp); +assert.equal(runCli(["help", "config"]), configHelp); const root = mkdtempSync(join(tmpdir(), "devspace-cli-config-test-")); try { @@ -57,7 +59,7 @@ try { assert.match(keyOutput, /Owner password updated/); assert.ok(!keyOutput.includes(newOwnerPassword)); const updated = JSON.parse(runCli(["config"], env)) as { accessKey: string }; - assert.match(updated.accessKey, /^.{3}\*+/); + assert.equal(updated.accessKey, "********"); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/cli.ts b/src/cli.ts index c0741ae2..591ef0e8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -46,10 +46,10 @@ async function main(argv: string[]): Promise { await runDoctor(); return; case "config": - runConfigCommand(args); + await runConfigCommand(args); return; case "help": - if ((rawCommand === "--help" || rawCommand === "-h") && args.length === 1 && args[0] === "config") { + if (args.length === 1 && args[0] === "config") { printConfigHelp(); return; } @@ -245,7 +245,7 @@ async function runDoctor(): Promise { } } -function runConfigCommand(args: string[]): void { +async function runConfigCommand(args: string[]): Promise { const [subcommand, key, ...rest] = args; if (!subcommand) { @@ -279,7 +279,8 @@ function runConfigCommand(args: string[]): void { } if (subcommand === "key") { - const value = [key, ...rest].join(" ").trim(); + const suppliedValue = [key, ...rest].join(" ").trim(); + const value = suppliedValue || await promptForOwnerPassword(); const result = setConfigKey(value); console.log([ "Owner password updated. Existing OAuth clients and tokens were cleared.", @@ -304,6 +305,18 @@ function runConfigCommand(args: string[]): void { printConfigUpdate(setConfigPublicBaseUrl(value)); } +async function promptForOwnerPassword(): Promise { + if (!input.isTTY || !output.isTTY) { + throw new Error( + "Owner password is required. In a non-interactive terminal, use `devspace config key `.", + ); + } + + const value = await prompts.password({ message: "Enter a new Owner password" }); + if (prompts.isCancel(value)) throw new SetupCancelledError(); + return String(value); +} + function printConfigShow(): void { console.log(JSON.stringify(buildConfigShowResult(), null, 2)); } @@ -353,7 +366,8 @@ function printConfigHelp(): void { " host Set the local bind host", " port Set the local bind port", " domain Set the public domain; MCP uses /mcp automatically", - " key Set the Owner password and revoke saved OAuth sessions", + " key [key] Set the Owner password and revoke saved OAuth sessions", + " Omit to enter it in a hidden prompt", "", "compatibility command", " set publicBaseUrl Set or clear the persisted public base URL", diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts index 134899de..d7a6ee4b 100644 --- a/src/config-operations.test.ts +++ b/src/config-operations.test.ts @@ -42,6 +42,9 @@ try { assert.equal(loadDevspaceFiles().config.host, "0.0.0.0"); assert.match(hostResult.warning ?? "", /may expose DevSpace/); assert.throws(() => setConfigHost("https://example.com"), /Invalid host/); + assert.throws(() => setConfigHost("a..example.com"), /Invalid host/); + assert.throws(() => setConfigHost("api-.example.com"), /Invalid host/); + assert.throws(() => setConfigHost(`${"a".repeat(64)}.example.com`), /Invalid host/); const domainResult = setConfigDomain("devspace.example.com"); assert.equal(loadDevspaceFiles().config.publicBaseUrl, "https://devspace.example.com"); @@ -50,6 +53,12 @@ try { assert.throws(() => setConfigDomain("localhost:8443"), /Domain must be a hostname/); assert.throws(() => setConfigDomain("https://devspace.example.com"), /Domain must be a hostname/); + const legacyPublicBaseUrl = setConfigPublicBaseUrl("https://legacy.example.com:8443/path/?query=value#fragment"); + assert.equal(loadDevspaceFiles().config.publicBaseUrl, "https://legacy.example.com:8443/path"); + assert.match(legacyPublicBaseUrl.message, /https:\/\/legacy\.example\.com:8443\/path/); + assert.throws(() => setConfigPublicBaseUrl("ftp://legacy.example.com"), /must use http or https/); + assert.throws(() => setConfigPublicBaseUrl("not a URL"), /valid http or https URL/); + setConfigPublicBaseUrl("none"); assert.equal(loadDevspaceFiles().config.publicBaseUrl, null); @@ -73,8 +82,7 @@ try { } const shown = buildConfigShowResult(); - assert.notEqual(shown.accessKey, newOwnerPassword); - assert.match(shown.accessKey, /^.{3}\*+/); + assert.equal(shown.accessKey, "********"); assert.throws(() => setConfigKey(""), /Owner password is required/); assert.throws(() => setConfigKey("too-short"), /at least 16 characters/); diff --git a/src/config-operations.ts b/src/config-operations.ts index 41ebbd41..c4052044 100644 --- a/src/config-operations.ts +++ b/src/config-operations.ts @@ -99,7 +99,11 @@ export function setConfigPublicBaseUrl( }; } - return setConfigDomain(trimmed, env); + const publicBaseUrl = normalizeConfiguredPublicBaseUrl(trimmed); + writeDevspaceConfig({ ...files.config, publicBaseUrl }, env); + return { + message: `Updated public base URL to ${publicBaseUrl}. MCP URL: ${new URL(MCP_PATH, publicBaseUrl).toString()}. Restart DevSpace for the change to take effect.`, + }; } export function setConfigKey( @@ -144,7 +148,11 @@ function validateHost(value: string): string { throw new Error(`Invalid host: ${value}`); } if (isIP(host) !== 0 || host === "localhost") return host; - if (!/^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(host)) { + const labels = host.split("."); + if ( + host.length > 253 + || labels.some((label) => !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label)) + ) { throw new Error(`Invalid host: ${value}`); } return host; @@ -165,8 +173,24 @@ function normalizeConfiguredDomain(value: string): string { return `https://${host}`; } +function normalizeConfiguredPublicBaseUrl(value: string): string { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("publicBaseUrl must use http or https."); + } + url.hash = ""; + url.search = ""; + url.pathname = url.pathname.replace(/\/+$/, ""); + return url.toString().replace(/\/$/, ""); + } catch (error) { + if (error instanceof Error && error.message === "publicBaseUrl must use http or https.") { + throw error; + } + throw new Error("publicBaseUrl must be a valid http or https URL."); + } +} + function maskSecret(secret: string | undefined): string { - if (!secret) return "(not configured)"; - if (secret.length <= 6) return "*".repeat(secret.length); - return `${secret.slice(0, 3)}${"*".repeat(Math.max(8, secret.length - 5))}${secret.slice(-2)}`; + return secret ? "********" : "(not configured)"; } From d7a77b621463666cd6d6a04ebd641763dee9ffd1 Mon Sep 17 00:00:00 2001 From: hermes-echo Date: Wed, 24 Jun 2026 11:09:57 +0000 Subject: [PATCH 13/13] fix(config): preserve public base URL paths --- src/config-operations.test.ts | 3 ++- src/config-operations.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts index d7a6ee4b..4b1308a8 100644 --- a/src/config-operations.test.ts +++ b/src/config-operations.test.ts @@ -55,7 +55,8 @@ try { const legacyPublicBaseUrl = setConfigPublicBaseUrl("https://legacy.example.com:8443/path/?query=value#fragment"); assert.equal(loadDevspaceFiles().config.publicBaseUrl, "https://legacy.example.com:8443/path"); - assert.match(legacyPublicBaseUrl.message, /https:\/\/legacy\.example\.com:8443\/path/); + assert.match(legacyPublicBaseUrl.message, /https:\/\/legacy\.example\.com:8443\/path\/mcp/); + assert.equal(buildConfigShowResult().publicUrl, "https://legacy.example.com:8443/path/mcp"); assert.throws(() => setConfigPublicBaseUrl("ftp://legacy.example.com"), /must use http or https/); assert.throws(() => setConfigPublicBaseUrl("not a URL"), /valid http or https URL/); diff --git a/src/config-operations.ts b/src/config-operations.ts index c4052044..8eb6bd28 100644 --- a/src/config-operations.ts +++ b/src/config-operations.ts @@ -38,7 +38,7 @@ export function buildConfigShowResult(env: NodeJS.ProcessEnv = process.env): Con host: settings.host, port: settings.port, publicBaseUrl: settings.publicBaseUrl, - publicUrl: new URL(MCP_PATH, settings.publicBaseUrl).toString(), + publicUrl: buildMcpUrl(settings.publicBaseUrl), allowedHosts: settings.allowedHosts, accessKey: maskSecret(ownerToken), configPath: files.configPath, @@ -81,7 +81,7 @@ export function setConfigDomain(value: string, env: NodeJS.ProcessEnv = process. writeDevspaceConfig({ ...files.config, publicBaseUrl }, env); return { - message: `Updated public domain to ${new URL(publicBaseUrl).hostname}. MCP URL: ${new URL(MCP_PATH, publicBaseUrl).toString()}. Restart DevSpace for the change to take effect.`, + message: `Updated public domain to ${new URL(publicBaseUrl).hostname}. MCP URL: ${buildMcpUrl(publicBaseUrl)}. Restart DevSpace for the change to take effect.`, }; } @@ -102,7 +102,7 @@ export function setConfigPublicBaseUrl( const publicBaseUrl = normalizeConfiguredPublicBaseUrl(trimmed); writeDevspaceConfig({ ...files.config, publicBaseUrl }, env); return { - message: `Updated public base URL to ${publicBaseUrl}. MCP URL: ${new URL(MCP_PATH, publicBaseUrl).toString()}. Restart DevSpace for the change to take effect.`, + message: `Updated public base URL to ${publicBaseUrl}. MCP URL: ${buildMcpUrl(publicBaseUrl)}. Restart DevSpace for the change to take effect.`, }; } @@ -191,6 +191,12 @@ function normalizeConfiguredPublicBaseUrl(value: string): string { } } +function buildMcpUrl(publicBaseUrl: string): string { + const url = new URL(publicBaseUrl); + url.pathname = `${url.pathname.replace(/\/+$/, "")}${MCP_PATH}`; + return url.toString(); +} + function maskSecret(secret: string | undefined): string { return secret ? "********" : "(not configured)"; }