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
+43 -21
View File
@@ -4,18 +4,19 @@
* -------
* Entry point for the `move` CLI.
*
* Thin shim that ties the three logic modules together:
* - `cli.ts` parses and validates `process.argv`.
* - `config.ts` holds the default tunables and the `resolveConfig` overlay.
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
* Thin shim that ties the four logic modules together:
* - `cli.ts` parses and validates `process.argv`.
* - `configFile.ts` loads and validates the JSON config file.
* - `config.ts` holds defaults and the layered `resolveConfig` overlay.
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
*
* Order of operations:
* 1. Parse CLI args. Bad input -> stderr message + short usage hint, exit 2.
* 2. `--help` / `--version` short-circuit before any mouse work happens.
* 3. Resolve CLI overrides on top of `DEFAULT_CONFIG` and hand the result
* (plus the verbose flag) to `runKeeper`.
* 4. Any unhandled rejection from `runKeeper` is logged and exits with
* code 1 so it's catchable by shells / supervisors.
* 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
* 2. `--help` / `--version` short-circuit before any I/O or mouse work.
* 3. Load + validate the config file (default XDG path, or `--config
* <path>` if supplied). Validation failures share the exit-2 path.
* 4. Resolve config (CLI > file > DEFAULT_CONFIG) and the verbose flag.
* 5. Run keeper. Any unhandled rejection exits 1.
*
* Runtime: Bun (uses `@nut-tree-fork/nut-js` via `keeper.ts`). The shebang
* above lets this file run as a real CLI once linked via `bun link`.
@@ -23,16 +24,26 @@
import { parseCliArgs, printHelp, VERSION } from "./cli.ts";
import type { ParsedCliArgs } from "./cli.ts";
import { resolveConfig } from "./config.ts";
import { resolveConfig, resolveVerbose } from "./config.ts";
import type { ConfigOverrides } from "./config.ts";
import { loadConfigFile } from "./configFile.ts";
import { runKeeper } from "./keeper.ts";
/**
* Print a CLI-style error and exit 2. Returns `never` so callers can use
* it without TypeScript flagging "variable might be undefined" downstream.
*/
function failUser(err: unknown): never {
const msg: string = err instanceof Error ? err.message : String(err);
process.stderr.write(`move: ${msg}\nTry 'move --help' for more information.\n`);
process.exit(2);
}
let cliArgs: ParsedCliArgs;
try {
cliArgs = parseCliArgs();
} catch (err: unknown) {
const msg: string = err instanceof Error ? err.message : String(err);
process.stderr.write(`move: ${msg}\nTry 'move --help' for more information.\n`);
process.exit(2);
failUser(err);
}
if (cliArgs.help) {
@@ -44,14 +55,25 @@ if (cliArgs.version) {
process.exit(0);
}
const config = resolveConfig({
moveInterval: cliArgs.moveInterval,
checkInterval: cliArgs.checkInterval,
stepDelay: cliArgs.stepDelay,
stepCount: cliArgs.stepCount,
});
let fileOverrides: ConfigOverrides | null;
try {
fileOverrides = loadConfigFile(cliArgs.config);
} catch (err: unknown) {
failUser(err);
}
runKeeper(config, cliArgs.verbose).catch((err: unknown): void => {
const cliOverrides: ConfigOverrides = {
moveInterval: cliArgs.moveInterval,
checkInterval: cliArgs.checkInterval,
stepDelay: cliArgs.stepDelay,
stepCount: cliArgs.stepCount,
verbose: cliArgs.verbose,
};
const config = resolveConfig(fileOverrides, cliOverrides);
const verbose: boolean = resolveVerbose(fileOverrides, cliOverrides);
runKeeper(config, verbose).catch((err: unknown): void => {
console.error("Error:", err);
process.exit(1);
});