/** * config.ts * --------- * Runtime configuration types, defaults, the layered resolver, and the * default config-file path. * * The keeper is driven by a single `Config` object that carries every * tunable it cares about, including the `verbose` flag. 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 numeric `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"; import { CliError } from "./errors.ts"; // Single source of truth for default values. The same file ships in the // install tree and is copied to $XDG_CONFIG_HOME/move/config.json on a // fresh install (only if no config exists there yet). Values use the CLI // units (seconds for time fields, ms for stepDelay, pixels for stepCount); // the seconds->ms conversion happens below where DEFAULT_CONFIG is built. import seedRaw from "../scripts/config.default.json" with { type: "json" }; /** * The shape of a resolved runtime configuration. `readonly` to make * accidental mutation a type error. * * - `moveInterval` — how long the mouse must be idle (no real movement) * before a synthetic sweep is triggered. Milliseconds. * - `checkInterval` — cadence of the idleness poll in the main loop. * Milliseconds. * - `stepDelay` — pause between individual synthetic mouse steps inside * a sweep. Also the window in which the user can * "interrupt" by moving the cursor. Milliseconds. * - `stepCount` — number of pixel-steps in a single sweep. Pixels. * - `verbose` — whether per-sweep / interrupt / bounds events are * logged. The startup banner is always printed. */ export interface Config { readonly moveInterval: number; readonly checkInterval: number; readonly stepDelay: number; readonly stepCount: number; readonly verbose: boolean; } /** * Shape of `scripts/config.default.json` after parsing. The cast below * trusts the file's structure; `assertSeedShape` performs a small runtime * sanity check at import time so a corrupted seed file fails loudly * instead of silently producing `NaN` or `undefined` defaults. */ interface SeedShape { moveInterval: number; // seconds checkInterval: number; // seconds stepDelay: number; // milliseconds stepCount: number; // pixels verbose: boolean; } function assertSeedShape(raw: unknown): asserts raw is SeedShape { if (typeof raw !== "object" || raw === null) { throw new Error("scripts/config.default.json: root must be an object"); } const r = raw as Record; for (const key of ["moveInterval", "checkInterval", "stepDelay", "stepCount"] as const) { const v = r[key]; if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) { throw new Error(`scripts/config.default.json: '${key}' must be a positive finite number (got ${JSON.stringify(v)})`); } } if (typeof r.verbose !== "boolean") { throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`); } } assertSeedShape(seedRaw); const seed: SeedShape = seedRaw; /** * Built-in defaults used when neither the CLI nor the config file supplies * a value for a given field. Derived from `scripts/config.default.json` * (the single source of truth); time-valued fields are converted from * seconds to milliseconds here so the rest of the codebase works in * internal units. */ export const DEFAULT_CONFIG: Config = { moveInterval: seed.moveInterval * 1000, checkInterval: seed.checkInterval * 1000, stepDelay: seed.stepDelay, stepCount: seed.stepCount, verbose: seed.verbose, }; /** * 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 `boolean | undefined` like the numeric fields, so all five * fields share the same "first defined value wins" precedence logic. * * For the CLI specifically, `verbose` is `undefined` when `-V/--verbose` * was not passed and `true` when it was. There is no CLI off-switch * today, so CLI `false` doesn't occur — a file-set `verbose: true` cannot * be overridden back to false from the command line (see the Configuration * section of the README). */ export interface ConfigOverrides { readonly moveInterval: number | undefined; readonly checkInterval: number | undefined; readonly stepDelay: number | undefined; readonly stepCount: number | undefined; readonly verbose: boolean | undefined; } /** * 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. * * Computed at call time (not at module load) so tests can override * `XDG_CONFIG_HOME` after import. */ 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; if (!home || home.length === 0) { // Neither var is set; we don't have a sensible fallback. Throwing // CliError lets the entry-point's normal handler surface this as // 'move: cannot resolve default config path: ...' + exit 2 instead // of silently producing '/.config/move/config.json' and bewildering // the user with a downstream 'file not found' message. throw new CliError( "cannot resolve default config path: neither $XDG_CONFIG_HOME nor $HOME is set", ); } 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 * numeric 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: T | undefined, fileVal: T | undefined, fallback: T, ): T => { if (cliVal !== undefined) return cliVal; if (fileVal !== undefined) return fileVal; return fallback; }; return { 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), verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose), }; }