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:
2026-06-17 12:27:50 -05:00
parent c9645b374c
commit df9305c6ac
5 changed files with 379 additions and 76 deletions
+101 -28
View File
@@ -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;
}