Add JSON config file support (XDG-respecting)
End users can now set defaults in a config file at:
${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json
CLI flags continue to win when both are set:
CLI flags > config file > built-in defaults
Schema mirrors the CLI flag names and units. Loader is strict: unknown
keys, wrong types, and non-positive numerics are rejected with a clear
message naming the file and key, and the process exits 2.
Changes:
- New src/configFile.ts: existence-aware loader + strict schema
validation. Throws CliError; the entry point converts those to exit 2.
- src/config.ts: ConfigOverrides gains 'verbose'; resolveConfig takes
both file and CLI override layers; new defaultConfigPath() honors
XDG_CONFIG_HOME; new resolveVerbose() layers verbose with the
presence-only-CLI semantics documented.
- src/cli.ts: -C/--config <path> flag. printHelp prints the default
config path and the precedence rule.
- src/move.ts: loads the config file (default XDG path or --config),
passes both override layers into resolveConfig, resolves verbose
separately, exits 2 on any validation failure.
- README: new Configuration section with path, precedence, example,
validation rules, and the verbose CLI-can't-turn-off limitation.
Files table gains src/configFile.ts.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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
|
||||
* stepCount number pixels, positive
|
||||
* verbose 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.
|
||||
*
|
||||
* 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 { CliError } from "./cli.ts";
|
||||
import { defaultConfigPath, type ConfigOverrides } from "./config.ts";
|
||||
|
||||
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
|
||||
"moveInterval",
|
||||
"checkInterval",
|
||||
"stepDelay",
|
||||
"stepCount",
|
||||
"verbose",
|
||||
]);
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' that would otherwise sail through silently.
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (!ALLOWED_KEYS.has(key)) {
|
||||
const allowed: string = [...ALLOWED_KEYS].join(", ");
|
||||
throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
stepCount:
|
||||
"stepCount" in parsed
|
||||
? requirePositiveNumber("stepCount", parsed.stepCount, path)
|
||||
: undefined,
|
||||
verbose:
|
||||
"verbose" in parsed
|
||||
? requireBoolean("verbose", parsed.verbose, path)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user