From 8f8ff21c9594aaf00ddee3e3f4de9c41ec4f16d6 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:15:36 +0530 Subject: [PATCH 1/3] feat(config): load versioned jsonc configuration --- package-lock.json | 6 ++ package.json | 1 + src/cli.ts | 38 ++++--- src/config.ts | 114 ++++++++++++-------- src/harness.ts | 14 +++ src/presentation.ts | 8 ++ src/user-config.ts | 247 +++++++++++++++++++++++++++++++++++++++++--- 7 files changed, 354 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b5c247a..e8d69ce2 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", @@ -4450,6 +4451,11 @@ "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==" + }, "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 fcd5734a..73ab3485 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,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/src/cli.ts b/src/cli.ts index 20e61649..13b4e3ce 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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.server?.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 = isValidPort(files.config.server?.port) ? files.config.server?.port : 7676; let publicBaseUrl: string | null = null; if (useChatGpt) { @@ -180,11 +180,11 @@ 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, })); } @@ -220,17 +220,21 @@ async function runInit({ force }: { force: boolean }): Promise { const config: DevspaceUserConfig = { ...files.config, - host: files.config.host ?? "127.0.0.1", - port, - ...(allowedRoots ? { allowedRoots } : {}), - publicBaseUrl, + version: 1, + server: { + ...files.config.server, + host: files.config.server?.host ?? "127.0.0.1", + port, + ...(allowedRoots ? { allowedRoots } : {}), + publicBaseUrl, + }, subagents, }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), }; - writeDevspaceConfig(config, process.env, files.configDocument); + writeDevspaceConfig(config, process.env, files); writeDevspaceAuth(auth); const lines = [ @@ -322,7 +326,7 @@ async function serve(): Promise { async function runDoctor(): Promise { const files = loadDevspaceFiles(); console.log(`Config dir: ${files.dir}`); - console.log(`Config file: ${files.configExists ? files.configPath : "missing"}`); + console.log(`Config file: ${files.configSourcePath ?? "missing"}`); console.log(`Auth file: ${files.authExists ? files.authPath : "missing"}`); console.log(`Node: ${process.version} (${nodeVersionStatus()})`); console.log(`Node ABI: ${process.versions.modules}`); @@ -371,8 +375,12 @@ function runConfigCommand(args: string[]): void { writeDevspaceConfig({ ...files.config, - publicBaseUrl: normalizeOptionalPublicBaseUrl(value), - }, process.env, files.configDocument); + version: 1, + server: { + ...files.config.server, + publicBaseUrl: normalizeOptionalPublicBaseUrl(value), + }, + }, process.env, files); console.log(`Updated ${files.configPath}`); } diff --git a/src/config.ts b/src/config.ts index 11340013..65520342 100644 --- a/src/config.ts +++ b/src/config.ts @@ -98,7 +98,7 @@ function parseBoolean(value: string | undefined, name: string): boolean { throw new Error(`Invalid ${name}: ${value}`); } -function parseLegacyToolMode(env: NodeJS.ProcessEnv): LegacyToolMode { +function parseLegacyToolModeOverride(env: NodeJS.ProcessEnv): LegacyToolMode | undefined { const mode = env.DEVSPACE_TOOL_MODE; if (mode === "minimal" || mode === "full" || mode === "codex") return mode; if (mode) throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${mode}`); @@ -106,24 +106,27 @@ function parseLegacyToolMode(env: NodeJS.ProcessEnv): LegacyToolMode { if (env.DEVSPACE_MINIMAL_TOOLS !== undefined) { return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS, "DEVSPACE_MINIMAL_TOOLS") ? "minimal" : "full"; } - return "minimal"; + return undefined; } -function parseLogLevel(value: string | undefined): LogLevel { - if (!value || value === "info") return "info"; +function parseLogLevel(value: string | undefined, fallback: LogLevel = "info"): LogLevel { + if (!value) return fallback; + if (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"; +function parseLogFormat(value: string | undefined, fallback: LogFormat = "json"): LogFormat { + if (!value) return fallback; + if (value === "json") return "json"; if (value === "pretty") return "pretty"; throw new Error(`Invalid DEVSPACE_LOG_FORMAT: ${value}`); } -function parsePathList(value: string | undefined): string[] { +function parsePathList(value: string | undefined, fallback: string[] = []): string[] { + if (value === undefined) return fallback; return ( value ?.split(",") @@ -157,24 +160,34 @@ function parsePositiveInteger( return parsed; } -function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { +function parseLoggingConfig( + env: NodeJS.ProcessEnv, + stored: Partial = {}, +): LoggingConfig { return { - level: parseLogLevel(env.DEVSPACE_LOG_LEVEL), - format: parseLogFormat(env.DEVSPACE_LOG_FORMAT), + level: parseLogLevel(env.DEVSPACE_LOG_LEVEL, stored.level), + format: parseLogFormat(env.DEVSPACE_LOG_FORMAT, stored.format), requests: env.DEVSPACE_LOG_REQUESTS === undefined - ? true + ? stored.requests ?? true : parseBoolean(env.DEVSPACE_LOG_REQUESTS, "DEVSPACE_LOG_REQUESTS"), - assets: parseBoolean(env.DEVSPACE_LOG_ASSETS, "DEVSPACE_LOG_ASSETS"), + assets: env.DEVSPACE_LOG_ASSETS === undefined + ? stored.assets ?? false + : parseBoolean(env.DEVSPACE_LOG_ASSETS, "DEVSPACE_LOG_ASSETS"), toolCalls: env.DEVSPACE_LOG_TOOL_CALLS === undefined - ? true + ? stored.toolCalls ?? true : parseBoolean(env.DEVSPACE_LOG_TOOL_CALLS, "DEVSPACE_LOG_TOOL_CALLS"), - shellCommands: parseBoolean(env.DEVSPACE_LOG_SHELL_COMMANDS, "DEVSPACE_LOG_SHELL_COMMANDS"), - trustProxy: parseBoolean(env.DEVSPACE_TRUST_PROXY, "DEVSPACE_TRUST_PROXY"), + shellCommands: env.DEVSPACE_LOG_SHELL_COMMANDS === undefined + ? stored.shellCommands ?? false + : parseBoolean(env.DEVSPACE_LOG_SHELL_COMMANDS, "DEVSPACE_LOG_SHELL_COMMANDS"), + trustProxy: env.DEVSPACE_TRUST_PROXY === undefined + ? stored.trustProxy ?? false + : parseBoolean(env.DEVSPACE_TRUST_PROXY, "DEVSPACE_TRUST_PROXY"), }; } -function parseLegacyWidgetMode(value: string | undefined): LegacyWidgetMode { - if (!value || value === "full") return "full"; +function parseLegacyWidgetModeOverride(value: string | undefined): LegacyWidgetMode | undefined { + if (!value) return undefined; + if (value === "full") return "full"; if (value === "off" || value === "changes") return value; throw new Error(`Invalid DEVSPACE_WIDGETS: ${value}`); @@ -191,25 +204,33 @@ function parseRequiredSecret(value: string | undefined, name: string): string { return secret; } -function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined): OAuthConfig { +function parseOAuthConfig( + env: NodeJS.ProcessEnv, + ownerToken: string | undefined, + stored: { + accessTokenTtlSeconds?: number; + refreshTokenTtlSeconds?: number; + scopes?: string[]; + allowedRedirectHosts?: string[]; + } = {}, +): OAuthConfig { return { ownerToken: parseRequiredSecret(env.DEVSPACE_OAUTH_OWNER_TOKEN ?? ownerToken, "DEVSPACE_OAUTH_OWNER_TOKEN"), accessTokenTtlSeconds: parsePositiveInteger( - env.DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS, + env.DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS ?? numberConfigValue(stored.accessTokenTtlSeconds), DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS, "DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS", ), refreshTokenTtlSeconds: parsePositiveInteger( - env.DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS, + env.DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS ?? numberConfigValue(stored.refreshTokenTtlSeconds), 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", - ]), + scopes: parseStringList(env.DEVSPACE_OAUTH_SCOPES, stored.scopes ?? ["devspace"]), + allowedRedirectHosts: parseStringList( + env.DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS, + stored.allowedRedirectHosts ?? ["chatgpt.com", "localhost", "127.0.0.1"], + ), }; } @@ -227,10 +248,13 @@ function defaultAgentDir(): string { 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 storedServer = files.config.server ?? {}; + const storedSkills = files.config.skills ?? {}; + const storedArtifacts = files.config.artifacts ?? {}; + const host = env.HOST ?? storedServer.host ?? "127.0.0.1"; + const port = parsePort(env.PORT ?? storedServer.port); const publicBaseUrl = parsePublicBaseUrl( - env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), + env.DEVSPACE_PUBLIC_BASE_URL ?? storedServer.publicBaseUrl ?? localPublicBaseUrl(host, port), ); const derivedAllowedHosts = [ "localhost", @@ -238,38 +262,44 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { "::1", host, new URL(publicBaseUrl).hostname, - ...(files.config.allowedHosts ?? []), + ...(storedServer.allowedHosts ?? []), ]; + const legacyToolMode = parseLegacyToolModeOverride(env); + const legacyWidgetMode = parseLegacyWidgetModeOverride(env.DEVSPACE_WIDGETS); return { host, port, - oauth: parseOAuthConfig(env, files.auth.ownerToken), - allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), + oauth: parseOAuthConfig(env, files.auth.ownerToken, files.config.oauth), + allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? storedServer.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, - harness: harnessFromLegacyToolMode(parseLegacyToolMode(env)), - presentation: presentationFromLegacyWidgetMode(parseLegacyWidgetMode(env.DEVSPACE_WIDGETS)), - stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), - worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), + harness: legacyToolMode + ? harnessFromLegacyToolMode(legacyToolMode) + : files.config.harness ?? { kind: "claude-code", inspection: "shell" }, + presentation: legacyWidgetMode + ? presentationFromLegacyWidgetMode(legacyWidgetMode) + : files.config.presentation ?? { mode: "inline" }, + stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? storedServer.stateDir ?? defaultStateDir())), + worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? storedServer.worktreeRoot ?? defaultWorktreeRoot())), artifactsEnabled: env.DEVSPACE_ARTIFACTS === undefined - ? files.config.artifactsEnabled === true + ? storedArtifacts.enabled === true : parseBoolean(env.DEVSPACE_ARTIFACTS, "DEVSPACE_ARTIFACTS"), artifactMaxFileBytes: parsePositiveInteger( - env.DEVSPACE_ARTIFACT_MAX_FILE_BYTES ?? numberConfigValue(files.config.artifactMaxFileBytes), + env.DEVSPACE_ARTIFACT_MAX_FILE_BYTES ?? numberConfigValue(storedArtifacts.maxFileBytes), DEFAULT_ARTIFACT_MAX_FILE_BYTES, "DEVSPACE_ARTIFACT_MAX_FILE_BYTES", ), skillsEnabled: env.DEVSPACE_SKILLS === undefined - ? true + ? storedSkills.enabled ?? true : parseBoolean(env.DEVSPACE_SKILLS, "DEVSPACE_SKILLS"), - skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), + skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS, storedSkills.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), + agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? storedSkills.agentDir ?? defaultAgentDir())), + logging: parseLoggingConfig(env, files.config.logging), }; } diff --git a/src/harness.ts b/src/harness.ts index fff3320c..c9db89dd 100644 --- a/src/harness.ts +++ b/src/harness.ts @@ -1,3 +1,17 @@ +import * as z from "zod/v4"; + +export const harnessConfigSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("claude-code").describe("Expose the Claude Code-style coding harness."), + inspection: z + .enum(["shell", "dedicated"]) + .describe("Use shell inspection or expose dedicated grep/glob/ls tools."), + }), + z.object({ + kind: z.literal("codex").describe("Expose the Codex-style coding harness."), + }), +]); + export type HarnessConfig = | { kind: "claude-code"; diff --git a/src/presentation.ts b/src/presentation.ts index e1a84b07..a971b844 100644 --- a/src/presentation.ts +++ b/src/presentation.ts @@ -1,3 +1,11 @@ +import * as z from "zod/v4"; + +export const presentationConfigSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("off") }), + z.object({ mode: z.literal("inline") }), + z.object({ mode: z.literal("change-review") }), +]); + export type PresentationConfig = | { mode: "off" } | { mode: "inline" } diff --git a/src/user-config.ts b/src/user-config.ts index d8c89f16..15db5407 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -7,11 +7,88 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; +import { + applyEdits, + modify, + parse as parseJsonc, + printParseErrorCode, + type ParseError, +} from "jsonc-parser"; import * as z from "zod/v4"; +import { harnessConfigSchema } from "./harness.js"; +import { + resolveSubagentsConfig, + storedSubagentsConfigSchema, + subagentsConfigSchema, +} from "./local-agent-config.js"; +import { presentationConfigSchema } from "./presentation.js"; import { expandHomePath } from "./roots.js"; -import { storedSubagentsConfigSchema } from "./local-agent-config.js"; -const devspaceUserConfigSchema = z.object({ +export const DEVSPACE_CONFIG_VERSION = 1 as const; +export const DEVSPACE_CONFIG_SCHEMA_URL = + "https://raw.githubusercontent.com/Waishnav/devspace/refs/tags/v1.1.0/schema/devspace-config.schema.json"; + +const serverConfigSchema = z.object({ + host: z.string().optional().describe("Local bind host. Defaults to 127.0.0.1."), + port: z.number().int().min(1).max(65535).optional().describe("Local MCP server port."), + allowedRoots: z.array(z.string()).optional().describe("Project roots DevSpace may open."), + publicBaseUrl: z.string().nullable().optional().describe("Public origin used by remote MCP hosts."), + allowedHosts: z.array(z.string()).optional().describe("Optional HTTP Host allowlist."), + stateDir: z.string().optional().describe("Directory containing persisted DevSpace state."), + worktreeRoot: z.string().optional().describe("Directory for DevSpace-managed Git worktrees."), +}); + +const skillsConfigSchema = z.object({ + enabled: z.boolean().optional().describe("Whether skills are exposed to the host model."), + paths: z.array(z.string()).optional().describe("Additional skill directories."), + agentDir: z.string().optional().describe("Compatibility agent directory. Defaults to ~/.codex."), +}); + +const artifactsConfigSchema = z.object({ + enabled: z.boolean().optional().describe("Enable native MCP-host artifact download."), + maxFileBytes: z.number().int().positive().optional().describe("Maximum bytes accepted for one artifact."), +}); + +const loggingConfigSchema = z.object({ + level: z.enum(["silent", "error", "warn", "info", "debug"]).optional(), + format: z.enum(["json", "pretty"]).optional(), + requests: z.boolean().optional(), + assets: z.boolean().optional(), + toolCalls: z.boolean().optional(), + shellCommands: z.boolean().optional(), + trustProxy: z.boolean().optional(), +}); + +const oauthConfigSchema = z.object({ + accessTokenTtlSeconds: z.number().int().positive().optional(), + refreshTokenTtlSeconds: z.number().int().positive().optional(), + scopes: z.array(z.string().min(1)).optional(), + allowedRedirectHosts: z.array(z.string().min(1)).optional(), +}); + +export const devspaceConfigSchema = z.object({ + $schema: z.string().optional().describe("JSON Schema URL used by editors."), + version: z.literal(DEVSPACE_CONFIG_VERSION), + server: serverConfigSchema.optional(), + harness: harnessConfigSchema.optional(), + presentation: presentationConfigSchema.optional(), + skills: skillsConfigSchema.optional(), + artifacts: artifactsConfigSchema.optional(), + subagents: subagentsConfigSchema.optional(), + logging: loggingConfigSchema.optional(), + oauth: oauthConfigSchema.optional(), +}); + +export function createDevspaceConfigJsonSchema(): Record { + return { + ...(z.toJSONSchema(devspaceConfigSchema, { target: "draft-2020-12" }) as Record), + $id: DEVSPACE_CONFIG_SCHEMA_URL, + title: "DevSpace configuration", + description: "Versioned configuration for the DevSpace server and coding harness.", + }; +} + +const legacyDevspaceUserConfigSchema = z.object({ host: z.string().optional(), port: z.number().optional(), allowedRoots: z.array(z.string()).optional(), @@ -29,19 +106,23 @@ const devspaceAuthConfigSchema = z.object({ ownerToken: z.string().optional(), }); -export type DevspaceUserConfig = z.infer; - +export type DevspaceUserConfig = z.infer; export type DevspaceAuthConfig = z.infer; export interface DevspaceFiles { dir: string; configPath: string; + legacyConfigPath: string; authPath: string; configExists: boolean; + jsoncConfigExists: boolean; + legacyConfigExists: boolean; authExists: boolean; config: DevspaceUserConfig; auth: DevspaceAuthConfig; configDocument: Record; + configSourcePath?: string; + configSourceText?: string; } export function devspaceConfigDir(env: NodeJS.ProcessEnv = process.env): string { @@ -49,6 +130,10 @@ 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"); } @@ -66,34 +151,63 @@ 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 configPath = join(dir, "config.jsonc"); + const legacyConfigPath = join(dir, "config.json"); const authPath = join(dir, "auth.json"); - const configExists = existsSync(configPath); + const jsoncConfigExists = existsSync(configPath); + const legacyConfigExists = existsSync(legacyConfigPath); const authExists = existsSync(authPath); - - const configDocument = configExists ? readJsonObject(configPath) : {}; + const configSourcePath = jsoncConfigExists + ? configPath + : legacyConfigExists + ? legacyConfigPath + : undefined; + const configSourceText = configSourcePath ? readFileSync(configSourcePath, "utf8") : undefined; + const configDocument = configSourceText + ? readConfigDocument(configSourceText, configSourcePath!, jsoncConfigExists) + : {}; const authDocument = authExists ? readJsonObject(authPath) : {}; + const config = jsoncConfigExists + ? parseDocument(devspaceConfigSchema, configDocument, configPath) + : legacyConfigExists + ? migrateLegacyConfig(parseDocument(legacyDevspaceUserConfigSchema, configDocument, legacyConfigPath)) + : { version: DEVSPACE_CONFIG_VERSION }; return { dir, configPath, + legacyConfigPath, authPath, - configExists, + configExists: jsoncConfigExists || legacyConfigExists, + jsoncConfigExists, + legacyConfigExists, authExists, - config: parseDocument(devspaceUserConfigSchema, configDocument, configPath), + config, auth: parseDocument(devspaceAuthConfigSchema, authDocument, authPath), configDocument, + configSourcePath, + configSourceText, }; } export function writeDevspaceConfig( config: DevspaceUserConfig, env: NodeJS.ProcessEnv = process.env, - existingDocument: Record = {}, + source: Pick | undefined = undefined, ): string { const filePath = devspaceConfigPath(env); mkdirSync(devspaceConfigDir(env), { recursive: true }); - writeJsonFile(filePath, { ...existingDocument, ...config }, 0o600); + const canonical = { + ...config, + $schema: DEVSPACE_CONFIG_SCHEMA_URL, + version: DEVSPACE_CONFIG_VERSION, + } satisfies DevspaceUserConfig; + + const existingJsonc = source?.jsoncConfigExists ? source.configSourceText : undefined; + const content = existingJsonc + ? updateJsoncDocument(existingJsonc, canonical) + : JSON.stringify(canonical, null, 2) + "\n"; + writeFileSync(filePath, content, { mode: 0o600 }); return filePath; } @@ -111,19 +225,78 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } +function migrateLegacyConfig(config: z.infer): DevspaceUserConfig { + const server = compactObject({ + host: config.host, + port: config.port, + allowedRoots: config.allowedRoots, + publicBaseUrl: config.publicBaseUrl, + allowedHosts: config.allowedHosts, + stateDir: config.stateDir, + worktreeRoot: config.worktreeRoot, + }); + const artifacts = compactObject({ + enabled: config.artifactsEnabled, + maxFileBytes: config.artifactMaxFileBytes, + }); + const skills = compactObject({ agentDir: config.agentDir }); + + return { + version: DEVSPACE_CONFIG_VERSION, + ...(Object.keys(server).length > 0 ? { server } : {}), + ...(Object.keys(artifacts).length > 0 ? { artifacts } : {}), + ...(Object.keys(skills).length > 0 ? { skills } : {}), + ...(config.subagents !== undefined + ? { subagents: resolveSubagentsConfig(config.subagents, {}) } + : {}), + }; +} + +function compactObject>(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ) as Partial; +} + +function readConfigDocument( + source: string, + filePath: string, + allowComments: boolean, +): Record { + if (!allowComments) return readJsonText(source, filePath); + + const errors: ParseError[] = []; + const parsed: unknown = parseJsonc(source, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + if (errors.length > 0) { + const reason = errors.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`).join(", "); + throw new Error(`Unable to read ${filePath}: ${reason}`); + } + return assertRecord(parsed, filePath); +} + function readJsonObject(filePath: string): Record { + return readJsonText(readFileSync(filePath, "utf8"), filePath); +} + +function readJsonText(source: string, filePath: string): Record { try { - const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8")); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("expected a JSON object"); - } - return parsed as Record; + return assertRecord(JSON.parse(source) as unknown, filePath); } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new Error(`Unable to read ${filePath}: ${reason}`); } } +function assertRecord(value: unknown, filePath: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Unable to read ${filePath}: expected a configuration object`); + } + return value as Record; +} + function parseDocument( schema: z.ZodType, document: Record, @@ -135,6 +308,46 @@ function parseDocument( throw new Error(`Invalid ${filePath}: ${z.prettifyError(result.error)}`); } +function updateJsoncDocument(source: string, config: DevspaceUserConfig): string { + let updated = source; + const formattingOptions = { insertSpaces: true, tabSize: 2, eol: "\n" }; + + for (const [path, value] of configEntries(config)) { + updated = applyEdits(updated, modify(updated, path, value, { formattingOptions })); + } + + return updated.endsWith("\n") ? updated : `${updated}\n`; +} + +function configEntries(config: DevspaceUserConfig): Array<[Array, unknown]> { + const entries: Array<[Array, unknown]> = [ + [["$schema"], config.$schema], + [["version"], config.version], + ]; + + const sectionKeys = { + server: ["host", "port", "allowedRoots", "publicBaseUrl", "allowedHosts", "stateDir", "worktreeRoot"], + harness: ["kind", "inspection"], + presentation: ["mode"], + skills: ["enabled", "paths", "agentDir"], + artifacts: ["enabled", "maxFileBytes"], + subagents: ["enabled", "providers"], + logging: ["level", "format", "requests", "assets", "toolCalls", "shellCommands", "trustProxy"], + oauth: ["accessTokenTtlSeconds", "refreshTokenTtlSeconds", "scopes", "allowedRedirectHosts"], + } as const; + + for (const [section, keys] of Object.entries(sectionKeys) as Array< + [keyof typeof sectionKeys, readonly string[]] + >) { + const value = config[section] as Record | undefined; + if (value === undefined) continue; + for (const key of keys) { + entries.push([[section, key], value?.[key]]); + } + } + return entries; +} + function writeJsonFile(filePath: string, value: unknown, mode: number): void { writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode }); } From 2b87315d9fd9e17a073ef04aa35b670bf01ca483 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:16:11 +0530 Subject: [PATCH 2/3] feat(config): publish v1 json schema --- package.json | 1 + schema/devspace-config.schema.json | 308 +++++++++++++++++++++++++++++ src/config-schema.test.ts | 9 + 3 files changed, 318 insertions(+) create mode 100644 schema/devspace-config.schema.json create mode 100644 src/config-schema.test.ts diff --git a/package.json b/package.json index 73ab3485..3d192489 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dist", "docs", "examples", + "schema", "scripts", "skills", "README.md" diff --git a/schema/devspace-config.schema.json b/schema/devspace-config.schema.json new file mode 100644 index 00000000..23342e06 --- /dev/null +++ b/schema/devspace-config.schema.json @@ -0,0 +1,308 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "description": "JSON Schema URL used by editors.", + "type": "string" + }, + "version": { + "type": "number", + "const": 1 + }, + "server": { + "type": "object", + "properties": { + "host": { + "description": "Local bind host. Defaults to 127.0.0.1.", + "type": "string" + }, + "port": { + "description": "Local MCP server port.", + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "allowedRoots": { + "description": "Project roots DevSpace may open.", + "type": "array", + "items": { + "type": "string" + } + }, + "publicBaseUrl": { + "description": "Public origin used by remote MCP hosts.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowedHosts": { + "description": "Optional HTTP Host allowlist.", + "type": "array", + "items": { + "type": "string" + } + }, + "stateDir": { + "description": "Directory containing persisted DevSpace state.", + "type": "string" + }, + "worktreeRoot": { + "description": "Directory for DevSpace-managed Git worktrees.", + "type": "string" + } + }, + "additionalProperties": false + }, + "harness": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "claude-code", + "description": "Expose the Claude Code-style coding harness." + }, + "inspection": { + "type": "string", + "enum": [ + "shell", + "dedicated" + ], + "description": "Use shell inspection or expose dedicated grep/glob/ls tools." + } + }, + "required": [ + "kind", + "inspection" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "codex", + "description": "Expose the Codex-style coding harness." + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + } + ] + }, + "presentation": { + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "off" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "inline" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "change-review" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + } + ] + }, + "skills": { + "type": "object", + "properties": { + "enabled": { + "description": "Whether skills are exposed to the host model.", + "type": "boolean" + }, + "paths": { + "description": "Additional skill directories.", + "type": "array", + "items": { + "type": "string" + } + }, + "agentDir": { + "description": "Compatibility agent directory. Defaults to ~/.codex.", + "type": "string" + } + }, + "additionalProperties": false + }, + "artifacts": { + "type": "object", + "properties": { + "enabled": { + "description": "Enable native MCP-host artifact download.", + "type": "boolean" + }, + "maxFileBytes": { + "description": "Maximum bytes accepted for one artifact.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "subagents": { + "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": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "silent", + "error", + "warn", + "info", + "debug" + ] + }, + "format": { + "type": "string", + "enum": [ + "json", + "pretty" + ] + }, + "requests": { + "type": "boolean" + }, + "assets": { + "type": "boolean" + }, + "toolCalls": { + "type": "boolean" + }, + "shellCommands": { + "type": "boolean" + }, + "trustProxy": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "oauth": { + "type": "object", + "properties": { + "accessTokenTtlSeconds": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "refreshTokenTtlSeconds": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "scopes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "allowedRedirectHosts": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "version" + ], + "additionalProperties": false, + "$id": "https://raw.githubusercontent.com/Waishnav/devspace/refs/tags/v1.1.0/schema/devspace-config.schema.json", + "title": "DevSpace configuration", + "description": "Versioned configuration for the DevSpace server and coding harness." +} diff --git a/src/config-schema.test.ts b/src/config-schema.test.ts new file mode 100644 index 00000000..da1a7552 --- /dev/null +++ b/src/config-schema.test.ts @@ -0,0 +1,9 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createDevspaceConfigJsonSchema } from "./user-config.js"; + +const checkedIn = JSON.parse( + readFileSync(new URL("../schema/devspace-config.schema.json", import.meta.url), "utf8"), +) as Record; + +assert.deepEqual(checkedIn, createDevspaceConfigJsonSchema()); From a8eb6be19721461cf5437f18ac038478b769a23d Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:16:11 +0530 Subject: [PATCH 3/3] test(config): cover jsonc migration and preservation --- package.json | 2 +- src/config.test.ts | 66 +++++++++++++++++++++++++ src/user-config.test.ts | 106 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/user-config.test.ts diff --git a/package.json b/package.json index 3d192489..c3209e95 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/runtime-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/ui/tool-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", + "test": "tsx src/config.test.ts && tsx src/user-config.test.ts && tsx src/config-schema.test.ts && tsx src/runtime-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/ui/tool-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" }, "keywords": [], diff --git a/src/config.test.ts b/src/config.test.ts index 58de89e2..81196677 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -221,3 +221,69 @@ assert.deepEqual(fileConfig.allowedHosts, [ "::1", "devspace.example.com", ]); + +const jsoncConfigDir = mkdtempSync(join(tmpdir(), "devspace-jsonc-load-config-test-")); +writeFileSync( + join(jsoncConfigDir, "config.jsonc"), + `{ + // Structured v1 configuration. + "version": 1, + "server": { + "port": 8989, + "allowedRoots": [${JSON.stringify(process.cwd())}], + "publicBaseUrl": "https://jsonc.devspace.example.com" + }, + "harness": { + "kind": "codex" + }, + "presentation": { + "mode": "change-review" + }, + "skills": { + "enabled": false, + "paths": ["~/.custom-skills"] + }, + "artifacts": { + "enabled": true, + "maxFileBytes": 456 + }, + "logging": { + "level": "debug", + "requests": false + }, + "oauth": { + "accessTokenTtlSeconds": 120, + "scopes": ["devspace", "admin"] + } + }`, +); +writeFileSync( + join(jsoncConfigDir, "auth.json"), + JSON.stringify({ ownerToken: "jsonc-owner-token-long-enough" }), +); + +const jsoncConfig = loadConfig({ DEVSPACE_CONFIG_DIR: jsoncConfigDir }); +assert.equal(jsoncConfig.port, 8989); +assert.equal(jsoncConfig.publicBaseUrl, "https://jsonc.devspace.example.com"); +assert.deepEqual(jsoncConfig.harness, { kind: "codex" }); +assert.deepEqual(jsoncConfig.presentation, { mode: "change-review" }); +assert.equal(jsoncConfig.skillsEnabled, false); +assert.deepEqual(jsoncConfig.skillPaths, ["~/.custom-skills"]); +assert.equal(jsoncConfig.artifactsEnabled, true); +assert.equal(jsoncConfig.artifactMaxFileBytes, 456); +assert.equal(jsoncConfig.logging.level, "debug"); +assert.equal(jsoncConfig.logging.requests, false); +assert.equal(jsoncConfig.oauth.accessTokenTtlSeconds, 120); +assert.deepEqual(jsoncConfig.oauth.scopes, ["devspace", "admin"]); + +const envOverride = loadConfig({ + DEVSPACE_CONFIG_DIR: jsoncConfigDir, + DEVSPACE_TOOL_MODE: "full", + DEVSPACE_WIDGETS: "off", + DEVSPACE_SKILLS: "1", + DEVSPACE_LOG_LEVEL: "warn", +}); +assert.deepEqual(envOverride.harness, { kind: "claude-code", inspection: "dedicated" }); +assert.deepEqual(envOverride.presentation, { mode: "off" }); +assert.equal(envOverride.skillsEnabled, true); +assert.equal(envOverride.logging.level, "warn"); diff --git a/src/user-config.test.ts b/src/user-config.test.ts new file mode 100644 index 00000000..19ea4d3e --- /dev/null +++ b/src/user-config.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DEVSPACE_CONFIG_SCHEMA_URL, + loadDevspaceFiles, + writeDevspaceConfig, +} from "./user-config.js"; + +const legacyRoot = mkdtempSync(join(tmpdir(), "devspace-legacy-config-test-")); +writeFileSync( + join(legacyRoot, "config.json"), + JSON.stringify({ + host: "127.0.0.1", + port: 8787, + publicBaseUrl: "https://legacy.example.com", + artifactsEnabled: true, + artifactMaxFileBytes: 123, + futureLegacyKey: { keep: true }, + }, null, 2), +); + +const legacyFiles = loadDevspaceFiles({ DEVSPACE_CONFIG_DIR: legacyRoot }); +assert.equal(legacyFiles.jsoncConfigExists, false); +assert.equal(legacyFiles.legacyConfigExists, true); +assert.deepEqual(legacyFiles.config.server, { + host: "127.0.0.1", + port: 8787, + publicBaseUrl: "https://legacy.example.com", +}); +assert.deepEqual(legacyFiles.config.artifacts, { + enabled: true, + maxFileBytes: 123, +}); + +writeDevspaceConfig({ + ...legacyFiles.config, + server: { + ...legacyFiles.config.server, + publicBaseUrl: "https://jsonc.example.com", + }, +}, { DEVSPACE_CONFIG_DIR: legacyRoot }, legacyFiles); + +const migratedPath = join(legacyRoot, "config.jsonc"); +assert.equal(existsSync(migratedPath), true); +assert.equal(existsSync(join(legacyRoot, "config.json")), true); +const migrated = readFileSync(migratedPath, "utf8"); +assert.match(migrated, /"\$schema": "https:\/\/raw\.githubusercontent\.com/); +assert.match(migrated, /"version": 1/); +assert.match(migrated, /"publicBaseUrl": "https:\/\/jsonc\.example\.com"/); + +const jsoncRoot = mkdtempSync(join(tmpdir(), "devspace-jsonc-config-test-")); +const jsoncPath = join(jsoncRoot, "config.jsonc"); +writeFileSync(jsoncPath, `{ + // Keep this top-level comment. + "$schema": "${DEVSPACE_CONFIG_SCHEMA_URL}", + "version": 1, + "server": { + // Keep this server comment. + "host": "127.0.0.1", + "futureSetting": true, + }, + "harness": { + "kind": "claude-code", + "inspection": "shell", + "futureHarnessSetting": true, + }, + "futureTopLevel": { + "keep": true, + }, +} +`); +writeFileSync(join(jsoncRoot, "config.json"), JSON.stringify({ port: 9999 })); + +const jsoncFiles = loadDevspaceFiles({ DEVSPACE_CONFIG_DIR: jsoncRoot }); +assert.equal(jsoncFiles.jsoncConfigExists, true); +assert.equal(jsoncFiles.legacyConfigExists, true); +assert.equal(jsoncFiles.config.server?.host, "127.0.0.1"); +assert.equal(jsoncFiles.config.server?.port, undefined); + +writeDevspaceConfig({ + ...jsoncFiles.config, + harness: { kind: "codex" }, + server: { + ...jsoncFiles.config.server, + publicBaseUrl: "https://preserved.example.com", + }, +}, { DEVSPACE_CONFIG_DIR: jsoncRoot }, jsoncFiles); + +const rewritten = readFileSync(jsoncPath, "utf8"); +assert.match(rewritten, /Keep this top-level comment/); +assert.match(rewritten, /Keep this server comment/); +assert.match(rewritten, /"futureSetting": true/); +assert.match(rewritten, /"futureHarnessSetting": true/); +assert.match(rewritten, /"futureTopLevel"/); +assert.doesNotMatch(rewritten, /"inspection":/); +assert.match(rewritten, /"publicBaseUrl": "https:\/\/preserved\.example\.com"/); +assert.deepEqual(loadDevspaceFiles({ DEVSPACE_CONFIG_DIR: jsoncRoot }).config.harness, { kind: "codex" }); + +const malformedRoot = mkdtempSync(join(tmpdir(), "devspace-malformed-jsonc-test-")); +writeFileSync(join(malformedRoot, "config.jsonc"), "{ version: 1,, }"); +assert.throws( + () => loadDevspaceFiles({ DEVSPACE_CONFIG_DIR: malformedRoot }), + /Unable to read .*config\.jsonc/, +);