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>
This commit is contained in:
paseo-bot[bot]
2026-09-24 10:47:49 +02:00
committed by GitHub
parent e07da55f81
commit 49f9cec6be
2 changed files with 52 additions and 22 deletions
@@ -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();
+24 -22
View File
@@ -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/, ""));