diff --git a/README.md b/README.md index 3605847c..71f96a3b 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,62 @@ 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 + +# 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" +``` + +`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. `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 + +```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: diff --git a/docs/configuration.md b/docs/configuration.md index 71073380..b5f92762 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,10 +22,34 @@ 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 as JSON. Owner passwords are always masked. +npx @waishnav/devspace config + +# 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 + +# Set the Owner password and revoke persisted OAuth clients and tokens. +npx @waishnav/devspace config key "your-new-owner-password" ``` +`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 +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 +`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..1310b182 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,16 +1,84 @@ 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.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/); +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, /\(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 \[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 { + 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"], env), /public domain/); + + 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)"); + + 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)); + const updated = JSON.parse(runCli(["config"], env)) as { accessKey: string }; + assert.equal(updated.accessKey, "********"); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +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: { ...env, ...overrides }, + }); +} diff --git a/src/cli.ts b/src/cli.ts index 87ba662d..591ef0e8 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, + setConfigKey, + setConfigDomain, + setConfigHost, + setConfigPort, + setConfigPublicBaseUrl, + type ConfigUpdateResult, +} from "./config-operations.js"; import { generateOwnerToken, loadDevspaceFiles, @@ -37,11 +46,18 @@ async function main(argv: string[]): Promise { await runDoctor(); return; case "config": - runConfigCommand(args); + await runConfigCommand(args); return; case "help": - printHelp(); - return; + if (args.length === 1 && args[0] === "config") { + printConfigHelp(); + return; + } + if (args.length === 0) { + printHelp(); + return; + } + throw new Error(`Unknown help target: ${args.join(" ")}. Use \`devspace --help \`.`); case "version": printVersion(); return; @@ -49,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"; @@ -228,12 +245,48 @@ async function runDoctor(): Promise { } } -function runConfigCommand(args: string[]): void { +async function runConfigCommand(args: string[]): Promise { const [subcommand, key, ...rest] = args; - const files = loadDevspaceFiles(); - if (!subcommand || subcommand === "get") { - console.log(JSON.stringify(files.config, null, 2)); + if (!subcommand) { + printConfigShow(); + return; + } + + if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") { + throw new Error(`Unknown config command: ${subcommand}. Use \`devspace --help config\`.`); + } + + if (subcommand === "get") { + console.log(JSON.stringify(loadDevspaceFiles().config, null, 2)); + 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 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.", + `Saved to: ${result.authPath}`, + "Restart DevSpace for the new Owner password to take effect.", + ].join("\n")); return; } @@ -249,47 +302,79 @@ 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)); +} + +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)); +} + +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] []", "", - "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", + "These are common DevSpace commands used in various situations:", "", - "For temporary tunnels:", - " DEVSPACE_PUBLIC_BASE_URL=https://example.trycloudflare.com devspace serve", + "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 current effective settings", + "", + "get help and version information", + " help Show this help", + " version Print the installed version", + "", + "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"), ); } -function printVersion(): void { - const packageJson = require("../package.json") as { version?: unknown }; - if (typeof packageJson.version !== "string") { - throw new Error("Unable to read DevSpace package version."); - } - - 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 printConfigHelp(): void { + console.log( + [ + "usage: devspace config [ []]", + "", + "These are common DevSpace configuration commands:", + "", + "inspect effective settings", + " (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 domain; MCP uses /mcp automatically", + " 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", + "", + "Configuration changes are saved locally. Restart DevSpace for them to take effect.", + ].join("\n"), + ); } function normalizePublicBaseUrl(value: string): string { @@ -301,6 +386,15 @@ function normalizePublicBaseUrl(value: string): string { return parsed.toString().replace(/\/$/, ""); } +function printVersion(): void { + const packageJson = require("../package.json") as { version?: unknown }; + if (typeof packageJson.version !== "string") { + throw new Error("Unable to read DevSpace package version."); + } + + console.log(packageJson.version); +} + type TextPromptOptions = Omit[0], "validate"> & { defaultValue: string; validate?: (value: string | undefined) => string | Error | undefined; diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts new file mode 100644 index 00000000..4b1308a8 --- /dev/null +++ b/src/config-operations.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildConfigShowResult, + setConfigDomain, + setConfigHost, + setConfigKey, + 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-")); +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"); + +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/); + 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"); + assert.equal(domainResult.warning, undefined); + 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/); + + 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\/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/); + + 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 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 { + assert.equal(clearedStore.getClient(client.client_id), undefined); + } finally { + clearedStore.close(); + } + + const shown = buildConfigShowResult(); + assert.equal(shown.accessKey, "********"); + + assert.throws(() => setConfigKey(""), /Owner password is required/); + assert.throws(() => setConfigKey("too-short"), /at least 16 characters/); + assert.throws( + () => 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-")); + 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(() => setConfigKey("new-owner-password-for-test", 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; + delete process.env.DEVSPACE_STATE_DIR; +} diff --git a/src/config-operations.ts b/src/config-operations.ts new file mode 100644 index 00000000..8eb6bd28 --- /dev/null +++ b/src/config-operations.ts @@ -0,0 +1,202 @@ +import { isIP } from "node:net"; +import { loadServerSettings } from "./config.js"; +import { SqliteOAuthStore } from "./oauth-store.js"; +import { + 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 ConfigKeyUpdateResult { + 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: buildMcpUrl(settings.publicBaseUrl), + 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 = normalizeConfiguredDomain(value); + const files = loadDevspaceFiles(env); + writeDevspaceConfig({ ...files.config, publicBaseUrl }, env); + + return { + message: `Updated public domain to ${new URL(publicBaseUrl).hostname}. MCP URL: ${buildMcpUrl(publicBaseUrl)}. Restart DevSpace for the change to take effect.`, + }; +} + +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.", + }; + } + + const publicBaseUrl = normalizeConfiguredPublicBaseUrl(trimmed); + writeDevspaceConfig({ ...files.config, publicBaseUrl }, env); + return { + message: `Updated public base URL to ${publicBaseUrl}. MCP URL: ${buildMcpUrl(publicBaseUrl)}. Restart DevSpace for the change to take effect.`, + }; +} + +export function setConfigKey( + value: string, + env: NodeJS.ProcessEnv = process.env, +): ConfigKeyUpdateResult { + if (env.DEVSPACE_OAUTH_OWNER_TOKEN?.trim()) { + throw new Error( + "Cannot update the persisted Owner password while DEVSPACE_OAUTH_OWNER_TOKEN is set. Unset it first, then run `devspace config key ` again.", + ); + } + + const ownerToken = validateOwnerToken(value); + const stateDir = loadServerSettings(env).stateDir; + const store = new SqliteOAuthStore(stateDir); + + try { + store.clearAll(); + } finally { + store.close(); + } + + const authPath = writeDevspaceAuth({ ownerToken }, env); + 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 { + 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; + 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; +} + +function isPublicHost(host: string): boolean { + return !["127.0.0.1", "localhost", "::1"].includes(host); +} + +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."); + } + + const host = validateHost(domain); + 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 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)"; +} 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(); }