diff --git a/src/cli.ts b/src/cli.ts index 7cf723f8..20e61649 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -230,7 +230,7 @@ async function runInit({ force }: { force: boolean }): Promise { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), }; - writeDevspaceConfig(config); + writeDevspaceConfig(config, process.env, files.configDocument); writeDevspaceAuth(auth); const lines = [ @@ -372,7 +372,7 @@ function runConfigCommand(args: string[]): void { writeDevspaceConfig({ ...files.config, publicBaseUrl: normalizeOptionalPublicBaseUrl(value), - }); + }, process.env, files.configDocument); console.log(`Updated ${files.configPath}`); } diff --git a/src/config.test.ts b/src/config.test.ts index 7b3eeeb6..9cbc261b 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -21,6 +21,10 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "f assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "codex"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "maybe" }), + /Invalid DEVSPACE_MINIMAL_TOOLS: maybe/, +); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); @@ -34,10 +38,18 @@ assert.equal( ); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "treu" }), + /Invalid DEVSPACE_SKILLS: treu/, +); assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).subagents, { enabled: true, providers: [], }); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "sometimes" }), + /Invalid DEVSPACE_SUBAGENTS: sometimes/, +); assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "invalid" }), /Invalid DEVSPACE_WIDGETS: invalid/, diff --git a/src/config.ts b/src/config.ts index 54a131c9..aa82a66e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -81,8 +81,13 @@ function normalizeAllowedHosts(rawHosts: string[], derivedHosts: string[]): stri 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 parseBoolean(value: string | undefined, name: string): boolean { + if (value === undefined) return false; + + const normalized = value.toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + throw new Error(`Invalid ${name}: ${value}`); } function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { @@ -91,7 +96,7 @@ function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { if (mode) throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${mode}`); if (env.DEVSPACE_MINIMAL_TOOLS !== undefined) { - return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS) ? "minimal" : "full"; + return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS, "DEVSPACE_MINIMAL_TOOLS") ? "minimal" : "full"; } return "minimal"; } @@ -148,11 +153,15 @@ 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), + requests: env.DEVSPACE_LOG_REQUESTS === undefined + ? true + : parseBoolean(env.DEVSPACE_LOG_REQUESTS, "DEVSPACE_LOG_REQUESTS"), + assets: parseBoolean(env.DEVSPACE_LOG_ASSETS, "DEVSPACE_LOG_ASSETS"), + toolCalls: env.DEVSPACE_LOG_TOOL_CALLS === undefined + ? 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"), }; } @@ -238,13 +247,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { artifactsEnabled: env.DEVSPACE_ARTIFACTS === undefined ? files.config.artifactsEnabled === true - : parseBoolean(env.DEVSPACE_ARTIFACTS), + : parseBoolean(env.DEVSPACE_ARTIFACTS, "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), + skillsEnabled: env.DEVSPACE_SKILLS === undefined + ? true + : parseBoolean(env.DEVSPACE_SKILLS, "DEVSPACE_SKILLS"), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 3f1de5aa..a0db8612 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -11,7 +11,7 @@ const providerSchema = z.object({ effort: z.string().trim().min(1).optional(), }).strict(); -const subagentsSchema = z.object({ +export const subagentsConfigSchema = z.object({ enabled: z.boolean(), providers: z.array(providerSchema), }).strict().superRefine((value, context) => { @@ -29,8 +29,13 @@ const subagentsSchema = z.object({ }); export type SubagentProviderConfig = z.infer; -export type SubagentsConfig = z.infer; -export type StoredSubagentsConfig = boolean | SubagentsConfig; +export const storedSubagentsConfigSchema = z.union([ + z.boolean(), + subagentsConfigSchema, +]); + +export type SubagentsConfig = z.infer; +export type StoredSubagentsConfig = z.infer; export function resolveSubagentsConfig( value: unknown, @@ -40,7 +45,7 @@ export function resolveSubagentsConfig( ? { enabled: false, providers: [] } : typeof value === "boolean" ? legacySubagentsConfig(value) - : subagentsSchema.parse(value); + : subagentsConfigSchema.parse(value); return { ...stored, enabled: env.DEVSPACE_SUBAGENTS === undefined @@ -73,5 +78,8 @@ function legacySubagentsConfig(enabled: boolean): SubagentsConfig { } function parseBoolean(value: string): boolean { - return ["1", "true", "yes", "on"].includes(value.toLowerCase()); + const normalized = value.toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + throw new Error(`Invalid DEVSPACE_SUBAGENTS: ${value}`); } diff --git a/src/user-config.ts b/src/user-config.ts index 98d05ac6..d8c89f16 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -7,26 +7,31 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; +import * as z from "zod/v4"; import { expandHomePath } from "./roots.js"; -import type { StoredSubagentsConfig } from "./local-agent-config.js"; - -export interface DevspaceUserConfig { - host?: string; - port?: number; - allowedRoots?: string[]; - publicBaseUrl?: string | null; - allowedHosts?: string[]; - stateDir?: string; - worktreeRoot?: string; - artifactsEnabled?: boolean; - artifactMaxFileBytes?: number; - agentDir?: string; - subagents?: StoredSubagentsConfig; -} +import { storedSubagentsConfigSchema } from "./local-agent-config.js"; -export interface DevspaceAuthConfig { - ownerToken?: string; -} +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(), +}); + +const devspaceAuthConfigSchema = z.object({ + ownerToken: z.string().optional(), +}); + +export type DevspaceUserConfig = z.infer; + +export type DevspaceAuthConfig = z.infer; export interface DevspaceFiles { dir: string; @@ -36,6 +41,7 @@ export interface DevspaceFiles { authExists: boolean; config: DevspaceUserConfig; auth: DevspaceAuthConfig; + configDocument: Record; } export function devspaceConfigDir(env: NodeJS.ProcessEnv = process.env): string { @@ -65,24 +71,29 @@ export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): Devspac const configExists = existsSync(configPath); const authExists = existsSync(authPath); + const configDocument = configExists ? readJsonObject(configPath) : {}; + const authDocument = authExists ? readJsonObject(authPath) : {}; + return { dir, configPath, authPath, configExists, authExists, - config: configExists ? readJsonFile(configPath) : {}, - auth: authExists ? readJsonFile(authPath) : {}, + config: parseDocument(devspaceUserConfigSchema, configDocument, configPath), + auth: parseDocument(devspaceAuthConfigSchema, authDocument, authPath), + configDocument, }; } export function writeDevspaceConfig( config: DevspaceUserConfig, env: NodeJS.ProcessEnv = process.env, + existingDocument: Record = {}, ): string { const filePath = devspaceConfigPath(env); mkdirSync(devspaceConfigDir(env), { recursive: true }); - writeJsonFile(filePath, config, 0o600); + writeJsonFile(filePath, { ...existingDocument, ...config }, 0o600); return filePath; } @@ -100,15 +111,30 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } -function readJsonFile(filePath: string): T { +function readJsonObject(filePath: string): Record { try { - return JSON.parse(readFileSync(filePath, "utf8")) as T; + 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; } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new Error(`Unable to read ${filePath}: ${reason}`); } } +function parseDocument( + schema: z.ZodType, + document: Record, + filePath: string, +): T { + const result = schema.safeParse(document); + if (result.success) return result.data; + + throw new Error(`Invalid ${filePath}: ${z.prettifyError(result.error)}`); +} + function writeJsonFile(filePath: string, value: unknown, mode: number): void { writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode }); }