/** * configFile.ts * ------------- * JSON config file loading + strict validation. * * Default path: ${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json * * Schema (all keys optional; matching CLI flag names and units): * * moveInterval number seconds, positive * checkInterval number seconds, positive * stepDelay number milliseconds, positive * pattern string a registered strategy name * verbose boolean * loop boolean * * Unknown keys, wrong types, and non-positive numerics are rejected with a * `CliError` so the entry point can exit 2 (user error) with a clear * message pointing at the offending file. The removed `stepCount` / * `stepSize` keys are the exception: they're tolerated (ignored with a * one-line notice) so an older seeded config keeps working after upgrade. * * Return semantics: * - `null` when no `explicitPath` was passed and the default path does * not exist. This is the "user has no config" happy path. * - A `ConfigOverrides` when a file was found and validated. * - Throws `CliError` if a problem is detected (missing explicit path, * bad JSON, wrong shape, unknown keys, invalid values). */ import { existsSync, readFileSync, statSync } from "node:fs"; import { defaultConfigPath, type ConfigOverrides } from "./config.ts"; import { CliError } from "./errors.ts"; import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts"; const ALLOWED_KEYS: ReadonlySet = new Set([ "moveInterval", "checkInterval", "stepDelay", "pattern", "verbose", "loop", ]); /** * Keys that used to be valid but have since been removed. They're tolerated * (not rejected like a genuine unknown key) so upgrading doesn't hard-fail a * config that was seeded with them — every pre-1.3.0 install has `stepCount` * in its file. They no longer do anything: sweep size and step count are now * properties of each movement pattern. A one-line notice points the user at * the file so they can remove them at leisure. */ const DEPRECATED_KEYS: ReadonlySet = new Set([ "stepCount", "stepSize", ]); function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function requirePositiveNumber(name: string, raw: unknown, path: string): number { if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { throw new CliError( `invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (expected a positive number)`, ); } return raw; } function requireBoolean(name: string, raw: unknown, path: string): boolean { if (typeof raw !== "boolean") { throw new CliError( `invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (expected a boolean)`, ); } return raw; } function requirePatternName(name: string, raw: unknown, path: string): string { const canonical: string | null = typeof raw === "string" ? resolvePatternName(raw) : null; if (canonical === null) { throw new CliError( `invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (valid: ${PATTERN_NAMES.join(", ")})`, ); } return canonical; } /** * Load and validate the config file. See module docstring for return * semantics. * * @param explicitPath - If provided (e.g., from `--config`), the file * must exist and validate. If `undefined`, fall * back to `defaultConfigPath()`; a missing default * file is silent (returns `null`). */ export function loadConfigFile(explicitPath: string | undefined): ConfigOverrides | null { const required: boolean = explicitPath !== undefined; const path: string = explicitPath ?? defaultConfigPath(); if (!existsSync(path)) { if (required) { throw new CliError(`config file not found: ${path}`); } return null; } if (!statSync(path).isFile()) { throw new CliError(`config path is not a regular file: ${path}`); } let raw: string; try { raw = readFileSync(path, "utf-8"); } catch (err: unknown) { const msg: string = err instanceof Error ? err.message : String(err); throw new CliError(`could not read config file ${path}: ${msg}`); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err: unknown) { const msg: string = err instanceof Error ? err.message : String(err); throw new CliError(`config file ${path} is not valid JSON: ${msg}`); } if (!isPlainObject(parsed)) { throw new CliError(`config file ${path} must contain a JSON object at the root`); } // Strict mode: reject any key we don't know about (catches typos like // 'movInterval'), but tolerate keys we've since removed — collect those // and warn once, rather than hard-failing a config seeded by an older // install. const deprecatedFound: string[] = []; for (const key of Object.keys(parsed)) { if (ALLOWED_KEYS.has(key)) continue; if (DEPRECATED_KEYS.has(key)) { deprecatedFound.push(key); continue; } const allowed: string = [...ALLOWED_KEYS].join(", "); throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`); } if (deprecatedFound.length > 0) { const names: string = deprecatedFound.map((k) => `'${k}'`).join(", "); process.stderr.write( `move: ignoring obsolete key(s) ${names} in ${path}\n` + ` (sweep size is now defined by each movement pattern)\n`, ); } return { moveInterval: "moveInterval" in parsed ? requirePositiveNumber("moveInterval", parsed.moveInterval, path) : undefined, checkInterval: "checkInterval" in parsed ? requirePositiveNumber("checkInterval", parsed.checkInterval, path) : undefined, stepDelay: "stepDelay" in parsed ? requirePositiveNumber("stepDelay", parsed.stepDelay, path) : undefined, pattern: "pattern" in parsed ? requirePatternName("pattern", parsed.pattern, path) : undefined, verbose: "verbose" in parsed ? requireBoolean("verbose", parsed.verbose, path) : undefined, loop: "loop" in parsed ? requireBoolean("loop", parsed.loop, path) : undefined, }; }