From 49f9cec6be01ef7e7604dadf127425eac6493820 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:47:49 +0200 Subject: [PATCH] Name config.json when the CLI cannot parse it (#5337) readPersistedConfig, used by paseo daemon config, status, pair, and set-password, parsed and validated config.json without the error wrapping loadPersistedConfig applies, so an invalid file surfaced as a bare SyntaxError or ZodError. Both readers now share one parse step that names the file and, for schema errors, the field. Closes #5328 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/persisted-config.test.ts | 28 +++++++++++ .../server/src/server/persisted-config.ts | 46 ++++++++++--------- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index cb98d050a4..63f18e2b5e 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -765,6 +765,34 @@ describe("config.json saved with a UTF-8 byte order mark", () => { }); }); +describe("readPersistedConfig with an unreadable config.json", () => { + test("names the file when it is not valid JSON", () => { + const home = createTempHome(); + const configPath = path.join(home, "config.json"); + try { + writeFileSync(configPath, '{"version":1,'); + + expect(() => readPersistedConfig(home)).toThrow(`[Config] Invalid JSON in ${configPath}: `); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("names the file and the field when it does not match the schema", () => { + const home = createTempHome(); + const configPath = path.join(home, "config.json"); + try { + writeFileSync(configPath, '{"daemon":{"listen":5}}'); + + expect(() => readPersistedConfig(home)).toThrow( + `[Config] Invalid config in ${configPath}:\n - daemon.listen: `, + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); + describe.skipIf(process.platform === "win32")("persisted config file permissions", () => { test("initializes config.json with private permissions", () => { const home = createTempHome(); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index ad78ee1b2c..40c4150907 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -436,6 +436,29 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per }); } + const config = parseConfigFile(configPath, raw); + log?.info(`Loaded from ${configPath}`); + return config; +} + +/** Observe the file without initializing a home, identity, or default configuration. */ +export function readPersistedConfig( + paseoHome: string, + options: { defaultsIfMissing?: boolean } = {}, +): PersistedConfig { + const configPath = getConfigPath(paseoHome); + let raw: string; + try { + raw = readFileSync(configPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {}; + throw error; + } + return parseConfigFile(configPath, raw); +} + +function parseConfigFile(configPath: string, raw: string): PersistedConfig { let parsed: unknown; try { parsed = parseConfigText(raw); @@ -446,37 +469,16 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per }); } - const migrated = stripRemovedConfigFields(parsed); - const result = PersistedConfigSchema.safeParse(migrated); + const result = PersistedConfigSchema.safeParse(stripRemovedConfigFields(parsed)); if (!result.success) { const issues = result.error.issues .map((i) => ` - ${i.path.join(".")}: ${i.message}`) .join("\n"); throw new Error(`[Config] Invalid config in ${configPath}:\n${issues}`); } - - log?.info(`Loaded from ${configPath}`); return result.data as PersistedConfig; } -/** Observe the file without initializing a home, identity, or default configuration. */ -export function readPersistedConfig( - paseoHome: string, - options: { defaultsIfMissing?: boolean } = {}, -): PersistedConfig { - let raw: string; - try { - raw = readFileSync(getConfigPath(paseoHome), "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") - return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {}; - throw error; - } - return PersistedConfigSchema.parse( - stripRemovedConfigFields(parseConfigText(raw)), - ) as PersistedConfig; -} - /** Editors such as Windows Notepad save UTF-8 with a byte order mark, which JSON.parse rejects. */ function parseConfigText(raw: string): unknown { return JSON.parse(raw.replace(/^\uFEFF/, ""));