diff --git a/README.md b/README.md index a940768..806c62d 100644 --- a/README.md +++ b/README.md @@ -95,17 +95,78 @@ out-of-bounds events. Invalid input (unknown flag, missing value, non-positive number) prints an error to `stderr` and exits with code `2`. +## Configuration + +`move` reads an optional JSON config file at: + +``` +${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json +``` + +If the file is missing, defaults are used (no config file is required). +Pass `-C` / `--config ` to point at a different file; in that mode +the file must exist. + +### Precedence + +``` +CLI flags > config file > built-in defaults +``` + +CLI flags always win. The config file fills in any flag the user didn't +pass on the command line. Built-in defaults fill in anything the file +doesn't set. + +### Example + +```jsonc +{ + "moveInterval": 240, + "checkInterval": 10, + "stepDelay": 50, + "stepCount": 250, + "verbose": false +} +``` + +All keys are optional; supply only the ones you want to override. Keys +and units mirror the CLI flags exactly: `moveInterval` and +`checkInterval` are seconds, `stepDelay` is milliseconds, `stepCount` is +pixels, `verbose` is a boolean. + +### Validation + +The loader is strict: + +- Root must be a JSON object. +- Unknown keys are rejected (catches typos like `"movInterval"`). +- Numeric values must be finite and strictly positive. +- `verbose` must be a boolean. + +Any validation failure prints a message naming the file and the offending +key to `stderr` and exits `2`. + +### Known limitation: `verbose` can be turned on but not off from the CLI + +`--verbose` is a presence-only flag (there is no `--no-verbose`). If the +config file sets `"verbose": true`, the CLI cannot force quiet mode in +that invocation. Workarounds: edit the file, or point at a different +file with `--config`. + ## How it works -The source lives under `src/`, split into an entry point plus three logic +The source lives under `src/`, split into an entry point plus four logic modules: - `src/move.ts` is a thin entry point: parses args, dispatches `--help` / - `--version`, resolves the runtime config, and calls - `runKeeper(config, verbose)`. + `--version`, loads the config file, resolves the layered runtime + config, and calls `runKeeper(config, verbose)`. - `src/cli.ts` owns argument parsing, validation, and help/version output. -- `src/config.ts` exports the `Config` type, `DEFAULT_CONFIG`, and the - `resolveConfig` overlay function. +- `src/configFile.ts` owns optional JSON config-file loading + strict + schema validation. +- `src/config.ts` exports the `Config` type, `DEFAULT_CONFIG`, + `defaultConfigPath`, and the layered `resolveConfig` / `resolveVerbose` + overlay functions. - `src/keeper.ts` owns the synthetic-activity sweep and the idle-watch loop. Defaults live in `src/config.ts` as `DEFAULT_CONFIG`: @@ -190,7 +251,8 @@ move --help | `scripts/dev-setup.sh` | Contributor bootstrap (verify Bun + `bun install`). | | `src/move.ts` | CLI entry point: parses args, dispatches help/version, starts the loop. | | `src/cli.ts` | Argument parsing, validation, and help/version output. | -| `src/config.ts` | `Config` type, `DEFAULT_CONFIG`, and `resolveConfig` overlay. | +| `src/config.ts` | `Config` type, `DEFAULT_CONFIG`, `defaultConfigPath`, and the layered `resolveConfig` / `resolveVerbose` overlay. | +| `src/configFile.ts` | Optional JSON config-file loader with strict schema validation. | | `src/keeper.ts` | Synthetic-activity sweep and idle-watch loop. | | `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. | | `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). | diff --git a/src/cli.ts b/src/cli.ts index 750c28f..f45c002 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ * * -h, --help Prints `printHelp()` to stdout; entry exits 0. * -v, --version Prints `move ` to stdout; entry exits 0. + * -C, --config 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; @@ -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 Load defaults from a JSON config file. + Default path: ${cfgPath} -m, --move-interval Idle time before a sweep fires. Default: ${moveDefaultSec}. -c, --check-interval Cursor poll cadence. Default: ${checkDefaultSec}. -d, --step-delay 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 `); } diff --git a/src/config.ts b/src/config.ts index 8907aa2..218c0df 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; +} diff --git a/src/configFile.ts b/src/configFile.ts new file mode 100644 index 0000000..cf06be4 --- /dev/null +++ b/src/configFile.ts @@ -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 = new Set([ + "moveInterval", + "checkInterval", + "stepDelay", + "stepCount", + "verbose", +]); + +function isPlainObject(value: unknown): value is Record { + 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, + }; +} diff --git a/src/move.ts b/src/move.ts index bfe3b53..bde3f4e 100755 --- a/src/move.ts +++ b/src/move.ts @@ -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 + * ` 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); });