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:
+101
-28
@@ -1,20 +1,26 @@
|
||||
/**
|
||||
* config.ts
|
||||
* ---------
|
||||
* Runtime configuration types, defaults, and the CLI->config resolver.
|
||||
* Runtime configuration types, defaults, the layered resolver, and the
|
||||
* default config-file path.
|
||||
*
|
||||
* The keeper is driven by a single `Config` object that carries the four
|
||||
* tunables it cares about. Defaults live in `DEFAULT_CONFIG`; CLI overrides
|
||||
* are layered on top by `resolveConfig` rather than mutating the defaults,
|
||||
* so the defaults stay genuinely constant and the resolved config stays
|
||||
* structurally typed.
|
||||
* tunables it cares about. Defaults live in `DEFAULT_CONFIG`; CLI and
|
||||
* config-file overrides are layered on top by `resolveConfig` rather than
|
||||
* mutating the defaults, so the defaults stay genuinely constant and the
|
||||
* resolved config stays structurally typed.
|
||||
*
|
||||
* All fields are in their internal units (ms, pixels). The CLI exposes the
|
||||
* time-valued fields in seconds for ergonomics; `resolveConfig` performs
|
||||
* the seconds->ms conversion at the boundary so downstream code never has
|
||||
* to think about it.
|
||||
* All `Config` fields are in their internal units (ms, pixels). The CLI and
|
||||
* config file expose the time-valued fields in seconds for ergonomics;
|
||||
* `resolveConfig` performs the seconds->ms conversion at the boundary so
|
||||
* downstream code never has to think about it.
|
||||
*
|
||||
* Layering precedence (highest wins):
|
||||
* CLI overrides > file overrides > DEFAULT_CONFIG
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* The shape of a resolved runtime configuration. `readonly` to make
|
||||
* accidental mutation a type error.
|
||||
@@ -36,8 +42,8 @@ export interface Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in defaults used when the user did not supply an explicit CLI
|
||||
* override for the corresponding flag.
|
||||
* Built-in defaults used when neither the CLI nor the config file supplies
|
||||
* a value for a given field.
|
||||
*/
|
||||
export const DEFAULT_CONFIG: Config = {
|
||||
moveInterval: 4 * 60 * 1000, // 4 minutes in milliseconds
|
||||
@@ -47,30 +53,97 @@ export const DEFAULT_CONFIG: Config = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Subset of `ParsedCliArgs` that `resolveConfig` actually consumes. Declared
|
||||
* locally instead of importing from `cli.ts` to keep the dependency arrow
|
||||
* pointing one way (cli -> config), which lets `config.ts` stay a leaf
|
||||
* module with no internal imports.
|
||||
* Common shape for override layers (CLI args and config-file content).
|
||||
*
|
||||
* `undefined` means "this layer doesn't supply a value"; the next layer
|
||||
* down (file overrides, then DEFAULT_CONFIG) is consulted in that case.
|
||||
*
|
||||
* Numeric fields are in CLI / config-file units:
|
||||
* moveInterval, checkInterval — seconds
|
||||
* stepDelay — milliseconds
|
||||
* stepCount — pixels
|
||||
*
|
||||
* `verbose` is included so both layers can share a single shape, even
|
||||
* though `verbose` isn't part of `Config` itself (it's plumbed through to
|
||||
* `runKeeper` as a separate parameter).
|
||||
*/
|
||||
export interface ConfigOverrides {
|
||||
readonly moveInterval: number | undefined; // seconds (CLI units)
|
||||
readonly checkInterval: number | undefined; // seconds (CLI units)
|
||||
readonly stepDelay: number | undefined; // milliseconds
|
||||
readonly stepCount: number | undefined; // pixels
|
||||
readonly moveInterval: number | undefined;
|
||||
readonly checkInterval: number | undefined;
|
||||
readonly stepDelay: number | undefined;
|
||||
readonly stepCount: number | undefined;
|
||||
readonly verbose: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay user-supplied CLI values on top of `DEFAULT_CONFIG` and return a
|
||||
* resolved `Config`. Time-valued CLI inputs (move/check interval) are in
|
||||
* seconds; this is where they're converted to milliseconds for internal use.
|
||||
* Resolve the default config-file path per the XDG Base Directory Spec.
|
||||
* Honors `$XDG_CONFIG_HOME` if set; otherwise falls back to
|
||||
* `$HOME/.config`. The file itself is always `move/config.json` under
|
||||
* that base.
|
||||
*
|
||||
* Any field left `undefined` in the overrides falls back to the default.
|
||||
* Computed at call time (not at module load) so tests can override
|
||||
* `XDG_CONFIG_HOME` after import.
|
||||
*/
|
||||
export function resolveConfig(overrides: ConfigOverrides): Config {
|
||||
export function defaultConfigPath(): string {
|
||||
const xdg = process.env.XDG_CONFIG_HOME;
|
||||
if (xdg && xdg.length > 0) {
|
||||
return join(xdg, "move", "config.json");
|
||||
}
|
||||
const home = process.env.HOME ?? "";
|
||||
return join(home, ".config", "move", "config.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay file overrides (lowest priority) and CLI overrides (highest)
|
||||
* on top of `DEFAULT_CONFIG` and return a resolved `Config`. Time-valued
|
||||
* inputs are in seconds; this is where they're converted to milliseconds
|
||||
* for internal use.
|
||||
*
|
||||
* For each field, the first layer that supplies a defined value wins:
|
||||
* CLI -> file -> DEFAULT_CONFIG
|
||||
*/
|
||||
export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides): Config {
|
||||
const pickSeconds = (
|
||||
cliVal: number | undefined,
|
||||
fileVal: number | undefined,
|
||||
fallbackMs: number,
|
||||
): number => {
|
||||
if (cliVal !== undefined) return cliVal * 1000;
|
||||
if (fileVal !== undefined) return fileVal * 1000;
|
||||
return fallbackMs;
|
||||
};
|
||||
|
||||
const pickRaw = (
|
||||
cliVal: number | undefined,
|
||||
fileVal: number | undefined,
|
||||
fallback: number,
|
||||
): number => {
|
||||
if (cliVal !== undefined) return cliVal;
|
||||
if (fileVal !== undefined) return fileVal;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
moveInterval: overrides.moveInterval !== undefined ? overrides.moveInterval * 1000 : DEFAULT_CONFIG.moveInterval,
|
||||
checkInterval: overrides.checkInterval !== undefined ? overrides.checkInterval * 1000 : DEFAULT_CONFIG.checkInterval,
|
||||
stepDelay: overrides.stepDelay ?? DEFAULT_CONFIG.stepDelay,
|
||||
stepCount: overrides.stepCount ?? DEFAULT_CONFIG.stepCount,
|
||||
moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval),
|
||||
checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval),
|
||||
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
|
||||
stepCount: pickRaw(cli.stepCount, file?.stepCount, DEFAULT_CONFIG.stepCount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective `verbose` flag from the same layered overrides.
|
||||
*
|
||||
* `--verbose` on the CLI is a presence-only switch (no `--no-verbose`),
|
||||
* so this is strictly an "or-up" merge: CLI true wins outright, otherwise
|
||||
* the file's value (or false if absent) is used.
|
||||
*
|
||||
* Known limitation: a user with `"verbose": true` in their config file
|
||||
* cannot force quiet mode from the CLI. They must edit the file (or use
|
||||
* `--config` to point at a different one).
|
||||
*/
|
||||
export function resolveVerbose(file: ConfigOverrides | null, cli: ConfigOverrides): boolean {
|
||||
if (cli.verbose === true) return true;
|
||||
if (file?.verbose === true) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user