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
+29 -21
View File
@@ -9,6 +9,7 @@
*
* -h, --help Prints `printHelp()` to stdout; entry exits 0.
* -v, --version Prints `move <VERSION>` to stdout; entry exits 0.
* -C, --config <path> Override the default config-file path.
* -m, --move-interval Idle time (seconds) before a sweep fires.
* -c, --check-interval Cursor poll cadence (seconds).
* -d, --step-delay Pause between synthetic steps (ms).
@@ -16,7 +17,7 @@
* -V, --verbose Enable per-sweep / interrupt / bounds logging.
* (`-V` capital because `-v` is `--version`.)
*
* Numeric overrides are layered onto `DEFAULT_CONFIG` by
* Numeric overrides are layered (CLI > file > DEFAULT_CONFIG) by
* `resolveConfig` in `config.ts`; this module only parses and validates.
*/
@@ -25,23 +26,25 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { DEFAULT_CONFIG } from "./config.ts";
import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts";
/**
* Thrown when CLI input is invalid (unknown option, missing value, bad number).
* Distinct from runtime errors so the top-level entry can exit with code 2
* (user error) instead of code 1 (runtime failure).
* Thrown when CLI input is invalid (unknown option, missing value, bad
* number, malformed config file). Distinct from runtime errors so the
* entry point can exit with code 2 (user error) instead of code 1
* (runtime failure).
*/
export class CliError extends Error {}
/**
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user did
* not supply the flag; this lets `resolveConfig` cleanly distinguish "use the
* default" from "explicit override".
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user
* did not supply the flag; this lets `resolveConfig` cleanly distinguish
* "use the layer below" from "explicit override".
*/
export interface ParsedCliArgs {
help: boolean;
version: boolean;
config: string | undefined;
moveInterval: number | undefined; // seconds
checkInterval: number | undefined; // seconds
stepDelay: number | undefined; // milliseconds
@@ -50,10 +53,9 @@ export interface ParsedCliArgs {
}
/**
* Validate a CLI-supplied numeric value. Returns `undefined` if the user did
* not supply the flag at all; throws `CliError` on anything that isn't a
* positive finite number. Zero is rejected: every numeric tunable here is a
* duration or count where zero is meaningless or actively broken.
* Validate a CLI-supplied numeric value. Returns `undefined` if the user
* did not supply the flag at all; throws `CliError` on anything that isn't
* a positive finite number.
*/
function parsePositiveNumber(name: string, raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
@@ -66,11 +68,8 @@ function parsePositiveNumber(name: string, raw: string | undefined): number | un
/**
* Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
* `parseArgs` in strict mode so unknown flags and missing values surface as
* `CliError`s that the entry point can turn into exit code 2.
*
* Numeric flags are stored as `string` by `parseArgs` and then validated by
* `parsePositiveNumber`.
* `parseArgs` in strict mode so unknown flags and missing values surface
* as `CliError`s that the entry point can turn into exit code 2.
*/
export function parseCliArgs(): ParsedCliArgs {
let values: Record<string, string | boolean | undefined>;
@@ -80,6 +79,7 @@ export function parseCliArgs(): ParsedCliArgs {
options: {
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
config: { type: "string", short: "C" },
"move-interval": { type: "string", short: "m" },
"check-interval": { type: "string", short: "c" },
"step-delay": { type: "string", short: "d" },
@@ -100,6 +100,7 @@ export function parseCliArgs(): ParsedCliArgs {
return {
help: Boolean(values.help),
version: Boolean(values.version),
config: values.config as string | undefined,
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
@@ -111,8 +112,8 @@ export function parseCliArgs(): ParsedCliArgs {
/**
* Read the package version from `package.json` at runtime so help/version
* output stays in sync with the manifest without a build step. Resolved
* relative to this module's own location so the lookup works regardless of
* the caller's CWD.
* relative to this module's own location so the lookup works regardless
* of the caller's CWD.
*/
export const VERSION: string = (() => {
// This module lives in `src/`, so `package.json` is one directory up.
@@ -123,12 +124,14 @@ export const VERSION: string = (() => {
/**
* Write the usage block to stdout. Default values are pulled from
* `DEFAULT_CONFIG` (converted to the units the CLI exposes) so the help
* text never drifts from the actual defaults.
* `DEFAULT_CONFIG` (converted to the units the CLI exposes); the default
* config-file path is computed by `defaultConfigPath`. Both make the help
* text self-updating when their sources change.
*/
export function printHelp(): void {
const moveDefaultSec: number = DEFAULT_CONFIG.moveInterval / 1000;
const checkDefaultSec: number = DEFAULT_CONFIG.checkInterval / 1000;
const cfgPath: string = defaultConfigPath();
process.stdout.write(`Usage: move [options]
@@ -138,6 +141,8 @@ nudging the mouse cursor after a configurable idle period.
Options:
-h, --help Show this help and exit.
-v, --version Print version and exit.
-C, --config <path> Load defaults from a JSON config file.
Default path: ${cfgPath}
-m, --move-interval <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}.
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
@@ -145,9 +150,12 @@ Options:
-V, --verbose Log every sweep, interrupt, and bounds event
(default prints only the startup banner).
Precedence (highest wins): CLI flags > config file > built-in defaults.
Examples:
move
move --move-interval 180 --check-interval 5
move -m 300 -V
move --config ~/myprofile.json
`);
}