From 11fdcf966f1b7ac3e6d63a7a906853019be2e9f4 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:28:14 +0530 Subject: [PATCH 01/11] feat(config): define versioned JSONC schema --- package-lock.json | 11 +- package.json | 5 +- schema/v1/devspace.schema.json | 301 ++++++++++++++++++++++++++++++ scripts/generate-config-schema.ts | 10 + src/config-schema.test.ts | 27 +++ src/config-schema.ts | 97 ++++++++++ 6 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 schema/v1/devspace.schema.json create mode 100644 scripts/generate-config-schema.ts create mode 100644 src/config-schema.test.ts create mode 100644 src/config-schema.ts diff --git a/package-lock.json b/package-lock.json index 5b5c247a4..c0563885c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "jsonc-parser": "^3.3.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -782,7 +783,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -1087,7 +1088,7 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { - "version": "1.0.3", + "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" @@ -4450,6 +4451,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/package.json b/package.json index 301edf4c4..dfa5cf557 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dist", "docs", "examples", + "schema", "scripts", "skills", "README.md" @@ -28,9 +29,10 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", + "schema:config": "tsx scripts/generate-config-schema.ts", "start": "node dist/cli.js serve", "test": "tsx src/user-config.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsx src/config-schema.test.ts && tsc -p tsconfig.json --noEmit" }, "keywords": [], "author": "", @@ -51,6 +53,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "jsonc-parser": "^3.3.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json new file mode 100644 index 000000000..e7c18466e --- /dev/null +++ b/schema/v1/devspace.schema.json @@ -0,0 +1,301 @@ +{ + "$id": "https://raw.githubusercontent.com/Waishnav/devspace/main/schema/v1/devspace.schema.json", + "title": "DevSpace configuration", + "description": "Versioned configuration for a local DevSpace MCP server.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "default": "https://raw.githubusercontent.com/Waishnav/devspace/main/schema/v1/devspace.schema.json", + "type": "string", + "format": "uri" + }, + "configVersion": { + "type": "number", + "const": 1 + }, + "server": { + "default": {}, + "type": "object", + "properties": { + "host": { + "default": "127.0.0.1", + "type": "string", + "minLength": 1 + }, + "port": { + "default": 7676, + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "publicBaseUrl": { + "default": null, + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "allowedHosts": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "trustProxy": { + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "workspaces": { + "default": {}, + "type": "object", + "properties": { + "allowedRoots": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "worktreeRoot": { + "default": "~/.devspace/worktrees", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "storage": { + "default": {}, + "type": "object", + "properties": { + "stateDir": { + "default": "~/.local/share/devspace", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "tools": { + "default": {}, + "type": "object", + "properties": { + "mode": { + "default": "codex", + "type": "string", + "enum": [ + "claude", + "codex" + ] + } + }, + "additionalProperties": false + }, + "ui": { + "default": {}, + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "artifacts": { + "default": {}, + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "maxFileBytes": { + "default": 104857600, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "skills": { + "default": {}, + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "paths": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "agentDir": { + "default": "~/.codex", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "subagents": { + "default": { + "enabled": false, + "providers": [] + }, + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": [ + "codex", + "claude", + "opencode", + "pi", + "cursor", + "copilot", + "grok" + ] + }, + "enabled": { + "type": "boolean" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "effort": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "enabled" + ], + "additionalProperties": false + } + } + }, + "required": [ + "enabled", + "providers" + ], + "additionalProperties": false + }, + "logging": { + "default": {}, + "type": "object", + "properties": { + "level": { + "default": "info", + "type": "string", + "enum": [ + "silent", + "error", + "warn", + "info", + "debug" + ] + }, + "format": { + "default": "json", + "type": "string", + "enum": [ + "json", + "pretty" + ] + }, + "requests": { + "default": true, + "type": "boolean" + }, + "assets": { + "default": false, + "type": "boolean" + }, + "toolCalls": { + "default": true, + "type": "boolean" + }, + "shellCommands": { + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "oauth": { + "default": {}, + "type": "object", + "properties": { + "accessTokenTtlSeconds": { + "default": 3600, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "refreshTokenTtlSeconds": { + "default": 2592000, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "scopes": { + "default": [ + "devspace" + ], + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "allowedRedirectHosts": { + "default": [ + "chatgpt.com", + "localhost", + "127.0.0.1" + ], + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "configVersion" + ], + "additionalProperties": false +} diff --git a/scripts/generate-config-schema.ts b/scripts/generate-config-schema.ts new file mode 100644 index 000000000..2e56f4a8e --- /dev/null +++ b/scripts/generate-config-schema.ts @@ -0,0 +1,10 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { + devspaceConfigJsonSchema, +} from "../src/config-schema.js"; + +const outputPath = resolve("schema/v1/devspace.schema.json"); + +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\n`); diff --git a/src/config-schema.test.ts b/src/config-schema.test.ts new file mode 100644 index 000000000..f76c9ac60 --- /dev/null +++ b/src/config-schema.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + defaultDevspaceConfig, + devspaceConfigJsonSchema, + devspaceConfigSchema, +} from "./config-schema.js"; + +const defaults = defaultDevspaceConfig(); +assert.equal(defaults.configVersion, 1); +assert.equal(defaults.tools.mode, "codex"); +assert.equal(defaults.ui.enabled, true); + +assert.throws( + () => devspaceConfigSchema.parse({ configVersion: 1, typo: true }), + /Unrecognized key/, +); + +const generatedSchema = `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\n`; +const committedSchema = readFileSync( + resolve("schema/v1/devspace.schema.json"), + "utf8", +); +assert.equal(committedSchema, generatedSchema, "run `npm run schema:config` after changing config-schema.ts"); + +console.log("config schema tests passed"); diff --git a/src/config-schema.ts b/src/config-schema.ts new file mode 100644 index 000000000..c30bb3612 --- /dev/null +++ b/src/config-schema.ts @@ -0,0 +1,97 @@ +import * as z from "zod/v4"; +import { subagentsConfigSchema } from "./local-agent-config.js"; + +export const DEVSPACE_CONFIG_VERSION = 1 as const; +export const DEVSPACE_CONFIG_SCHEMA_URL = + "https://raw.githubusercontent.com/Waishnav/devspace/main/schema/v1/devspace.schema.json"; + +const serverConfigSchema = z.object({ + host: z.string().trim().min(1).default("127.0.0.1"), + port: z.number().int().min(1).max(65_535).default(7676), + publicBaseUrl: z.string().url().nullable().default(null), + allowedHosts: z.array(z.string().trim().min(1)).default([]), + trustProxy: z.boolean().default(false), +}).strict().prefault({}); + +const workspacesConfigSchema = z.object({ + allowedRoots: z.array(z.string().trim().min(1)).default([]), + worktreeRoot: z.string().trim().min(1).default("~/.devspace/worktrees"), +}).strict().prefault({}); + +const storageConfigSchema = z.object({ + stateDir: z.string().trim().min(1).default("~/.local/share/devspace"), +}).strict().prefault({}); + +const toolsConfigSchema = z.object({ + mode: z.enum(["claude", "codex"]).default("codex"), +}).strict().prefault({}); + +const uiConfigSchema = z.object({ + enabled: z.boolean().default(true), +}).strict().prefault({}); + +const artifactsConfigSchema = z.object({ + enabled: z.boolean().default(false), + maxFileBytes: z.number().int().positive().default(100 * 1024 * 1024), +}).strict().prefault({}); + +const skillsConfigSchema = z.object({ + enabled: z.boolean().default(true), + paths: z.array(z.string().trim().min(1)).default([]), + agentDir: z.string().trim().min(1).default("~/.codex"), +}).strict().prefault({}); + +const loggingConfigSchema = z.object({ + level: z.enum(["silent", "error", "warn", "info", "debug"]).default("info"), + format: z.enum(["json", "pretty"]).default("json"), + requests: z.boolean().default(true), + assets: z.boolean().default(false), + toolCalls: z.boolean().default(true), + shellCommands: z.boolean().default(false), +}).strict().prefault({}); + +const oauthConfigSchema = z.object({ + accessTokenTtlSeconds: z.number().int().positive().default(60 * 60), + refreshTokenTtlSeconds: z.number().int().positive().default(30 * 24 * 60 * 60), + scopes: z.array(z.string().trim().min(1)).min(1).default(["devspace"]), + allowedRedirectHosts: z.array(z.string().trim().min(1)).min(1).default([ + "chatgpt.com", + "localhost", + "127.0.0.1", + ]), +}).strict().prefault({}); + +export const devspaceConfigSchema = z.object({ + $schema: z.string().url().default(DEVSPACE_CONFIG_SCHEMA_URL), + configVersion: z.literal(DEVSPACE_CONFIG_VERSION), + server: serverConfigSchema, + workspaces: workspacesConfigSchema, + storage: storageConfigSchema, + tools: toolsConfigSchema, + ui: uiConfigSchema, + artifacts: artifactsConfigSchema, + skills: skillsConfigSchema, + subagents: subagentsConfigSchema.default({ enabled: false, providers: [] }), + logging: loggingConfigSchema, + oauth: oauthConfigSchema, +}).strict(); + +export type DevspaceConfig = z.output; +export type DevspaceConfigInput = z.input; +export type ToolMode = DevspaceConfig["tools"]["mode"]; + +export function defaultDevspaceConfig(): DevspaceConfig { + return devspaceConfigSchema.parse({ configVersion: DEVSPACE_CONFIG_VERSION }); +} + +export function devspaceConfigJsonSchema(): object { + return { + $id: DEVSPACE_CONFIG_SCHEMA_URL, + title: "DevSpace configuration", + description: "Versioned configuration for a local DevSpace MCP server.", + ...z.toJSONSchema(devspaceConfigSchema, { + target: "draft-2020-12", + io: "input", + }), + }; +} From 7743f320e6e2cc4a1ae0ace3cb6dc82ff0ff2d77 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:34:01 +0530 Subject: [PATCH 02/11] feat(config): migrate legacy JSON once --- src/cli.ts | 47 +++--- src/config-migration.ts | 96 ++++++++++++ src/config.test.ts | 269 ++++++++++++++------------------- src/config.ts | 248 +++++++----------------------- src/local-agent-client.ts | 6 +- src/local-agent-config.test.ts | 15 +- src/local-agent-config.ts | 26 +--- src/user-config.test.ts | 110 +++++++++++--- src/user-config.ts | 178 +++++++++++++++++----- 9 files changed, 534 insertions(+), 461 deletions(-) create mode 100644 src/config-migration.ts diff --git a/src/cli.ts b/src/cli.ts index 7cf723f8c..c745e791e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,6 @@ import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; import { loadConfig } from "./config.js"; import { resolveCliWorkspaceContext } from "./cli-workspace.js"; -import { resolveSubagentsConfig } from "./local-agent-config.js"; import { getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js"; @@ -46,6 +45,7 @@ import { import { generateOwnerToken, loadDevspaceFiles, + setDevspaceConfigValue, writeDevspaceAuth, writeDevspaceConfig, type DevspaceUserConfig, @@ -143,7 +143,7 @@ async function runInit({ force }: { force: boolean }): Promise { hint: "Use DevSpace from Codex, Claude Code, OpenCode, Pi, and similar tools.", }, ], - initialValues: files.config.publicBaseUrl ? ["chatgpt"] : ["coding-agents"], + initialValues: files.config.server.publicBaseUrl ? ["chatgpt"] : ["coding-agents"], required: true, }); if (prompts.isCancel(destinationAnswer)) throw new SetupCancelledError(); @@ -153,7 +153,7 @@ async function runInit({ force }: { force: boolean }): Promise { let allowedRoots: string[] | undefined; if (useChatGpt) { - const defaultRoots = files.config.allowedRoots?.join(", ") || process.cwd(); + const defaultRoots = files.config.workspaces.allowedRoots.join(", ") || process.cwd(); const rootsAnswer = await textPrompt({ message: `Which project folders can DevSpace access? Press Enter to use ${defaultRoots}`, placeholder: defaultRoots, @@ -166,7 +166,7 @@ async function runInit({ force }: { force: boolean }): Promise { .filter(Boolean); } - const port = isValidPort(files.config.port) ? files.config.port : 7676; + const port = files.config.server.port; let publicBaseUrl: string | null = null; if (useChatGpt) { @@ -180,16 +180,16 @@ async function runInit({ force }: { force: boolean }): Promise { "Connect ChatGPT", ); publicBaseUrl = normalizePublicBaseUrl(await textPrompt({ - message: files.config.publicBaseUrl - ? `What public URL will ChatGPT connect to? Press Enter to keep ${files.config.publicBaseUrl}` + message: files.config.server.publicBaseUrl + ? `What public URL will ChatGPT connect to? Press Enter to keep ${files.config.server.publicBaseUrl}` : "What public URL will ChatGPT connect to?", - placeholder: files.config.publicBaseUrl ?? "https://your-tunnel-host.example.com", - defaultValue: files.config.publicBaseUrl ?? "", + placeholder: files.config.server.publicBaseUrl ?? "https://your-tunnel-host.example.com", + defaultValue: files.config.server.publicBaseUrl ?? "", validate: validateRequiredPublicBaseUrl, })); } - const currentSubagents = resolveSubagentsConfig(files.config.subagents, {}); + const currentSubagents = files.config.subagents; const availability = getLocalAgentProviderAvailabilitySnapshot(); const configuredProviders = currentSubagents.providers .filter((provider) => provider.enabled) @@ -220,10 +220,16 @@ async function runInit({ force }: { force: boolean }): Promise { const config: DevspaceUserConfig = { ...files.config, - host: files.config.host ?? "127.0.0.1", - port, - ...(allowedRoots ? { allowedRoots } : {}), - publicBaseUrl, + server: { + ...files.config.server, + host: files.config.server.host, + port, + publicBaseUrl, + }, + workspaces: { + ...files.config.workspaces, + ...(allowedRoots ? { allowedRoots } : {}), + }, subagents, }; const auth = { @@ -295,7 +301,7 @@ async function serve(): Promise { console.log(`allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`); if (config.allowedHosts.includes("*")) { - console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); + console.warn("warning: Host header allowlist is disabled because server.allowedHosts contains '*'"); } console.log("auth: Owner password approval required"); console.log(`logging: ${config.logging.level} ${config.logging.format}`); @@ -369,10 +375,10 @@ function runConfigCommand(args: string[]): void { throw new Error("Missing publicBaseUrl value."); } - writeDevspaceConfig({ - ...files.config, - publicBaseUrl: normalizeOptionalPublicBaseUrl(value), - }); + setDevspaceConfigValue( + ["server", "publicBaseUrl"], + normalizeOptionalPublicBaseUrl(value), + ); console.log(`Updated ${files.configPath}`); } @@ -384,7 +390,7 @@ function printHelp(): void { "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 init Create or update ~/.devspace/config.jsonc and auth.json", " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", " devspace config set publicBaseUrl ", @@ -396,7 +402,8 @@ function printHelp(): void { " devspace -v, --version Print the installed version", "", "For temporary tunnels:", - " DEVSPACE_PUBLIC_BASE_URL=https://example.trycloudflare.com devspace serve", + " devspace config set publicBaseUrl https://example.trycloudflare.com", + " devspace serve", ].join("\n"), ); } diff --git a/src/config-migration.ts b/src/config-migration.ts new file mode 100644 index 000000000..f24851adb --- /dev/null +++ b/src/config-migration.ts @@ -0,0 +1,96 @@ +import * as z from "zod/v4"; +import { + DEVSPACE_CONFIG_VERSION, + devspaceConfigSchema, + type DevspaceConfig, +} from "./config-schema.js"; +import { storedSubagentsConfigSchema } from "./local-agent-config.js"; +import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js"; + +const legacyConfigSchema = z.object({ + host: z.string().optional(), + port: z.number().optional(), + allowedRoots: z.array(z.string()).optional(), + publicBaseUrl: z.string().nullable().optional(), + allowedHosts: z.array(z.string()).optional(), + stateDir: z.string().optional(), + worktreeRoot: z.string().optional(), + artifactsEnabled: z.boolean().optional(), + artifactMaxFileBytes: z.number().optional(), + agentDir: z.string().optional(), + subagents: storedSubagentsConfigSchema.optional(), + tools: z.object({ + mode: z.enum(["claude", "codex"]).optional(), + }).strict().optional(), + ui: z.object({ + enabled: z.boolean().optional(), + }).strict().optional(), +}).passthrough(); + +const LEGACY_CONFIG_KEYS = new Set([ + "host", + "port", + "allowedRoots", + "publicBaseUrl", + "allowedHosts", + "stateDir", + "worktreeRoot", + "artifactsEnabled", + "artifactMaxFileBytes", + "agentDir", + "subagents", + "tools", + "ui", +]); + +export function migrateLegacyConfig(value: unknown): DevspaceConfig { + const legacy = legacyConfigSchema.parse(value); + const unsupportedKeys = Object.keys(legacy).filter((key) => !LEGACY_CONFIG_KEYS.has(key)); + if (unsupportedKeys.length > 0) { + throw new Error( + `Unsupported legacy configuration keys: ${unsupportedKeys.sort().join(", ")}`, + ); + } + + return devspaceConfigSchema.parse({ + configVersion: DEVSPACE_CONFIG_VERSION, + server: definedEntries({ + host: legacy.host, + port: legacy.port, + publicBaseUrl: legacy.publicBaseUrl, + allowedHosts: legacy.allowedHosts, + }), + workspaces: definedEntries({ + allowedRoots: legacy.allowedRoots, + worktreeRoot: legacy.worktreeRoot, + }), + storage: definedEntries({ stateDir: legacy.stateDir }), + tools: definedEntries({ mode: legacy.tools?.mode }), + ui: definedEntries({ enabled: legacy.ui?.enabled }), + artifacts: definedEntries({ + enabled: legacy.artifactsEnabled, + maxFileBytes: legacy.artifactMaxFileBytes, + }), + skills: definedEntries({ agentDir: legacy.agentDir }), + subagents: migrateLegacySubagents(legacy.subagents), + }); +} + +function definedEntries>(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter((entry) => entry[1] !== undefined), + ) as Partial; +} + +function migrateLegacySubagents( + value: z.infer | undefined, +): unknown { + if (value === undefined) return undefined; + if (typeof value !== "boolean") return value; + return { + enabled: value, + providers: value + ? LOCAL_AGENT_PROVIDERS.map((id) => ({ id, enabled: true })) + : [], + }; +} diff --git a/src/config.test.ts b/src/config.test.ts index bb464d844..c266b1e8d 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,167 +1,126 @@ import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { loadConfig } from "./config.js"; +import { writeDevspaceAuth, writeDevspaceConfig } from "./user-config.js"; -const emptyConfigDir = mkdtempSync(join(tmpdir(), "devspace-empty-config-test-")); -const baseEnv = { - DEVSPACE_CONFIG_DIR: emptyConfigDir, - DEVSPACE_ALLOWED_ROOTS: process.cwd(), +const configDir = mkdtempSync(join(tmpdir(), "devspace-config-test-")); +const env = { + DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }; -assert.equal(loadConfig(baseEnv).uiEnabled, true); -assert.equal(loadConfig(baseEnv).toolMode, "codex"); -assert.equal(loadConfig(baseEnv).skillsEnabled, true); -assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); -assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); -assert.deepEqual(loadConfig(baseEnv).subagents, { enabled: false, providers: [] }); -assert.equal(loadConfig(baseEnv).artifactsEnabled, false); -assert.equal(loadConfig(baseEnv).artifactMaxFileBytes, 100 * 1024 * 1024); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_ARTIFACTS: "1" }).artifactsEnabled, true); -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "123" }).artifactMaxFileBytes, - 123, -); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); -assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).subagents, { - enabled: true, - providers: [], -}); -assert.deepEqual(loadConfig(baseEnv).logging, { - level: "info", - format: "json", - requests: true, - assets: false, - toolCalls: true, - shellCommands: false, - trustProxy: false, -}); - -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "silent" }).logging.level, "silent"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "error" }).logging.level, "error"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "warn" }).logging.level, "warn"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "info" }).logging.level, "info"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "debug" }).logging.level, "debug"); - -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_FORMAT: "json" }).logging.format, "json"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_FORMAT: "pretty" }).logging.format, "pretty"); - -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_REQUESTS: "0" }).logging.requests, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_ASSETS: "1" }).logging.assets, true); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_TOOL_CALLS: "0" }).logging.toolCalls, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_SHELL_COMMANDS: "1" }).logging.shellCommands, true); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TRUST_PROXY: "1" }).logging.trustProxy, true); - -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "trace" }), - /Invalid DEVSPACE_LOG_LEVEL: trace/, -); - -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_LOG_FORMAT: "color" }), - /Invalid DEVSPACE_LOG_FORMAT: color/, -); - -assert.equal(loadConfig(baseEnv).oauth.ownerToken, "test-owner-token-that-is-long-enough"); -assert.deepEqual(loadConfig(baseEnv).oauth.scopes, ["devspace"]); -assert.deepEqual(loadConfig(baseEnv).oauth.allowedRedirectHosts, [ - "chatgpt.com", - "localhost", - "127.0.0.1", -]); -assert.equal(loadConfig(baseEnv).oauth.accessTokenTtlSeconds, 3600); -assert.equal(loadConfig(baseEnv).oauth.refreshTokenTtlSeconds, 2592000); +try { + const defaults = loadConfig(env); + assert.equal(defaults.host, "127.0.0.1"); + assert.equal(defaults.port, 7676); + assert.equal(defaults.publicBaseUrl, "http://127.0.0.1:7676"); + assert.deepEqual(defaults.allowedRoots, [process.cwd()]); + assert.deepEqual(defaults.allowedHosts, ["localhost", "127.0.0.1", "::1"]); + assert.equal(defaults.toolMode, "codex"); + assert.equal(defaults.uiEnabled, true); + assert.equal(defaults.skillsEnabled, true); + assert.equal(defaults.artifactsEnabled, false); + assert.deepEqual(defaults.subagents, { enabled: false, providers: [] }); + assert.deepEqual(defaults.logging, { + level: "info", + format: "json", + requests: true, + assets: false, + toolCalls: true, + shellCommands: false, + trustProxy: false, + }); -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_SCOPES: "devspace,admin" }).oauth.scopes, - ["devspace", "admin"], -); -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS: "chatgpt.com,example.com" }).oauth - .allowedRedirectHosts, - ["chatgpt.com", "example.com"], -); -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: "120" }).oauth - .accessTokenTtlSeconds, - 120, -); -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS: "240" }).oauth - .refreshTokenTtlSeconds, - 240, -); - -assert.throws( - () => loadConfig({ DEVSPACE_CONFIG_DIR: emptyConfigDir, DEVSPACE_ALLOWED_ROOTS: process.cwd() }), - /DEVSPACE_OAUTH_OWNER_TOKEN is required/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_OAUTH_OWNER_TOKEN: "too-short" }), - /DEVSPACE_OAUTH_OWNER_TOKEN must be at least 16 characters long/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: "0" }), - /Invalid DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: 0/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "0" }), - /Invalid DEVSPACE_ARTIFACT_MAX_FILE_BYTES: 0/, -); + writeDevspaceConfig({ + configVersion: 1, + server: { + host: "0.0.0.0", + port: 8787, + publicBaseUrl: "https://devspace.example.com/", + allowedHosts: ["example.internal"], + trustProxy: true, + }, + workspaces: { + allowedRoots: ["~/work"], + worktreeRoot: "~/trees", + }, + storage: { stateDir: "~/state" }, + tools: { mode: "claude" }, + ui: { enabled: false }, + artifacts: { enabled: true, maxFileBytes: 321 }, + skills: { enabled: false, paths: ["~/skills"], agentDir: "~/agent" }, + subagents: { + enabled: true, + providers: [{ id: "codex", enabled: true }], + }, + logging: { + level: "debug", + format: "pretty", + requests: false, + assets: true, + toolCalls: false, + shellCommands: true, + }, + oauth: { + accessTokenTtlSeconds: 120, + refreshTokenTtlSeconds: 240, + scopes: ["devspace", "admin"], + allowedRedirectHosts: ["chatgpt.com", "example.com"], + }, + }, env); + writeDevspaceAuth({ ownerToken: "persisted-owner-token-long-enough" }, env); -assert.equal(loadConfig(baseEnv).publicBaseUrl, "http://127.0.0.1:7676"); -assert.deepEqual(loadConfig(baseEnv).allowedHosts, ["localhost", "127.0.0.1", "::1"]); + const configured = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); + assert.equal(configured.host, "0.0.0.0"); + assert.equal(configured.port, 8787); + assert.equal(configured.publicBaseUrl, "https://devspace.example.com"); + assert.deepEqual(configured.allowedRoots, [resolve("~/work".replace("~", process.env.HOME!))]); + assert.deepEqual(configured.allowedHosts, [ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + "devspace.example.com", + "example.internal", + ]); + assert.equal(configured.toolMode, "claude"); + assert.equal(configured.uiEnabled, false); + assert.equal(configured.stateDir, resolve(process.env.HOME!, "state")); + assert.equal(configured.worktreeRoot, resolve(process.env.HOME!, "trees")); + assert.equal(configured.artifactsEnabled, true); + assert.equal(configured.artifactMaxFileBytes, 321); + assert.equal(configured.skillsEnabled, false); + assert.deepEqual(configured.skillPaths, [resolve(process.env.HOME!, "skills")]); + assert.equal(configured.agentDir, resolve(process.env.HOME!, "agent")); + assert.equal(configured.subagents.enabled, true); + assert.equal(configured.oauth.ownerToken, "persisted-owner-token-long-enough"); + assert.equal(configured.oauth.accessTokenTtlSeconds, 120); + assert.deepEqual(configured.oauth.scopes, ["devspace", "admin"]); + assert.deepEqual(configured.logging, { + level: "debug", + format: "pretty", + requests: false, + assets: true, + toolCalls: false, + shellCommands: true, + trustProxy: true, + }); -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_PUBLIC_BASE_URL: "https://abc.trycloudflare.com/" }).publicBaseUrl, - "https://abc.trycloudflare.com", -); -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_PUBLIC_BASE_URL: "https://abc.trycloudflare.com/" }).allowedHosts, - ["localhost", "127.0.0.1", "::1", "abc.trycloudflare.com"], -); -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_ALLOWED_HOSTS: "*" }).allowedHosts, - ["*"], -); + assert.equal(loadConfig(env).oauth.ownerToken, env.DEVSPACE_OAUTH_OWNER_TOKEN); +} finally { + rmSync(configDir, { recursive: true, force: true }); +} -const configDir = mkdtempSync(join(tmpdir(), "devspace-config-test-")); -writeFileSync( - join(configDir, "config.json"), - JSON.stringify({ - port: 8787, - allowedRoots: [process.cwd()], - publicBaseUrl: "https://devspace.example.com", - subagents: true, - artifactsEnabled: true, - artifactMaxFileBytes: 321, - tools: { mode: "claude" }, - ui: { enabled: false }, - }), -); -writeFileSync( - join(configDir, "auth.json"), - JSON.stringify({ - ownerToken: "persisted-owner-token-long-enough", - }), -); +const missingAuthDir = mkdtempSync(join(tmpdir(), "devspace-config-no-auth-test-")); +try { + assert.throws( + () => loadConfig({ DEVSPACE_CONFIG_DIR: missingAuthDir }), + /OAuth owner token is required/, + ); +} finally { + rmSync(missingAuthDir, { recursive: true, force: true }); +} -const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); -assert.equal(fileConfig.port, 8787); -assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); -assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); -assert.equal(fileConfig.subagents.enabled, true); -assert.equal(fileConfig.subagents.providers.length, 7); -assert.equal(fileConfig.artifactsEnabled, true); -assert.equal(fileConfig.artifactMaxFileBytes, 321); -assert.equal(fileConfig.toolMode, "claude"); -assert.equal(fileConfig.uiEnabled, false); -assert.deepEqual(fileConfig.allowedHosts, [ - "localhost", - "127.0.0.1", - "::1", - "devspace.example.com", -]); +console.log("config tests passed"); diff --git a/src/config.ts b/src/config.ts index e42365574..c0e8f943a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,15 +1,12 @@ -import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { resolve } from "node:path"; +import type { ToolMode } from "./config-schema.js"; import { expandHomePath } from "./roots.js"; -import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; +import type { LoggingConfig } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; -import { resolveSubagentsConfig, type SubagentsConfig } from "./local-agent-config.js"; +import type { SubagentsConfig } from "./local-agent-config.js"; -export type ToolMode = "claude" | "codex"; -const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; -const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; -const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024; +export type { ToolMode } from "./config-schema.js"; export interface ServerConfig { host: string; @@ -33,168 +30,13 @@ export interface ServerConfig { logging: LoggingConfig; } -function parsePort(value: string | number | undefined): number { - if (value === undefined || value === "") return 7676; - - const port = Number(value); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(`Invalid PORT: ${value}`); - } - - return port; -} - -function parseAllowedRoots(value: string | string[] | undefined): string[] { - if (Array.isArray(value)) { - const roots = value.map((entry) => entry.trim()).filter(Boolean); - return (roots.length > 0 ? roots : [process.cwd()]).map((root) => resolve(expandHomePath(root))); - } - - const rawRoots = - value - ?.split(",") - .map((entry) => entry.trim()) - .filter(Boolean) ?? []; - - const roots = rawRoots.length > 0 ? rawRoots : [process.cwd()]; - return roots.map((root) => resolve(expandHomePath(root))); -} - -function parseAllowedHosts(value: string | string[] | undefined, derivedHosts: string[]): string[] { - if (Array.isArray(value)) { - return normalizeAllowedHosts(value, derivedHosts); - } - - const rawHosts = - value - ?.split(",") - .map((entry) => entry.trim()) - .filter(Boolean) ?? []; - - return normalizeAllowedHosts(rawHosts, derivedHosts); -} - -function normalizeAllowedHosts(rawHosts: string[], derivedHosts: string[]): string[] { - const hosts = rawHosts.length > 0 ? rawHosts : derivedHosts; - if (hosts.includes("*")) return ["*"]; - return Array.from(new Set(hosts.map((host) => host.trim()).filter(Boolean))); -} - -function parseBoolean(value: string | undefined): boolean { - return ["1", "true", "yes", "on"].includes(value?.toLowerCase() ?? ""); -} - -function parseLogLevel(value: string | undefined): LogLevel { - if (!value || value === "info") return "info"; - if (["silent", "error", "warn", "debug"].includes(value)) return value as LogLevel; - - throw new Error(`Invalid DEVSPACE_LOG_LEVEL: ${value}`); -} - -function parseLogFormat(value: string | undefined): LogFormat { - if (!value || value === "json") return "json"; - if (value === "pretty") return "pretty"; - - throw new Error(`Invalid DEVSPACE_LOG_FORMAT: ${value}`); -} - -function parsePathList(value: string | undefined): string[] { - return ( - value - ?.split(",") - .map((entry) => entry.trim()) - .filter(Boolean) ?? [] - ); -} - -function parseStringList(value: string | undefined, fallback: string[]): string[] { - const entries = value - ?.split(",") - .map((entry) => entry.trim()) - .filter(Boolean); - - return entries && entries.length > 0 ? entries : fallback; -} - -function parsePositiveInteger( - value: string | undefined, - fallback: number, - name: string, - max = Number.MAX_SAFE_INTEGER, -): number { - if (!value) return fallback; - - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) { - throw new Error(`Invalid ${name}: ${value}`); - } - - return parsed; -} - -function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { - return { - level: parseLogLevel(env.DEVSPACE_LOG_LEVEL), - format: parseLogFormat(env.DEVSPACE_LOG_FORMAT), - requests: env.DEVSPACE_LOG_REQUESTS === undefined ? true : parseBoolean(env.DEVSPACE_LOG_REQUESTS), - assets: parseBoolean(env.DEVSPACE_LOG_ASSETS), - toolCalls: env.DEVSPACE_LOG_TOOL_CALLS === undefined ? true : parseBoolean(env.DEVSPACE_LOG_TOOL_CALLS), - shellCommands: parseBoolean(env.DEVSPACE_LOG_SHELL_COMMANDS), - trustProxy: parseBoolean(env.DEVSPACE_TRUST_PROXY), - }; -} - -function parseRequiredSecret(value: string | undefined, name: string): string { - const secret = value?.trim(); - if (!secret) { - throw new Error(`${name} is required for DevSpace OAuth. Run: devspace init`); - } - if (secret.length < 16) { - throw new Error(`${name} must be at least 16 characters long.`); - } - return secret; -} - -function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined): OAuthConfig { - return { - ownerToken: parseRequiredSecret(env.DEVSPACE_OAUTH_OWNER_TOKEN ?? ownerToken, "DEVSPACE_OAUTH_OWNER_TOKEN"), - accessTokenTtlSeconds: parsePositiveInteger( - env.DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS, - DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS, - "DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS", - ), - refreshTokenTtlSeconds: parsePositiveInteger( - env.DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS, - DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS, - "DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS", - ), - scopes: parseStringList(env.DEVSPACE_OAUTH_SCOPES, ["devspace"]), - allowedRedirectHosts: parseStringList(env.DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS, [ - "chatgpt.com", - "localhost", - "127.0.0.1", - ]), - }; -} - -function defaultStateDir(): string { - return join(homedir(), ".local", "share", "devspace"); -} - -function defaultWorktreeRoot(): string { - return join(homedir(), ".devspace", "worktrees"); -} - -function defaultAgentDir(): string { - return join(homedir(), ".codex"); -} - export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { const files = loadDevspaceFiles(env); - const host = env.HOST ?? files.config.host ?? "127.0.0.1"; - const port = parsePort(env.PORT ?? files.config.port); + const stored = files.config; + const host = stored.server.host; + const port = stored.server.port; const publicBaseUrl = parsePublicBaseUrl( - env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), + stored.server.publicBaseUrl ?? localPublicBaseUrl(host, port), ); const derivedAllowedHosts = [ "localhost", @@ -202,41 +44,65 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { "::1", host, new URL(publicBaseUrl).hostname, - ...(files.config.allowedHosts ?? []), + ...stored.server.allowedHosts, ]; 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), + oauth: { + ownerToken: parseRequiredSecret( + env.DEVSPACE_OAUTH_OWNER_TOKEN ?? files.auth.ownerToken, + ), + accessTokenTtlSeconds: stored.oauth.accessTokenTtlSeconds, + refreshTokenTtlSeconds: stored.oauth.refreshTokenTtlSeconds, + scopes: stored.oauth.scopes, + allowedRedirectHosts: stored.oauth.allowedRedirectHosts, + }, + allowedRoots: normalizePaths(stored.workspaces.allowedRoots, [process.cwd()]), + allowedHosts: normalizeAllowedHosts(derivedAllowedHosts), publicBaseUrl, - toolMode: files.config.tools?.mode ?? "codex", - uiEnabled: files.config.ui?.enabled ?? true, - stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), - worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), - artifactsEnabled: - env.DEVSPACE_ARTIFACTS === undefined - ? files.config.artifactsEnabled === true - : parseBoolean(env.DEVSPACE_ARTIFACTS), - artifactMaxFileBytes: parsePositiveInteger( - env.DEVSPACE_ARTIFACT_MAX_FILE_BYTES ?? numberConfigValue(files.config.artifactMaxFileBytes), - DEFAULT_ARTIFACT_MAX_FILE_BYTES, - "DEVSPACE_ARTIFACT_MAX_FILE_BYTES", - ), - skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), - skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), + toolMode: stored.tools.mode, + uiEnabled: stored.ui.enabled, + stateDir: normalizePath(stored.storage.stateDir), + worktreeRoot: normalizePath(stored.workspaces.worktreeRoot), + artifactsEnabled: stored.artifacts.enabled, + artifactMaxFileBytes: stored.artifacts.maxFileBytes, + skillsEnabled: stored.skills.enabled, + skillPaths: normalizePaths(stored.skills.paths), devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), - subagents: resolveSubagentsConfig(files.config.subagents, env), - agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), - logging: parseLoggingConfig(env), + subagents: stored.subagents, + agentDir: normalizePath(stored.skills.agentDir), + logging: { + ...stored.logging, + trustProxy: stored.server.trustProxy, + }, }; } -function numberConfigValue(value: number | undefined): string | undefined { - return value === undefined ? undefined : String(value); +function normalizePaths(paths: string[], fallback: string[] = []): string[] { + return (paths.length > 0 ? paths : fallback).map(normalizePath); +} + +function normalizePath(path: string): string { + return resolve(expandHomePath(path)); +} + +function normalizeAllowedHosts(hosts: string[]): string[] { + if (hosts.includes("*")) return ["*"]; + return Array.from(new Set(hosts.map((host) => host.trim()).filter(Boolean))); +} + +function parseRequiredSecret(value: string | undefined): string { + const secret = value?.trim(); + if (!secret) { + throw new Error("OAuth owner token is required. Run: devspace init"); + } + if (secret.length < 16) { + throw new Error("OAuth owner token must be at least 16 characters long."); + } + return secret; } function parsePublicBaseUrl(value: string): string { diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index eb47ebafa..157826eb5 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -84,7 +84,7 @@ export class LocalAgentClient { this.endpoint = options.endpoint ?? this.paths.endpoint; this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; - this.spawnDaemon = options.spawnDaemon ?? (() => spawnLocalAgentDaemon(options.stateDir)); + this.spawnDaemon = options.spawnDaemon ?? (() => spawnLocalAgentDaemon()); } async run( @@ -408,13 +408,13 @@ export function createLocalAgentClient(config: Pick): return new LocalAgentClient({ stateDir: config.stateDir }); } -export function spawnLocalAgentDaemon(stateDir: string, env: NodeJS.ProcessEnv = process.env): void { +export function spawnLocalAgentDaemon(env: NodeJS.ProcessEnv = process.env): void { const entrypoint = resolveDaemonEntrypoint(); const child = spawn(process.execPath, [...daemonExecArgv(process.execArgv), entrypoint], { detached: true, stdio: "ignore", windowsHide: true, - env: { ...env, DEVSPACE_STATE_DIR: stateDir }, + env, }); child.unref(); } diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 713fed653..1751d0639 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -11,7 +11,7 @@ const config = resolveSubagentsConfig({ { id: "codex", enabled: true, model: " gpt-5.4 ", effort: " high " }, { id: "claude", enabled: false, model: "sonnet" }, ], -}, {}); +}); assert.deepEqual(config, { enabled: true, providers: [ @@ -24,31 +24,26 @@ assert.equal(isSubagentProviderEnabled(config, "claude"), false); assert.equal(isSubagentProviderEnabled(config, "pi"), false); assert.equal(subagentProviderConfig(config, "codex")?.model, "gpt-5.4"); -assert.equal(resolveSubagentsConfig(config, { DEVSPACE_SUBAGENTS: "0" }).enabled, false); -assert.equal(resolveSubagentsConfig({ ...config, enabled: false }, { - DEVSPACE_SUBAGENTS: "1", -}).enabled, true); -assert.equal(resolveSubagentsConfig(undefined, {}).providers.length, 0); -assert.equal(resolveSubagentsConfig(true, {}).providers.length, 7); +assert.equal(resolveSubagentsConfig(undefined).providers.length, 0); assert.throws( () => resolveSubagentsConfig({ enabled: true, providers: [{ id: "codex", enabled: true }, { id: "codex", enabled: false }], - }, {}), + }), /Duplicate subagent provider: codex/, ); assert.throws( () => resolveSubagentsConfig({ enabled: true, providers: [{ id: "unknown", enabled: true }], - }, {}), + }), /Invalid option/, ); assert.throws( () => resolveSubagentsConfig({ enabled: true, providers: [{ id: "codex", enabled: true, effort: " " }], - }, {}), + }), /Too small/, ); diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 538355d92..e008788c7 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -39,19 +39,10 @@ export type StoredSubagentsConfig = z.infer; export function resolveSubagentsConfig( value: unknown, - env: NodeJS.ProcessEnv = process.env, ): SubagentsConfig { - const stored = value === undefined + return value === undefined ? { enabled: false, providers: [] } - : typeof value === "boolean" - ? legacySubagentsConfig(value) - : subagentsConfigSchema.parse(value); - return { - ...stored, - enabled: env.DEVSPACE_SUBAGENTS === undefined - ? stored.enabled - : parseBoolean(env.DEVSPACE_SUBAGENTS), - }; + : subagentsConfigSchema.parse(value); } export function subagentProviderConfig( @@ -67,16 +58,3 @@ export function isSubagentProviderEnabled( ): boolean { return config.enabled && subagentProviderConfig(config, provider)?.enabled === true; } - -function legacySubagentsConfig(enabled: boolean): SubagentsConfig { - return { - enabled, - providers: enabled - ? LOCAL_AGENT_PROVIDERS.map((id) => ({ id, enabled: true })) - : [], - }; -} - -function parseBoolean(value: string): boolean { - return ["1", "true", "yes", "on"].includes(value.toLowerCase()); -} diff --git a/src/user-config.test.ts b/src/user-config.test.ts index f13ca46f5..9ff9df6e6 100644 --- a/src/user-config.test.ts +++ b/src/user-config.test.ts @@ -1,41 +1,103 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { loadDevspaceFiles } from "./user-config.js"; +import { + loadDevspaceFiles, + setDevspaceConfigValue, +} from "./user-config.js"; -const configDir = mkdtempSync(join(tmpdir(), "devspace-user-config-test-")); -const env = { DEVSPACE_CONFIG_DIR: configDir }; - -try { +withConfigDir((configDir, env) => { writeFileSync(join(configDir, "config.json"), JSON.stringify({ + host: "0.0.0.0", port: 8787, - subagents: { - enabled: true, - providers: [{ id: "codex", enabled: true }], - }, + allowedRoots: ["/work"], + publicBaseUrl: "https://devspace.example.com", + artifactsEnabled: true, + subagents: true, })); writeFileSync(join(configDir, "auth.json"), JSON.stringify({ ownerToken: "test-owner-token", })); - assert.deepEqual(loadDevspaceFiles(env).config, { - port: 8787, - subagents: { - enabled: true, - providers: [{ id: "codex", enabled: true }], + const files = loadDevspaceFiles(env); + assert.equal(files.migratedLegacyConfig, true); + assert.equal(files.config.server.host, "0.0.0.0"); + assert.equal(files.config.server.port, 8787); + assert.deepEqual(files.config.workspaces.allowedRoots, ["/work"]); + assert.equal(files.config.artifacts.enabled, true); + assert.equal(files.config.subagents.enabled, true); + assert.equal(files.config.tools.mode, "codex"); + assert.equal(files.config.ui.enabled, true); + assert.equal(files.auth.ownerToken, "test-owner-token"); + assert.equal(existsSync(join(configDir, "config.json")), false); + assert.equal(existsSync(join(configDir, "config.jsonc")), true); + assert.equal(existsSync(join(configDir, "config.json.v1.0.bak")), true); + + const nextLoad = loadDevspaceFiles(env); + assert.equal(nextLoad.migratedLegacyConfig, false); +}); + +withConfigDir((configDir, env) => { + writeFileSync(join(configDir, "config.jsonc"), `{ + // This comment must survive config updates. + "configVersion": 1, + "server": { + "port": 8787, }, - }); - assert.equal(loadDevspaceFiles(env).auth.ownerToken, "test-owner-token"); + }\n`); - writeFileSync(join(configDir, "config.json"), JSON.stringify({ port: "8787" })); - assert.throws(() => loadDevspaceFiles(env), /expected number/i); + const files = loadDevspaceFiles(env); + assert.equal(files.config.server.port, 8787); + assert.equal(files.config.tools.mode, "codex"); - writeFileSync(join(configDir, "config.json"), JSON.stringify({ unknownSetting: true })); - assert.equal(loadDevspaceFiles(env).config.unknownSetting, true); + setDevspaceConfigValue(["server", "publicBaseUrl"], "https://new.example.com", env); + const updated = readFileSync(join(configDir, "config.jsonc"), "utf8"); + assert.match(updated, /This comment must survive config updates/); + assert.equal(loadDevspaceFiles(env).config.server.publicBaseUrl, "https://new.example.com"); +}); +withConfigDir((configDir, env) => { + writeFileSync(join(configDir, "config.jsonc"), JSON.stringify({ configVersion: 1 })); writeFileSync(join(configDir, "config.json"), "{"); - assert.throws(() => loadDevspaceFiles(env), /Unable to read .*config\.json/); -} finally { - rmSync(configDir, { recursive: true, force: true }); + assert.equal(loadDevspaceFiles(env).config.server.port, 7676); + assert.equal(existsSync(join(configDir, "config.json")), true); +}); + +withConfigDir((configDir, env) => { + writeFileSync(join(configDir, "config.jsonc"), "{"); + writeFileSync(join(configDir, "config.json"), JSON.stringify({ port: 8787 })); + assert.throws(() => loadDevspaceFiles(env), /Unable to read .*config\.jsonc/); + assert.equal(existsSync(join(configDir, "config.json")), true); +}); + +withConfigDir((configDir, env) => { + writeFileSync(join(configDir, "config.json"), JSON.stringify({ unknownSetting: true })); + assert.throws( + () => loadDevspaceFiles(env), + /Unsupported legacy configuration keys: unknownSetting/, + ); + assert.equal(existsSync(join(configDir, "config.json")), true); + assert.equal(existsSync(join(configDir, "config.jsonc")), false); + assert.equal(existsSync(join(configDir, "config.json.v1.0.bak")), false); +}); + +console.log("user config tests passed"); + +function withConfigDir( + test: (configDir: string, env: NodeJS.ProcessEnv) => void, +): void { + const configDir = mkdtempSync(join(tmpdir(), "devspace-user-config-test-")); + const env = { DEVSPACE_CONFIG_DIR: configDir }; + try { + test(configDir, env); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } } diff --git a/src/user-config.ts b/src/user-config.ts index 00535be27..b1524ee01 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -3,39 +3,34 @@ import { existsSync, mkdirSync, readFileSync, + renameSync, + rmSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; +import { + applyEdits, + modify, + parse, + printParseErrorCode, + type ParseError, +} from "jsonc-parser"; import * as z from "zod/v4"; +import { + defaultDevspaceConfig, + devspaceConfigSchema, + type DevspaceConfig, + type DevspaceConfigInput, +} from "./config-schema.js"; +import { migrateLegacyConfig } from "./config-migration.js"; import { expandHomePath } from "./roots.js"; -import { storedSubagentsConfigSchema } from "./local-agent-config.js"; - -const devspaceUserConfigSchema = z.object({ - host: z.string().optional(), - port: z.number().optional(), - allowedRoots: z.array(z.string()).optional(), - publicBaseUrl: z.string().nullable().optional(), - allowedHosts: z.array(z.string()).optional(), - stateDir: z.string().optional(), - worktreeRoot: z.string().optional(), - artifactsEnabled: z.boolean().optional(), - artifactMaxFileBytes: z.number().optional(), - agentDir: z.string().optional(), - subagents: storedSubagentsConfigSchema.optional(), - tools: z.object({ - mode: z.enum(["claude", "codex"]).optional(), - }).strict().optional(), - ui: z.object({ - enabled: z.boolean().optional(), - }).strict().optional(), -}).passthrough(); const devspaceAuthConfigSchema = z.object({ ownerToken: z.string().optional(), }).passthrough(); -export type DevspaceUserConfig = z.infer; +export type DevspaceUserConfig = DevspaceConfig; export type DevspaceAuthConfig = z.infer; export interface DevspaceFiles { @@ -44,8 +39,9 @@ export interface DevspaceFiles { authPath: string; configExists: boolean; authExists: boolean; - config: DevspaceUserConfig; + config: DevspaceConfig; auth: DevspaceAuthConfig; + migratedLegacyConfig: boolean; } export function devspaceConfigDir(env: NodeJS.ProcessEnv = process.env): string { @@ -53,9 +49,17 @@ export function devspaceConfigDir(env: NodeJS.ProcessEnv = process.env): string } export function devspaceConfigPath(env: NodeJS.ProcessEnv = process.env): string { + return join(devspaceConfigDir(env), "config.jsonc"); +} + +export function devspaceLegacyConfigPath(env: NodeJS.ProcessEnv = process.env): string { return join(devspaceConfigDir(env), "config.json"); } +export function devspaceLegacyConfigBackupPath(env: NodeJS.ProcessEnv = process.env): string { + return join(devspaceConfigDir(env), "config.json.v1.0.bak"); +} + export function devspaceAuthPath(env: NodeJS.ProcessEnv = process.env): string { return join(devspaceConfigDir(env), "auth.json"); } @@ -70,8 +74,12 @@ export function devspaceAgentsDir(env: NodeJS.ProcessEnv = process.env): string export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): DevspaceFiles { const dir = devspaceConfigDir(env); - const configPath = join(dir, "config.json"); - const authPath = join(dir, "auth.json"); + const configPath = devspaceConfigPath(env); + const legacyConfigPath = devspaceLegacyConfigPath(env); + const authPath = devspaceAuthPath(env); + const migratedLegacyConfig = !existsSync(configPath) && existsSync(legacyConfigPath) + ? migrateLegacyConfigFile(legacyConfigPath, configPath, devspaceLegacyConfigBackupPath(env)) + : false; const configExists = existsSync(configPath); const authExists = existsSync(authPath); @@ -81,28 +89,46 @@ export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): Devspac authPath, configExists, authExists, - config: configExists ? readJsonFile(configPath, devspaceUserConfigSchema) : {}, + config: configExists ? readJsoncConfig(configPath) : defaultDevspaceConfig(), auth: authExists ? readJsonFile(authPath, devspaceAuthConfigSchema) : {}, + migratedLegacyConfig, }; } export function writeDevspaceConfig( - config: DevspaceUserConfig, + config: DevspaceConfigInput, env: NodeJS.ProcessEnv = process.env, ): string { const filePath = devspaceConfigPath(env); - mkdirSync(devspaceConfigDir(env), { recursive: true }); - writeJsonFile(filePath, config, 0o600); + const parsed = devspaceConfigSchema.parse(config); + atomicWrite(filePath, serializeConfig(parsed), 0o600); return filePath; } +export function setDevspaceConfigValue( + path: (string | number)[], + value: unknown, + env: NodeJS.ProcessEnv = process.env, +): string { + const files = loadDevspaceFiles(env); + const source = files.configExists + ? readFileSync(files.configPath, "utf8") + : serializeConfig(files.config); + const updated = applyEdits(source, modify(source, path, value, { + formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, + })); + parseJsoncConfig(updated, files.configPath); + atomicWrite(files.configPath, updated.endsWith("\n") ? updated : `${updated}\n`, 0o600); + return files.configPath; +} + export function writeDevspaceAuth( auth: DevspaceAuthConfig, env: NodeJS.ProcessEnv = process.env, ): string { const filePath = devspaceAuthPath(env); mkdirSync(devspaceConfigDir(env), { recursive: true }); - writeJsonFile(filePath, auth, 0o600); + writeJsonFile(filePath, devspaceAuthConfigSchema.parse(auth), 0o600); return filePath; } @@ -110,15 +136,99 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } +function migrateLegacyConfigFile( + legacyPath: string, + configPath: string, + backupPath: string, +): true { + if (existsSync(backupPath)) { + throw new Error(`Unable to migrate ${legacyPath}: backup already exists at ${backupPath}`); + } + + let migrated: DevspaceConfig; + try { + migrated = migrateLegacyConfig(JSON.parse(readFileSync(legacyPath, "utf8")) as unknown); + } catch (error) { + throw fileError("migrate", legacyPath, error); + } + + const temporaryPath = temporaryFilePath(configPath); + try { + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(temporaryPath, serializeConfig(migrated), { mode: 0o600, flag: "wx" }); + readJsoncConfig(temporaryPath); + renameSync(temporaryPath, configPath); + renameSync(legacyPath, backupPath); + } catch (error) { + rmSync(temporaryPath, { force: true }); + throw fileError("migrate", legacyPath, error); + } + return true; +} + +function readJsoncConfig(filePath: string): DevspaceConfig { + try { + return parseJsoncConfig(readFileSync(filePath, "utf8"), filePath); + } catch (error) { + if (error instanceof DevspaceConfigFileError) throw error; + throw fileError("read", filePath, error); + } +} + +function parseJsoncConfig(source: string, filePath: string): DevspaceConfig { + const errors: ParseError[] = []; + const value = parse(source, errors, { allowTrailingComma: true }); + if (errors.length > 0) { + const first = errors[0]!; + throw new DevspaceConfigFileError( + `Unable to read ${filePath}: ${printParseErrorCode(first.error)} at offset ${first.offset}`, + ); + } + try { + return devspaceConfigSchema.parse(value); + } catch (error) { + throw fileError("read", filePath, error); + } +} + +function serializeConfig(config: DevspaceConfig): string { + return `${JSON.stringify(config, null, 2)}\n`; +} + +function atomicWrite(filePath: string, source: string, mode: number): void { + mkdirSync(dirname(filePath), { recursive: true }); + const temporaryPath = temporaryFilePath(filePath); + try { + writeFileSync(temporaryPath, source, { mode, flag: "wx" }); + renameSync(temporaryPath, filePath); + } catch (error) { + rmSync(temporaryPath, { force: true }); + throw error; + } +} + +function temporaryFilePath(filePath: string): string { + return join( + dirname(filePath), + `.${basename(filePath)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`, + ); +} + function readJsonFile(filePath: string, schema: z.ZodType): T { try { return schema.parse(JSON.parse(readFileSync(filePath, "utf8")) as unknown); } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw new Error(`Unable to read ${filePath}: ${reason}`); + throw fileError("read", filePath, error); } } function writeJsonFile(filePath: string, value: unknown, mode: number): void { - writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode }); + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode }); } + +function fileError(action: "read" | "migrate", filePath: string, error: unknown): Error { + const reason = error instanceof Error ? error.message : String(error); + return new DevspaceConfigFileError(`Unable to ${action} ${filePath}: ${reason}`); +} + +class DevspaceConfigFileError extends Error {} From ebb1dea5f8d53bacaa71a884e7d805ca6981ceb7 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:38:24 +0530 Subject: [PATCH 03/11] test(config): use persisted fixtures --- src/cli.test.ts | 44 ++++++-------------- src/config.test.ts | 2 +- src/config.ts | 2 +- src/local-agent-profiles.test.ts | 21 ++++------ src/server.test.ts | 16 ++++--- src/skills.test.ts | 67 ++++++++++++++---------------- src/test-support/config.test.ts | 43 +++++++++++++++++++ src/workspace-conversation.test.ts | 32 +++++++------- src/workspaces.test.ts | 56 ++++++++++++++----------- 9 files changed, 151 insertions(+), 132 deletions(-) create mode 100644 src/test-support/config.test.ts diff --git a/src/cli.test.ts b/src/cli.test.ts index 9a2022efd..27c91da40 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -11,6 +11,7 @@ import { loadConfig } from "./config.js"; import { localAgentDaemonPaths } from "./local-agent-daemon-lifecycle.js"; import { encodeLocalAgentDaemonResponse } from "./local-agent-daemon-protocol.js"; import { LocalAgentStore } from "./local-agent-store.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); @@ -38,6 +39,11 @@ try { mkdirSync(stateDir, { recursive: true }); mkdirSync(join(configDir, "agents"), { recursive: true }); mkdirSync(projectRoot, { recursive: true }); + const cliConfigEnv = writeTestDevspaceConfig(configDir, { + workspaces: { allowedRoots: [projectRoot] }, + storage: { stateDir }, + subagents: { enabled: true, providers: [] }, + }); writeFileSync( join(configDir, "agents", "reviewer.md"), [ @@ -138,13 +144,9 @@ try { encoding: "utf8", env: { ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, + ...cliConfigEnv, DEVSPACE_WORKSPACE_ID: "ws_current", DEVSPACE_WORKSPACE_ROOT: projectRoot, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }, }); @@ -158,13 +160,9 @@ try { encoding: "utf8", env: { ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, + ...cliConfigEnv, DEVSPACE_WORKSPACE_ID: "ws_current", DEVSPACE_WORKSPACE_ROOT: projectRoot, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }, }, ); @@ -181,11 +179,7 @@ try { encoding: "utf8", env: { ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: stateDir, - DEVSPACE_STATE_DIR: stateDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + ...cliConfigEnv, DEVSPACE_WORKSPACE_ID: "", DEVSPACE_WORKSPACE_ROOT: stateDir, }, @@ -205,13 +199,9 @@ try { encoding: "utf8", env: { ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, + ...cliConfigEnv, DEVSPACE_WORKSPACE_ID: "ws_current", DEVSPACE_WORKSPACE_ROOT: projectRoot, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }, }, ); @@ -247,13 +237,9 @@ try { encoding: "utf8", env: { ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, + ...cliConfigEnv, DEVSPACE_WORKSPACE_ID: "ws_current", DEVSPACE_WORKSPACE_ROOT: projectRoot, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }, }, ), @@ -268,13 +254,7 @@ try { }); } - assert.equal(loadConfig({ - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - }).subagents.enabled, true); + assert.equal(loadConfig(cliConfigEnv).subagents.enabled, true); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/config.test.ts b/src/config.test.ts index c266b1e8d..e353de53c 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -92,7 +92,7 @@ try { assert.equal(configured.artifactsEnabled, true); assert.equal(configured.artifactMaxFileBytes, 321); assert.equal(configured.skillsEnabled, false); - assert.deepEqual(configured.skillPaths, [resolve(process.env.HOME!, "skills")]); + assert.deepEqual(configured.skillPaths, ["~/skills"]); assert.equal(configured.agentDir, resolve(process.env.HOME!, "agent")); assert.equal(configured.subagents.enabled, true); assert.equal(configured.oauth.ownerToken, "persisted-owner-token-long-enough"); diff --git a/src/config.ts b/src/config.ts index c0e8f943a..496f3bdb8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -69,7 +69,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { artifactsEnabled: stored.artifacts.enabled, artifactMaxFileBytes: stored.artifacts.maxFileBytes, skillsEnabled: stored.skills.enabled, - skillPaths: normalizePaths(stored.skills.paths), + skillPaths: stored.skills.paths, devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), subagents: stored.subagents, diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 6868e140e..d3c5705f1 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; import { loadLocalAgentProfiles, summarizeLocalAgentProfile } from "./local-agent-profiles.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const root = await mkdtemp(join(tmpdir(), "devspace-agent-profiles-test-")); @@ -57,12 +58,10 @@ try { ].join("\n"), ); - const enabledConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: workspaceRoot, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - }); + const enabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { + workspaces: { allowedRoots: [workspaceRoot] }, + subagents: { enabled: true, providers: [] }, + })); const profiles = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); assert.equal(profiles.length, 1); @@ -96,12 +95,10 @@ try { const profilesWithInvalid = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["reviewer"]); - const disabledConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: workspaceRoot, - DEVSPACE_SUBAGENTS: "0", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - }); + const disabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { + workspaces: { allowedRoots: [workspaceRoot] }, + subagents: { enabled: false, providers: [] }, + })); assert.deepEqual(await loadLocalAgentProfiles(disabledConfig, workspaceRoot), []); } finally { await rm(root, { recursive: true, force: true }); diff --git a/src/server.test.ts b/src/server.test.ts index 4f1215c18..b7d8d3513 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -16,6 +16,7 @@ import { ProcessSessionManager } from "./process-sessions.js"; import { createMcpServer } from "./server.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const execFileAsync = promisify(execFile); @@ -373,15 +374,12 @@ async function fixture( const initialProviderAvailability = typeof options.localAgentProviders === "function" ? options.localAgentProviders() : options.localAgentProviders ?? []; - const loadedConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: join(root, ".config"), - DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SUBAGENTS: options.localAgentProviders ? "1" : "0", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const loadedConfig = loadConfig(writeTestDevspaceConfig(join(root, ".config"), { + server: { port: 1 }, + workspaces: { allowedRoots: [root], worktreeRoot: join(root, ".worktrees") }, + skills: { agentDir }, + subagents: { enabled: options.localAgentProviders !== undefined, providers: [] }, + })); const modeConfig: ServerConfig = { ...loadedConfig, toolMode: options.toolMode ?? loadedConfig.toolMode, diff --git a/src/skills.test.ts b/src/skills.test.ts index 9db16a103..41556b3ad 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -9,6 +9,7 @@ import { loadWorkspaceSkills, resolveSkillReadPath, } from "./skills.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const root = await mkdtemp(join(tmpdir(), "devspace-skills-test-")); const originalHome = process.env.HOME; @@ -160,23 +161,22 @@ try { ].join("\n"), ); - const disabledConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SKILL_PATHS: explicitSkills, - DEVSPACE_SKILLS: "0", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const configDir = join(root, ".devspace"); + const disabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { + server: { port: 1 }, + workspaces: { allowedRoots: [projectRoot] }, + skills: { agentDir, paths: [explicitSkills], enabled: false }, + })); assert.deepEqual(loadWorkspaceSkills(disabledConfig, projectRoot).skills, []); - const config = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SKILL_PATHS: [explicitSkills, "~/.claude/skills", "./.claude/skills"].join(","), - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const config = loadConfig(writeTestDevspaceConfig(configDir, { + server: { port: 1 }, + workspaces: { allowedRoots: [projectRoot] }, + skills: { + agentDir, + paths: [explicitSkills, "~/.claude/skills", "./.claude/skills"], + }, + })); const loaded = loadWorkspaceSkills(config, projectRoot); assert.equal(loaded.skills.some((skill) => skill.name === "agent-global-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "agent-project-skill"), true); @@ -195,13 +195,12 @@ try { false, ); - const experimentalConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const experimentalConfig = loadConfig(writeTestDevspaceConfig(configDir, { + server: { port: 1 }, + workspaces: { allowedRoots: [projectRoot] }, + skills: { agentDir }, + subagents: { enabled: true, providers: [] }, + })); assert.equal( loadWorkspaceSkills(experimentalConfig, projectRoot).skills.some( (skill) => skill.name === "subagents", @@ -209,25 +208,21 @@ try { true, ); - const duplicateConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SKILL_PATHS: [explicitSkills, "./.agents/skills"].join(","), - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const duplicateConfig = loadConfig(writeTestDevspaceConfig(configDir, { + server: { port: 1 }, + workspaces: { allowedRoots: [projectRoot] }, + skills: { agentDir, paths: [explicitSkills, "./.agents/skills"] }, + })); assert.equal( effectiveSkillPaths(duplicateConfig, projectRoot).filter((path) => path === projectAgentsSkills).length, 1, ); - const legacyPiConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SKILL_PATHS: [explicitSkills, join(projectRoot, ".pi", "skills")].join(","), - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const legacyPiConfig = loadConfig(writeTestDevspaceConfig(configDir, { + server: { port: 1 }, + workspaces: { allowedRoots: [projectRoot] }, + skills: { agentDir, paths: [explicitSkills, join(projectRoot, ".pi", "skills")] }, + })); assert.equal( loadWorkspaceSkills(legacyPiConfig, projectRoot).skills.some((skill) => skill.name === "project-skill"), true, diff --git a/src/test-support/config.test.ts b/src/test-support/config.test.ts new file mode 100644 index 000000000..2c97bc4e8 --- /dev/null +++ b/src/test-support/config.test.ts @@ -0,0 +1,43 @@ +import { + defaultDevspaceConfig, + type DevspaceConfig, +} from "../config-schema.js"; +import { writeDevspaceConfig } from "../user-config.js"; + +type SectionOverrides = { + server?: Partial; + workspaces?: Partial; + storage?: Partial; + tools?: Partial; + ui?: Partial; + artifacts?: Partial; + skills?: Partial; + subagents?: DevspaceConfig["subagents"]; + logging?: Partial; + oauth?: Partial; +}; + +export function writeTestDevspaceConfig( + configDir: string, + overrides: SectionOverrides = {}, +): NodeJS.ProcessEnv { + const defaults = defaultDevspaceConfig(); + const env = { DEVSPACE_CONFIG_DIR: configDir }; + writeDevspaceConfig({ + ...defaults, + server: { ...defaults.server, ...overrides.server }, + workspaces: { ...defaults.workspaces, ...overrides.workspaces }, + storage: { ...defaults.storage, ...overrides.storage }, + tools: { ...defaults.tools, ...overrides.tools }, + ui: { ...defaults.ui, ...overrides.ui }, + artifacts: { ...defaults.artifacts, ...overrides.artifacts }, + skills: { ...defaults.skills, ...overrides.skills }, + subagents: overrides.subagents ?? defaults.subagents, + logging: { ...defaults.logging, ...overrides.logging }, + oauth: { ...defaults.oauth, ...overrides.oauth }, + }, env); + return { + ...env, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }; +} diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 5af9f991d..45c45a14d 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -9,6 +9,7 @@ import { loadConfig, type ServerConfig } from "./config.js"; import { openDatabase } from "./db/client.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const execFileAsync = promisify(execFile); @@ -242,14 +243,14 @@ test("canonical checkout identity survives macOS var path aliases", { skip: plat return; } - const aliasConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: join(context.root, ".alias-config"), - DEVSPACE_ALLOWED_ROOTS: `${context.root},${macAlias}`, - DEVSPACE_WORKTREE_ROOT: join(context.root, ".worktrees"), - DEVSPACE_AGENT_DIR: join(context.root, "agent"), - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const aliasConfig = loadConfig(writeTestDevspaceConfig(join(context.root, ".alias-config"), { + server: { port: 1 }, + workspaces: { + allowedRoots: [context.root, macAlias], + worktreeRoot: join(context.root, ".worktrees"), + }, + skills: { agentDir: join(context.root, "agent") }, + })); const aliasRegistry = new WorkspaceRegistry(aliasConfig, context.store); const direct = await context.registry.openWorkspace(context.project, { @@ -416,15 +417,12 @@ async function fixture( if (options.git) await initializeGitRepository(project); - const config = loadConfig({ - DEVSPACE_CONFIG_DIR: join(root, ".config"), - DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const config = loadConfig(writeTestDevspaceConfig(join(root, ".config"), { + server: { port: 1 }, + workspaces: { allowedRoots: [root], worktreeRoot: join(root, ".worktrees") }, + skills: { agentDir }, + subagents: { enabled: true, providers: [] }, + })); const openStore = () => { const store = new SqliteWorkspaceStore(stateDir); stores.add(store); diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 8584c1b7e..3dab10807 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -9,6 +9,7 @@ import { loadConfig, type ServerConfig } from "./config.js"; import { GitWorktreeError } from "./git-worktrees.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; const execFileAsync = promisify(execFile); @@ -47,14 +48,17 @@ test("a checkout exposes initial and nested instruction context while filtering await writeFile(join(context.outsideRoot, "secret.txt"), "outside secret\n"); await symlink(join(context.outsideRoot, "secret.txt"), join(unsafeAgentDir, "AGENTS.md")); - const unsafeConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: join(context.root, ".devspace-unsafe-home"), - DEVSPACE_ALLOWED_ROOTS: context.root, - DEVSPACE_WORKTREE_ROOT: join(context.root, ".devspace", "unsafe-worktrees"), - DEVSPACE_AGENT_DIR: unsafeAgentDir, - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const unsafeConfig = loadConfig(writeTestDevspaceConfig( + join(context.root, ".devspace-unsafe-home"), + { + server: { port: 1 }, + workspaces: { + allowedRoots: [context.root], + worktreeRoot: join(context.root, ".devspace", "unsafe-worktrees"), + }, + skills: { agentDir: unsafeAgentDir }, + }, + )); const unsafeWorkspace = await new WorkspaceRegistry(unsafeConfig).openWorkspace(context.root); assert.deepEqual( @@ -144,13 +148,17 @@ test("a symlinked allowed root preserves checkout and worktree path behavior", { await symlink(context.root, aliasRoot, "dir"); await createGitProject(context.root); - const aliasConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: aliasRoot, - DEVSPACE_WORKTREE_ROOT: join(aliasRoot, ".devspace", "alias-worktrees"), - DEVSPACE_AGENT_DIR: context.agentDir, - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const aliasConfig = loadConfig(writeTestDevspaceConfig( + join(context.root, ".devspace-alias-home"), + { + server: { port: 1 }, + workspaces: { + allowedRoots: [aliasRoot], + worktreeRoot: join(aliasRoot, ".devspace", "alias-worktrees"), + }, + skills: { agentDir: context.agentDir }, + }, + )); const aliasRegistry = new WorkspaceRegistry(aliasConfig); const worktree = await aliasRegistry.openWorkspace({ @@ -207,15 +215,15 @@ async function fixture(t: TestContext): Promise { await writeFile(join(root, "nested", "AGENTS.md"), "nested instructions\n"); await writeFile(join(root, "nested", "file.txt"), "hello\n"); - const config = loadConfig({ - DEVSPACE_CONFIG_DIR: join(root, ".devspace-home"), - DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + const config = loadConfig(writeTestDevspaceConfig(join(root, ".devspace-home"), { + server: { port: 1 }, + workspaces: { + allowedRoots: [root], + worktreeRoot: join(root, ".devspace", "worktrees"), + }, + skills: { agentDir }, + subagents: { enabled: true, providers: [] }, + })); t.after(async () => { await rm(root, { recursive: true, force: true }); From aa2d6ea41237ae57bf1c98c898ba25b2c03b0b5f Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:41:36 +0530 Subject: [PATCH 04/11] docs(config): document JSONC configuration --- .github/workflows/ci.yml | 2 - docs/artifact-exchange.md | 5 +- docs/chatgpt-coding-workflow.md | 14 +- docs/configuration.md | 392 ++++++++++++++------------------ docs/gotchas.md | 33 +-- docs/security.md | 10 +- docs/setup.md | 12 +- src/cli.ts | 7 +- 8 files changed, 201 insertions(+), 274 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fffcedb0..9db018c62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,7 @@ jobs: - windows-latest env: - DEVSPACE_ALLOWED_ROOTS: ${{ github.workspace }} DEVSPACE_OAUTH_OWNER_TOKEN: ci-owner-token-that-is-long-enough - DEVSPACE_PUBLIC_BASE_URL: http://127.0.0.1:7676 steps: - name: Checkout diff --git a/docs/artifact-exchange.md b/docs/artifact-exchange.md index 6a6f5c75a..f4728eb0d 100644 --- a/docs/artifact-exchange.md +++ b/docs/artifact-exchange.md @@ -1,7 +1,8 @@ # Download a native file DevSpace can save a file attached or generated by an MCP host, such as ChatGPT, -directly into an open workspace. Enable the tool with `DEVSPACE_ARTIFACTS=1`. +directly into an open workspace. Enable the tool with +`artifacts.enabled` in `~/.devspace/config.jsonc`. ## Workflow @@ -36,7 +37,7 @@ file-object shape, trusted OpenAI download hosts, and redirects before streaming Malformed references, unknown fields, absolute paths, traversal, and symlinked parents are rejected. -Downloads are streamed under `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` and published as +Downloads are streamed under `artifacts.maxFileBytes` and published as owner-only files without overwriting an existing destination. The tool is currently available on Linux. It is not registered on macOS, Windows, or BSD because Node.js does not expose the required descriptor-relative filesystem diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index a2296fa8e..1cb43e7ac 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -117,8 +117,8 @@ DevSpace discovers standard Agent Skills from: It also keeps compatibility with: - the bundled `subagents` skill when Subagents are enabled, unless `~/.devspace/skills/subagents/SKILL.md` exists -- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` -- additional paths from `DEVSPACE_SKILL_PATHS` +- `skills.agentDir/skills`, defaulting to `~/.codex/skills` +- additional paths from `skills.paths` When Subagents are enabled, DevSpace discovers agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. @@ -130,7 +130,7 @@ Example profiles are packaged under `examples/agents/` for users who want starter templates. Copy or adapt them into one of the active profile directories before use. -Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. +Legacy project paths such as `.pi/skills` can be added to `skills.paths` when needed. When `open_workspace` returns matching skills, the model should read the advertised `SKILL.md` before following that skill. @@ -140,8 +140,8 @@ Skill paths may be outside the workspace. DevSpace only permits reading: - advertised `SKILL.md` files - files under a skill directory after that skill's `SKILL.md` has been read -Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. Enable Subagents -and choose providers through `devspace init` or the persisted provider +Set `skills.enabled` to `false` to hide skills from workspace output. Enable +Subagents and choose providers through `devspace init` or the persisted provider configuration. The bundled `subagents` skill teaches the minimal `devspace agents targets`, `devspace agents ls`, `devspace agents run`, `devspace agents continue`, and `devspace agents show` workflow. The catalog @@ -173,7 +173,7 @@ returns a process session ID when a command is still running after its yield window. Use `write_stdin` to poll it, send input, resize a PTY, or send Ctrl-C. Set `tty: true` only for commands that need a terminal. -Set `tools.mode` to `claude` in `~/.devspace/config.json` to expose `write`, +Set `tools.mode` to `claude` in `~/.devspace/config.jsonc` to expose `write`, `edit`, and `bash` instead of the Codex mutation and command tools. Dedicated MCP tools for `grep`, `glob`, and `ls` are not registered in either mode; use the configured shell tool with command-line tools such as `rg`, `find`, and @@ -184,7 +184,7 @@ the configured shell tool with command-line tools such as `rg`, `find`, and DevSpace exposes `show_changes` in both tool modes and attaches widget UI only to `open_workspace` and `show_changes`. Reads, edits, and commands return normal MCP results without creating an iframe for each call. Set `ui.enabled` to -`false` in `~/.devspace/config.json` to disable UI metadata while keeping the +`false` in `~/.devspace/config.jsonc` to disable UI metadata while keeping the aggregate review tool available. Call `show_changes` exactly once after the final file modification in any turn diff --git a/docs/configuration.md b/docs/configuration.md index d246f4f1b..6a6f607bd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,171 +1,112 @@ # Configuration Reference -DevSpace can be configured through `devspace init`, persisted config files, or -environment variables. +DevSpace stores durable settings in `~/.devspace/config.jsonc`. The file accepts +comments and trailing commas and is validated before the server starts. Editor +completion is provided by the versioned [JSON Schema](../schema/v1/devspace.schema.json), +also hosted at the URL in the file's `$schema` property. -The default files are: +Authentication stays separate because it contains a secret: ```text -~/.devspace/config.json +~/.devspace/config.jsonc ~/.devspace/auth.json ``` -Use another config directory with: +Run `devspace init` to create both files. `devspace config set publicBaseUrl +` updates the JSONC document without discarding its comments. -```bash -DEVSPACE_CONFIG_DIR=/path/to/config npx @waishnav/devspace serve -``` - -## Commands - -```bash -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 -``` - -## Core Environment Variables - -| Variable | Purpose | -| --- | --- | -| `HOST` | Local bind host. Defaults to `127.0.0.1`. | -| `PORT` | Local port. Defaults to `7676`. | -| `DEVSPACE_ALLOWED_ROOTS` | Comma-separated local roots that workspaces may open. | -| `DEVSPACE_PUBLIC_BASE_URL` | Public origin for the server, without `/mcp`. | -| `DEVSPACE_ALLOWED_HOSTS` | Optional Host header allowlist override. | -| `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. | -| `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | -| `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | - -## Native Artifact Download - -Native-file download is disabled by default. Enable it when ChatGPT needs to hand -an attached or generated file into an already-open workspace: - -```bash -DEVSPACE_ARTIFACTS=1 npx @waishnav/devspace serve -``` - -This feature currently supports Linux. It is not registered on macOS, Windows, -or BSD because the secure publication path depends on traversable, -descriptor-anchored directory paths provided by Linux procfs. - -| Variable | Default | Purpose | -| --- | --- | --- | -| `DEVSPACE_ARTIFACTS` | `0` | Expose `download_artifact` for trusted native files. | -| `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` | `104857600` | Maximum streamed size of one file (100 MiB). | - -The same settings may be persisted in `~/.devspace/config.json` as -`artifactsEnabled` and `artifactMaxFileBytes`. - -`download_artifact` accepts the native file object supplied by the MCP connector, -a `workspaceId` returned by `open_workspace`, and a relative workspace `path`. -DevSpace safely creates missing parent directories, refuses to overwrite an -existing destination, and returns only the normalized workspace-relative path. -It does not accept conflict modes, expected hashes, arbitrary URL strings, local -paths, embedded credentials, or extra object fields. +## Complete example -There is no artifact root, total quota, TTL, pinning, persistent database record, -or background artifact cleanup service. See [Native File Download](artifact-exchange.md) -for the supported connector shape and security boundaries. - -## OAuth - -DevSpace uses a single-user OAuth approval flow. - -| Variable | Default | -| --- | --- | -| `DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS` | `3600` | -| `DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | -| `DEVSPACE_OAUTH_SCOPES` | `devspace` | -| `DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS` | `chatgpt.com,localhost,127.0.0.1` | - -MCP clients discover metadata from: - -```text -/.well-known/oauth-protected-resource/mcp -/.well-known/oauth-authorization-server -``` - -## Tool Modes - -`tools.mode` in `~/.devspace/config.json` controls the tool surface: - -```json +```jsonc { + "$schema": "https://raw.githubusercontent.com/Waishnav/devspace/main/schema/v1/devspace.schema.json", + "configVersion": 1, + + "server": { + "host": "127.0.0.1", + "port": 7676, + // Use the public origin only; do not append /mcp. + "publicBaseUrl": "https://devspace.example.com", + "allowedHosts": [], + "trustProxy": false, + }, + "workspaces": { + "allowedRoots": ["~/personal", "~/work"], + "worktreeRoot": "~/.devspace/worktrees", + }, + "storage": { + "stateDir": "~/.local/share/devspace", + }, "tools": { - "mode": "codex" - } -} -``` - -`DEVSPACE_TOOL_MODE` and `DEVSPACE_MINIMAL_TOOLS` are no longer read. Set -`tools.mode` in the configuration file when selecting the Claude surface; -omitting it selects Codex. - -| Value | Behavior | -| --- | --- | -| `codex` | Default. Exposes `open_workspace`, `read`, `apply_patch`, `exec_command`, and `write_stdin`. | -| `claude` | Exposes `open_workspace`, `read`, `write`, `edit`, and `bash`. Clients use `bash` with tools such as `rg`, `find`, and `ls` for inspection. | - -The dedicated MCP tools `grep`, `glob`, and `ls` are no longer exposed. Both -modes use their shell tool for search, file discovery, and directory inspection. - -Codex-mode commands run without a PTY by default. Set `tty: true` on -`exec_command` for interactive terminal programs. PTY support uses the optional -`node-pty` dependency; `write_stdin` can send input, poll output, and resize PTY -sessions. - -## UI - -DevSpace attaches ChatGPT Apps UI metadata only to `open_workspace` and -`show_changes`. This avoids creating an iframe for every read, edit, or command -tool call. The aggregate `show_changes` tool remains available to every MCP -host, including hosts that ignore UI metadata. - -UI is enabled by default. Disable it without removing `show_changes`: - -```json -{ + "mode": "codex", + }, "ui": { - "enabled": false - } + "enabled": true, + }, + "artifacts": { + "enabled": false, + "maxFileBytes": 104857600, + }, + "skills": { + "enabled": true, + "paths": [], + "agentDir": "~/.codex", + }, + "subagents": { + "enabled": false, + "providers": [], + }, + "logging": { + "level": "info", + "format": "json", + "requests": true, + "assets": false, + "toolCalls": true, + "shellCommands": false, + }, + "oauth": { + "accessTokenTtlSeconds": 3600, + "refreshTokenTtlSeconds": 2592000, + "scopes": ["devspace"], + "allowedRedirectHosts": ["chatgpt.com", "localhost", "127.0.0.1"], + }, } ``` -## Skills +Omitted sections and keys use the defaults shown above. An empty +`workspaces.allowedRoots` uses the current working directory. Unknown keys are +rejected so spelling mistakes cannot silently alter behavior. -| Variable | Purpose | -| --- | --- | -| `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled by default. | -| `DEVSPACE_SUBAGENTS` | Optional master override for the persisted Subagents configuration. | -| `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. | -| `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. | +## Tool modes and UI -DevSpace discovers standard Agent Skills from: +`tools.mode` accepts two values: -- `~/.agents/skills` -- project `.agents/skills` -- `~/.devspace/skills` +| Value | Tool surface | +| --- | --- | +| `codex` | Default. `open_workspace`, `read`, `apply_patch`, `exec_command`, `write_stdin`, and `show_changes`. | +| `claude` | `open_workspace`, `read`, `write`, `edit`, `bash`, and `show_changes`. | -It also keeps compatibility with: +The dedicated MCP tools `grep`, `glob`, and `ls` are not exposed. Each mode uses +its shell tool with programs such as `rg`, `find`, and `ls` when it needs those +operations. -- the bundled `subagents` skill when Subagents are enabled, unless `~/.devspace/skills/subagents/SKILL.md` exists -- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` -- additional paths from `DEVSPACE_SKILL_PATHS` +DevSpace attaches Apps UI metadata only to `open_workspace` and `show_changes`. +This avoids rendering an iframe for every read, edit, search, or command call. +Setting `ui.enabled` to `false` removes the metadata but does not remove the +`show_changes` tool. -When Subagents are enabled, DevSpace discovers agent profiles -from: +## Skills and subagents -- `~/.devspace/agents/*.md` -- project `.devspace/agents/*.md` +DevSpace discovers standard Agent Skills from `~/.agents/skills`, project +`.agents/skills`, and `~/.devspace/skills`. It also checks +`skills.agentDir/skills` and each path in `skills.paths`. Relative custom paths +are resolved from the active workspace. -Enable providers and set their defaults in `~/.devspace/config.json`: +Subagent providers are explicit. Omitted providers are disabled: -```json +```jsonc { + "configVersion": 1, "subagents": { "enabled": true, "providers": [ @@ -173,101 +114,104 @@ Enable providers and set their defaults in `~/.devspace/config.json`: "id": "codex", "enabled": true, "model": "gpt-5.4", - "effort": "high" + "effort": "high", }, { "id": "claude", "enabled": true, - "model": "sonnet" + "model": "sonnet", }, - { - "id": "grok", - "enabled": true, - "model": "grok-4.5", - "effort": "low" - } - ] - } + ], + }, } ``` -Each entry controls one provider. Providers omitted from the array are disabled. -`model` and `effort` are optional defaults; an invocation override wins over a -profile value, which wins over the provider default. The legacy boolean -`"subagents": true` remains readable and enables every provider, but new -configuration should use the explicit object form. - -`devspace agents targets` shows usable providers and profiles for the current -workspace. Add `--json` for a compact list of exact target names and their -selection metadata. Disabled, unavailable, and unconfigured providers are -omitted. Provider availability is runtime state and never rewrites the -configuration. - -Grok Build is discovered from the `grok` executable. Authenticate it with -`grok login` or `XAI_API_KEY`; DevSpace does not read or store Grok credentials. -Grok supports `grok-build` by default and validates explicit model and effort -values against the ACP session metadata when available. Set `GROK_COMMAND` when -the executable is not on the normal PATH. If your Grok installation selects a -custom agent profile, set `GROK_AGENT_PROFILE` to that profile's path; DevSpace -passes it to `grok agent stdio` without writing to Grok's configuration. - -`open_workspace` returns a compact catalog containing profile names, -descriptions, providers, and optional models/effort levels so the host model can choose an -agent without reading provider-specific launch details. Disabled or unavailable -providers and their profiles are omitted from this model-facing catalog. `devspace agents ls` -lists existing subagent sessions for the current workspace, scoped by the -workspace environment injected into shell commands. The `subagents` -skill teaches the model to use only the minimal `devspace agents ls`, -`devspace agents targets`, `devspace agents run`, `devspace agents continue`, -and `devspace agents show` workflow. - -For Codex, Claude Code, OpenCode, Pi, or another supported Coding Agent, use -the Skills CLI to install the same skill. DevSpace setup prints this command but -does not run it or write into agent skill directories: - -```bash -npx skills add Waishnav/devspace --skill subagents --global -``` +Profiles are loaded from `~/.devspace/agents/*.md` and project +`.devspace/agents/*.md`. `devspace agents targets` prints the configured targets +available in the current workspace. -Starter profile templates are available under `examples/agents/`. Copy or adapt -them into one of the active profile directories before use. +Provider executable discovery remains process-scoped. The supported overrides +are `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, +`COPILOT_COMMAND`, `GROK_COMMAND`, and `GROK_AGENT_PROFILE`. DevSpace does not +persist provider credentials. -Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. +## Native artifact download -Example: +Set `artifacts.enabled` to `true` when a host needs to save a native attached or +generated file into an open workspace. `artifacts.maxFileBytes` limits one +streamed file. The secure publication path is currently available only on +Linux; the tool is not registered on macOS, Windows, or BSD. -```bash -DEVSPACE_SKILL_PATHS="$HOME/.claude/skills,$HOME/company/skills" \ -npx @waishnav/devspace serve -``` +## Environment boundary -## Logging +Only two user-facing DevSpace environment variables remain: -| Variable | Default | +| Variable | Purpose | | --- | --- | -| `DEVSPACE_LOG_LEVEL` | `info` | -| `DEVSPACE_LOG_FORMAT` | `json` | -| `DEVSPACE_LOG_REQUESTS` | `1` | -| `DEVSPACE_LOG_ASSETS` | `0` | -| `DEVSPACE_LOG_TOOL_CALLS` | `1` | -| `DEVSPACE_LOG_SHELL_COMMANDS` | `0` | -| `DEVSPACE_TRUST_PROXY` | `0` | - -Set `DEVSPACE_LOG_FORMAT=pretty` for local debugging. - -Set `DEVSPACE_LOG_SHELL_COMMANDS=1` only when you intentionally want command -previews in logs. - -## Env-Only Example - -```bash -DEVSPACE_OAUTH_OWNER_TOKEN="$(openssl rand -base64 32)" \ -DEVSPACE_ALLOWED_ROOTS="$HOME/personal,$HOME/work" \ -DEVSPACE_PUBLIC_BASE_URL="https://devspace.example.com" \ -DEVSPACE_WORKTREE_ROOT="$HOME/.devspace/worktrees" \ -DEVSPACE_ARTIFACTS="1" \ -npx @waishnav/devspace serve -``` +| `DEVSPACE_CONFIG_DIR` | Bootstrap location for `config.jsonc`, `auth.json`, skills, and profiles. | +| `DEVSPACE_OAUTH_OWNER_TOKEN` | Optional secret override for the owner token stored in `auth.json`. | -The environment assignments must be part of the same command invocation, or -exported first. +Durable environment settings were removed in v1.1. Move existing deployment +values to these JSONC keys: + +| Removed setting | JSONC key | +| --- | --- | +| `HOST`, `PORT` | `server.host`, `server.port` | +| `DEVSPACE_PUBLIC_BASE_URL` | `server.publicBaseUrl` | +| `DEVSPACE_ALLOWED_HOSTS` | `server.allowedHosts` | +| `DEVSPACE_TRUST_PROXY` | `server.trustProxy` | +| `DEVSPACE_ALLOWED_ROOTS` | `workspaces.allowedRoots` | +| `DEVSPACE_WORKTREE_ROOT` | `workspaces.worktreeRoot` | +| `DEVSPACE_STATE_DIR` | `storage.stateDir` | +| `DEVSPACE_TOOL_MODE`, `DEVSPACE_MINIMAL_TOOLS` | `tools.mode` | +| `DEVSPACE_WIDGETS` | `ui.enabled` | +| `DEVSPACE_ARTIFACTS` | `artifacts.enabled` | +| `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` | `artifacts.maxFileBytes` | +| `DEVSPACE_SKILLS` | `skills.enabled` | +| `DEVSPACE_SKILL_PATHS` | `skills.paths` | +| `DEVSPACE_AGENT_DIR` | `skills.agentDir` | +| `DEVSPACE_SUBAGENTS` | `subagents.enabled` | +| `DEVSPACE_LOG_LEVEL` | `logging.level` | +| `DEVSPACE_LOG_FORMAT` | `logging.format` | +| `DEVSPACE_LOG_REQUESTS` | `logging.requests` | +| `DEVSPACE_LOG_ASSETS` | `logging.assets` | +| `DEVSPACE_LOG_TOOL_CALLS` | `logging.toolCalls` | +| `DEVSPACE_LOG_SHELL_COMMANDS` | `logging.shellCommands` | +| `DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS` | `oauth.accessTokenTtlSeconds` | +| `DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `oauth.refreshTokenTtlSeconds` | +| `DEVSPACE_OAUTH_SCOPES` | `oauth.scopes` | +| `DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS` | `oauth.allowedRedirectHosts` | + +These environment values are not read or auto-imported in v1.1. Environment is +process state, so there is no reliable file DevSpace can migrate on the user's +behalf. + +## v1.0 file migration + +The first v1.1 load performs one migration when `config.jsonc` is missing and +`config.json` exists: + +1. Validate the old JSON document. +2. Translate its known fields into the versioned JSONC structure. +3. Write and validate a temporary `config.jsonc`. +4. Atomically publish it. +5. Rename the old file to `config.json.v1.0.bak`. + +If `config.jsonc` exists, DevSpace never reads `config.json`. Invalid JSONC also +never falls back to the old file. Unsupported legacy keys stop migration with an +actionable error instead of being silently discarded. + +The persisted fields map as follows: + +| v1.0 JSON field | v1.1 JSONC key | +| --- | --- | +| `host`, `port` | `server.host`, `server.port` | +| `publicBaseUrl`, `allowedHosts` | `server.publicBaseUrl`, `server.allowedHosts` | +| `allowedRoots`, `worktreeRoot` | `workspaces.allowedRoots`, `workspaces.worktreeRoot` | +| `stateDir` | `storage.stateDir` | +| `artifactsEnabled`, `artifactMaxFileBytes` | `artifacts.enabled`, `artifacts.maxFileBytes` | +| `agentDir` | `skills.agentDir` | +| `subagents` | `subagents` | +| `tools.mode`, `ui.enabled` | unchanged nested keys | + +`auth.json` is unchanged. diff --git a/docs/gotchas.md b/docs/gotchas.md index cd5369314..2f6093552 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -69,10 +69,10 @@ npx @waishnav/devspace config set publicBaseUrl https://your-tunnel-host.example Temporary tunnels often change URLs between runs. -For a one-off run: +Update the configured URL: ```bash -DEVSPACE_PUBLIC_BASE_URL="https://new-tunnel.example.com" npx @waishnav/devspace serve +npx @waishnav/devspace config set publicBaseUrl https://new-tunnel.example.com ``` For a stable URL: @@ -94,11 +94,8 @@ npx @waishnav/devspace doctor Confirm the public URL hostname appears in allowed hosts. If you changed tunnel URLs, update `publicBaseUrl`. -Use this only for intentional local debugging: - -```bash -DEVSPACE_ALLOWED_HOSTS="*" npx @waishnav/devspace serve -``` +For intentional local debugging only, set `server.allowedHosts` to `["*"]` in +`~/.devspace/config.jsonc`. ## OAuth Redirect Host Rejected @@ -110,11 +107,8 @@ localhost 127.0.0.1 ``` -If another MCP client uses a different redirect host, configure: - -```bash -DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS="chatgpt.com,example.com" npx @waishnav/devspace serve -``` +If another MCP client uses a different redirect host, add it to +`oauth.allowedRedirectHosts` in `~/.devspace/config.jsonc`. ## Owner Password Not Accepted @@ -204,11 +198,8 @@ Confirm Bash is detected. ## Skills Do Not Appear -Skills are enabled by default. Check: - -```bash -DEVSPACE_SKILLS=1 npx @waishnav/devspace serve -``` +Skills are enabled by default. Confirm `skills.enabled` is `true` in +`~/.devspace/config.jsonc`. DevSpace looks in standard Agent Skills locations: @@ -219,8 +210,8 @@ DevSpace looks in standard Agent Skills locations: It also checks compatibility and custom paths: - the bundled `subagents` skill when Subagents are enabled, unless `~/.devspace/skills/subagents/SKILL.md` exists -- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` -- additional paths from `DEVSPACE_SKILL_PATHS` +- `skills.agentDir/skills`, defaulting to `~/.codex/skills` +- additional paths from `skills.paths` When Subagents are enabled, DevSpace loads agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`, then exposes a @@ -246,7 +237,7 @@ not copy files into agent skill directories. Packaged agent profile examples under `examples/agents/` are starter templates. Copy or adapt them into one of the active profile directories before use. -Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. +Legacy project paths such as `.pi/skills` can be added to `skills.paths` when needed. If a skill appears in `open_workspace`, the model must read that skill's `SKILL.md` before reading other files inside the skill directory. @@ -259,4 +250,4 @@ to avoid one iframe per call. Plain MCP clients may ignore ChatGPT Apps widget metadata and only show text results; `show_changes` remains available there. If both cards are missing in ChatGPT, confirm that `ui.enabled` is not `false` -in `~/.devspace/config.json` and reconnect the MCP server. +in `~/.devspace/config.jsonc` and reconnect the MCP server. diff --git a/docs/security.md b/docs/security.md index d7ec0e1d6..69bbc1303 100644 --- a/docs/security.md +++ b/docs/security.md @@ -51,8 +51,8 @@ DEVSPACE_OAUTH_OWNER_TOKEN="$(openssl rand -base64 32)" ## Public URL And Host Allowlist -DevSpace needs `DEVSPACE_PUBLIC_BASE_URL` so MCP clients can discover OAuth -metadata and connect to the correct resource. +DevSpace needs `server.publicBaseUrl` in `config.jsonc` so MCP clients can +discover OAuth metadata and connect to the correct resource. The value should be the origin only: @@ -60,10 +60,10 @@ The value should be the origin only: https://your-tunnel-host.example.com ``` -Do not include `/mcp` in `DEVSPACE_PUBLIC_BASE_URL`. +Do not include `/mcp` in `server.publicBaseUrl`. By default, DevSpace derives allowed Host headers from the local host and public -URL. Use `DEVSPACE_ALLOWED_HOSTS=*` only for intentional local debugging. +URL. Put `"*"` in `server.allowedHosts` only for intentional local debugging. ## Tunnels @@ -112,7 +112,7 @@ execute transferred content. ## Logs By default, DevSpace logs requests and tool calls. Shell command previews are -disabled unless `DEVSPACE_LOG_SHELL_COMMANDS=1`. +disabled unless `logging.shellCommands` is `true`. Do not enable shell command logging if commands may contain secrets. diff --git a/docs/setup.md b/docs/setup.md index 934b0b8c8..e5e76d8f9 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -56,7 +56,7 @@ remain limited to the roots configured for ChatGPT. Setup detects supported Coding Agents and asks which ones DevSpace may use. These choices are stored as provider objects under `subagents` in -`~/.devspace/config.json`. +`~/.devspace/config.jsonc`. If you selected Coding Agents, setup prints: @@ -99,13 +99,7 @@ Run: npx @waishnav/devspace serve ``` -If your tunnel URL changes for one run, override it without rewriting config: - -```bash -DEVSPACE_PUBLIC_BASE_URL="https://new-tunnel.example.com" npx @waishnav/devspace serve -``` - -For a stable public URL, persist it: +If your tunnel URL changes, update the persisted value before starting: ```bash npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com @@ -120,7 +114,7 @@ password approval page. Enter the Owner password printed during setup. The default config files are: ```text -~/.devspace/config.json +~/.devspace/config.jsonc ~/.devspace/auth.json ``` diff --git a/src/cli.ts b/src/cli.ts index c745e791e..16cc950df 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -99,6 +99,9 @@ function normalizeCommand(command: string | undefined): Command { async function ensureConfigured(): Promise { const files = loadDevspaceFiles(); + if (files.migratedLegacyConfig) { + console.log(`Migrated ${files.dir}/config.json to ${files.configPath}`); + } if (files.configExists && files.authExists) return; if (process.env.DEVSPACE_OAUTH_OWNER_TOKEN) return; @@ -674,10 +677,6 @@ async function textPrompt(options: TextPromptOptions): Promise { return value || options.defaultValue; } -function isValidPort(value: unknown): value is number { - return Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 65535; -} - function validateRequiredPublicBaseUrl(value: string | undefined): string | undefined { const trimmed = value?.trim() ?? ""; if (!trimmed) return "Enter the public URL from your tunnel or reverse proxy."; From c9ce866c6ed8c2f58baa54e176800e64d3f86f16 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:43:31 +0530 Subject: [PATCH 05/11] fix(config): preserve migration and daemon ownership --- src/cli.ts | 2 +- src/config.test.ts | 1 + src/config.ts | 2 ++ src/local-agent-client.ts | 26 +++++++++++++++++++++----- src/local-agent-daemon.test.ts | 12 +++++++++++- src/user-config.ts | 5 +++++ 6 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 16cc950df..f5e3bcb5f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -100,7 +100,7 @@ function normalizeCommand(command: string | undefined): Command { async function ensureConfigured(): Promise { const files = loadDevspaceFiles(); if (files.migratedLegacyConfig) { - console.log(`Migrated ${files.dir}/config.json to ${files.configPath}`); + console.log(`Migrated legacy configuration to ${files.configPath}`); } if (files.configExists && files.authExists) return; if (process.env.DEVSPACE_OAUTH_OWNER_TOKEN) return; diff --git a/src/config.test.ts b/src/config.test.ts index e353de53c..47a39652a 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -73,6 +73,7 @@ try { writeDevspaceAuth({ ownerToken: "persisted-owner-token-long-enough" }, env); const configured = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); + assert.equal(configured.configDir, configDir); assert.equal(configured.host, "0.0.0.0"); assert.equal(configured.port, 8787); assert.equal(configured.publicBaseUrl, "https://devspace.example.com"); diff --git a/src/config.ts b/src/config.ts index 496f3bdb8..e53305268 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,7 @@ import type { SubagentsConfig } from "./local-agent-config.js"; export type { ToolMode } from "./config-schema.js"; export interface ServerConfig { + configDir: string; host: string; port: number; oauth: OAuthConfig; @@ -48,6 +49,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { ]; return { + configDir: files.dir, host, port, oauth: { diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 157826eb5..01f8c1cdc 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -49,6 +49,7 @@ import type { StartLocalAgentInput, } from "./local-agent-manager.js"; import type { LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import { devspaceConfigDir } from "./user-config.js"; const DEFAULT_STARTUP_TIMEOUT_MS = 8_000; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -63,6 +64,7 @@ type RequestError = export interface LocalAgentClientOptions { stateDir: string; + configDir?: string; startupTimeoutMs?: number; requestTimeoutMs?: number; spawnDaemon?: () => void; @@ -84,7 +86,9 @@ export class LocalAgentClient { this.endpoint = options.endpoint ?? this.paths.endpoint; this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; - this.spawnDaemon = options.spawnDaemon ?? (() => spawnLocalAgentDaemon()); + this.spawnDaemon = options.spawnDaemon ?? (() => spawnLocalAgentDaemon( + options.configDir ?? devspaceConfigDir(), + )); } async run( @@ -404,21 +408,33 @@ export class LocalAgentClient { } } -export function createLocalAgentClient(config: Pick): LocalAgentClient { - return new LocalAgentClient({ stateDir: config.stateDir }); +export function createLocalAgentClient( + config: Pick, +): LocalAgentClient { + return new LocalAgentClient({ configDir: config.configDir, stateDir: config.stateDir }); } -export function spawnLocalAgentDaemon(env: NodeJS.ProcessEnv = process.env): void { +export function spawnLocalAgentDaemon( + configDir: string, + env: NodeJS.ProcessEnv = process.env, +): void { const entrypoint = resolveDaemonEntrypoint(); const child = spawn(process.execPath, [...daemonExecArgv(process.execArgv), entrypoint], { detached: true, stdio: "ignore", windowsHide: true, - env, + env: localAgentDaemonEnvironment(configDir, env), }); child.unref(); } +export function localAgentDaemonEnvironment( + configDir: string, + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + return { ...env, DEVSPACE_CONFIG_DIR: configDir }; +} + export function daemonExecArgv(execArgv: readonly string[]): string[] { const result: string[] = []; for (let index = 0; index < execArgv.length; index += 1) { diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 3b5abef45..6ea66e652 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -5,7 +5,11 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { createConnection, createServer as createNetServer } from "node:net"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { daemonExecArgv, LocalAgentClient } from "./local-agent-client.js"; +import { + daemonExecArgv, + localAgentDaemonEnvironment, + LocalAgentClient, +} from "./local-agent-client.js"; import { LocalAgentDaemon, type LocalAgentDaemonManager } from "./local-agent-daemon.js"; import { ensureLocalAgentDaemonSecret, @@ -112,6 +116,12 @@ assert.deepEqual( "detached daemon startup must not inherit inspector flags", ); +assert.deepEqual( + localAgentDaemonEnvironment("/alternate/config", { PATH: "/bin" }), + { PATH: "/bin", DEVSPACE_CONFIG_DIR: "/alternate/config" }, + "the daemon must reload the same persisted configuration as its client", +); + let shutdownSocket: ReturnType | undefined; try { const started = unwrap(await client.run({ diff --git a/src/user-config.ts b/src/user-config.ts index b1524ee01..051fe1f15 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -153,13 +153,18 @@ function migrateLegacyConfigFile( } const temporaryPath = temporaryFilePath(configPath); + let published = false; try { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(temporaryPath, serializeConfig(migrated), { mode: 0o600, flag: "wx" }); readJsoncConfig(temporaryPath); renameSync(temporaryPath, configPath); + published = true; renameSync(legacyPath, backupPath); } catch (error) { + if (published && existsSync(legacyPath)) { + rmSync(configPath, { force: true }); + } rmSync(temporaryPath, { force: true }); throw fileError("migrate", legacyPath, error); } From 172608302ee123521429d81dbe4adc5450700658 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:11:19 +0530 Subject: [PATCH 06/11] fix(config): make legacy migration race-safe --- src/user-config.test.ts | 68 +++++++++++++++++++++++++++++++++++++++++ src/user-config.ts | 20 ++++++++++-- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/user-config.test.ts b/src/user-config.test.ts index 9ff9df6e6..01ae8ca9d 100644 --- a/src/user-config.test.ts +++ b/src/user-config.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, @@ -44,6 +45,24 @@ withConfigDir((configDir, env) => { assert.equal(nextLoad.migratedLegacyConfig, false); }); +await withConfigDirAsync(async (configDir) => { + writeFileSync(join(configDir, "config.json"), JSON.stringify({ + port: 8787, + allowedRoots: ["/work"], + })); + + const results = await Promise.all([ + migrateInChildProcess(configDir), + migrateInChildProcess(configDir), + ]); + assert.equal(results.filter((result) => result.migrated).length, 1); + assert.equal(results.filter((result) => !result.migrated).length, 1); + assert.equal(existsSync(join(configDir, "config.json")), false); + assert.equal(existsSync(join(configDir, "config.jsonc")), true); + assert.equal(existsSync(join(configDir, "config.json.v1.0.bak")), true); + assert.equal(loadDevspaceFiles({ DEVSPACE_CONFIG_DIR: configDir }).config.server.port, 8787); +}); + withConfigDir((configDir, env) => { writeFileSync(join(configDir, "config.jsonc"), `{ // This comment must survive config updates. @@ -101,3 +120,52 @@ function withConfigDir( rmSync(configDir, { recursive: true, force: true }); } } + +async function withConfigDirAsync( + test: (configDir: string) => Promise, +): Promise { + const configDir = mkdtempSync(join(tmpdir(), "devspace-user-config-test-")); + try { + await test(configDir); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +} + +async function migrateInChildProcess( + configDir: string, +): Promise<{ migrated: boolean }> { + const moduleUrl = new URL("./user-config.ts", import.meta.url).href; + const source = [ + `import { loadDevspaceFiles } from ${JSON.stringify(moduleUrl)};`, + "const files = loadDevspaceFiles();", + "process.stdout.write(JSON.stringify({ migrated: files.migratedLegacyConfig }));", + ].join("\n"); + + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", source], + { + env: { ...process.env, DEVSPACE_CONFIG_DIR: configDir }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code) => { + if (code !== 0) { + reject(new Error(`migration child exited with ${code}: ${stderr}`)); + return; + } + resolve(JSON.parse(stdout) as { migrated: boolean }); + }); + }); +} diff --git a/src/user-config.ts b/src/user-config.ts index 051fe1f15..7b62b7615 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { existsSync, + linkSync, mkdirSync, readFileSync, renameSync, @@ -140,7 +141,7 @@ function migrateLegacyConfigFile( legacyPath: string, configPath: string, backupPath: string, -): true { +): boolean { if (existsSync(backupPath)) { throw new Error(`Unable to migrate ${legacyPath}: backup already exists at ${backupPath}`); } @@ -158,15 +159,24 @@ function migrateLegacyConfigFile( mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(temporaryPath, serializeConfig(migrated), { mode: 0o600, flag: "wx" }); readJsoncConfig(temporaryPath); - renameSync(temporaryPath, configPath); + try { + // A hard link publishes the complete temporary file atomically without + // replacing config.jsonc if another first-start process won the race. + linkSync(temporaryPath, configPath); + } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") throw error; + readJsoncConfig(configPath); + return false; + } published = true; renameSync(legacyPath, backupPath); } catch (error) { if (published && existsSync(legacyPath)) { rmSync(configPath, { force: true }); } - rmSync(temporaryPath, { force: true }); throw fileError("migrate", legacyPath, error); + } finally { + rmSync(temporaryPath, { force: true }); } return true; } @@ -236,4 +246,8 @@ function fileError(action: "read" | "migrate", filePath: string, error: unknown) return new DevspaceConfigFileError(`Unable to ${action} ${filePath}: ${reason}`); } +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + class DevspaceConfigFileError extends Error {} From 8cf4fcb9692d2717d5cb5c8ae3d8d3b18356b2aa Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:11:48 +0530 Subject: [PATCH 07/11] fix(ci): normalize schema line endings --- src/config-schema.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config-schema.test.ts b/src/config-schema.test.ts index f76c9ac60..129224df7 100644 --- a/src/config-schema.test.ts +++ b/src/config-schema.test.ts @@ -21,7 +21,7 @@ const generatedSchema = `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\ const committedSchema = readFileSync( resolve("schema/v1/devspace.schema.json"), "utf8", -); +).replace(/\r\n/g, "\n"); assert.equal(committedSchema, generatedSchema, "run `npm run schema:config` after changing config-schema.ts"); console.log("config schema tests passed"); From d1ae28673b5033458d4d185cf9cf0b541222e6c9 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:28:19 +0530 Subject: [PATCH 08/11] fix(config): preserve tunnel URL during init --- src/cli.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f5e3bcb5f..361c28a47 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -225,9 +225,8 @@ async function runInit({ force }: { force: boolean }): Promise { ...files.config, server: { ...files.config.server, - host: files.config.server.host, port, - publicBaseUrl, + ...(useChatGpt ? { publicBaseUrl } : {}), }, workspaces: { ...files.config.workspaces, From b22412932dea396151f99e8a58a5e46a7b302ffe Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:28:40 +0530 Subject: [PATCH 09/11] fix(config): explain migration conflict recovery --- src/user-config.test.ts | 14 ++++++++++++++ src/user-config.ts | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/user-config.test.ts b/src/user-config.test.ts index 01ae8ca9d..5f5fe477b 100644 --- a/src/user-config.test.ts +++ b/src/user-config.test.ts @@ -107,6 +107,20 @@ withConfigDir((configDir, env) => { assert.equal(existsSync(join(configDir, "config.json.v1.0.bak")), false); }); +withConfigDir((configDir, env) => { + const legacyPath = join(configDir, "config.json"); + const backupPath = join(configDir, "config.json.v1.0.bak"); + writeFileSync(legacyPath, JSON.stringify({ port: 8787 })); + writeFileSync(backupPath, JSON.stringify({ port: 7676 })); + + assert.throws( + () => loadDevspaceFiles(env), + (error: unknown) => error instanceof Error + && error.message.includes(`backup already exists at ${backupPath}`) + && error.message.includes(`Move ${backupPath} out of the way, then run DevSpace again.`), + ); +}); + console.log("user config tests passed"); function withConfigDir( diff --git a/src/user-config.ts b/src/user-config.ts index 7b62b7615..c6f7bc1f2 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -143,7 +143,10 @@ function migrateLegacyConfigFile( backupPath: string, ): boolean { if (existsSync(backupPath)) { - throw new Error(`Unable to migrate ${legacyPath}: backup already exists at ${backupPath}`); + throw new Error( + `Unable to migrate ${legacyPath}: backup already exists at ${backupPath}. ` + + `Move ${backupPath} out of the way, then run DevSpace again.`, + ); } let migrated: DevspaceConfig; From d5ef7192eddc817eac0fa288afa3f32c35d22356 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:29:23 +0530 Subject: [PATCH 10/11] fix(config): preserve comments during init --- src/cli.ts | 27 +++++++++++---------------- src/user-config.test.ts | 10 ++++++++++ src/user-config.ts | 21 ++++++++++++++++++--- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 361c28a47..922481516 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -46,9 +46,8 @@ import { generateOwnerToken, loadDevspaceFiles, setDevspaceConfigValue, + setDevspaceConfigValues, writeDevspaceAuth, - writeDevspaceConfig, - type DevspaceUserConfig, } from "./user-config.js"; import { expandHomePath } from "./roots.js"; import { shutdownHttpServer } from "./server-shutdown.js"; @@ -221,24 +220,20 @@ async function runInit({ force }: { force: boolean }): Promise { selectedProviders, ); - const config: DevspaceUserConfig = { - ...files.config, - server: { - ...files.config.server, - port, - ...(useChatGpt ? { publicBaseUrl } : {}), - }, - workspaces: { - ...files.config.workspaces, - ...(allowedRoots ? { allowedRoots } : {}), - }, - subagents, - }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), }; - writeDevspaceConfig(config); + setDevspaceConfigValues([ + { path: ["server", "port"], value: port }, + ...(useChatGpt + ? [{ path: ["server", "publicBaseUrl"], value: publicBaseUrl }] + : []), + ...(allowedRoots + ? [{ path: ["workspaces", "allowedRoots"], value: allowedRoots }] + : []), + { path: ["subagents"], value: subagents }, + ]); writeDevspaceAuth(auth); const lines = [ diff --git a/src/user-config.test.ts b/src/user-config.test.ts index 5f5fe477b..8e09b9122 100644 --- a/src/user-config.test.ts +++ b/src/user-config.test.ts @@ -12,6 +12,7 @@ import { join } from "node:path"; import { loadDevspaceFiles, setDevspaceConfigValue, + setDevspaceConfigValues, } from "./user-config.js"; withConfigDir((configDir, env) => { @@ -80,6 +81,15 @@ withConfigDir((configDir, env) => { const updated = readFileSync(join(configDir, "config.jsonc"), "utf8"); assert.match(updated, /This comment must survive config updates/); assert.equal(loadDevspaceFiles(env).config.server.publicBaseUrl, "https://new.example.com"); + + setDevspaceConfigValues([ + { path: ["server", "port"], value: 7676 }, + { path: ["tools", "mode"], value: "claude" }, + ], env); + const multiUpdated = readFileSync(join(configDir, "config.jsonc"), "utf8"); + assert.match(multiUpdated, /This comment must survive config updates/); + assert.equal(loadDevspaceFiles(env).config.server.port, 7676); + assert.equal(loadDevspaceFiles(env).config.tools.mode, "claude"); }); withConfigDir((configDir, env) => { diff --git a/src/user-config.ts b/src/user-config.ts index c6f7bc1f2..6775e4b7c 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -45,6 +45,11 @@ export interface DevspaceFiles { migratedLegacyConfig: boolean; } +export interface DevspaceConfigEdit { + path: (string | number)[]; + value: unknown; +} + export function devspaceConfigDir(env: NodeJS.ProcessEnv = process.env): string { return resolve(expandHomePath(env.DEVSPACE_CONFIG_DIR ?? join(homedir(), ".devspace"))); } @@ -110,14 +115,24 @@ export function setDevspaceConfigValue( path: (string | number)[], value: unknown, env: NodeJS.ProcessEnv = process.env, +): string { + return setDevspaceConfigValues([{ path, value }], env); +} + +export function setDevspaceConfigValues( + edits: DevspaceConfigEdit[], + env: NodeJS.ProcessEnv = process.env, ): string { const files = loadDevspaceFiles(env); const source = files.configExists ? readFileSync(files.configPath, "utf8") : serializeConfig(files.config); - const updated = applyEdits(source, modify(source, path, value, { - formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, - })); + const updated = edits.reduce( + (document, edit) => applyEdits(document, modify(document, edit.path, edit.value, { + formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, + })), + source, + ); parseJsoncConfig(updated, files.configPath); atomicWrite(files.configPath, updated.endsWith("\n") ? updated : `${updated}\n`, 0o600); return files.configPath; From 3f4ff9e59bbf003d02827dca8964878c28dc582b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:29:44 +0530 Subject: [PATCH 11/11] fix(config): anchor schema paths to modules --- scripts/generate-config-schema.ts | 5 ++--- src/config-schema.test.ts | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/scripts/generate-config-schema.ts b/scripts/generate-config-schema.ts index 2e56f4a8e..324b611b6 100644 --- a/scripts/generate-config-schema.ts +++ b/scripts/generate-config-schema.ts @@ -1,10 +1,9 @@ import { mkdirSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; import { devspaceConfigJsonSchema, } from "../src/config-schema.js"; -const outputPath = resolve("schema/v1/devspace.schema.json"); +const outputPath = new URL("../schema/v1/devspace.schema.json", import.meta.url); -mkdirSync(dirname(outputPath), { recursive: true }); +mkdirSync(new URL(".", outputPath), { recursive: true }); writeFileSync(outputPath, `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\n`); diff --git a/src/config-schema.test.ts b/src/config-schema.test.ts index 129224df7..78438f63c 100644 --- a/src/config-schema.test.ts +++ b/src/config-schema.test.ts @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; import { defaultDevspaceConfig, devspaceConfigJsonSchema, @@ -19,7 +18,7 @@ assert.throws( const generatedSchema = `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\n`; const committedSchema = readFileSync( - resolve("schema/v1/devspace.schema.json"), + new URL("../schema/v1/devspace.schema.json", import.meta.url), "utf8", ).replace(/\r\n/g, "\n"); assert.equal(committedSchema, generatedSchema, "run `npm run schema:config` after changing config-schema.ts");