Files
Move/src/config.ts
T
nokeo08 940113019d Move verbose into Config; drop resolveVerbose
Verbose was awkwardly a separate runKeeper argument with a separate
resolveVerbose helper, even though it's just another tunable on the
same precedence ladder as the numeric fields. This commit collapses it.

Changes:

- Config gains 'readonly verbose: boolean'. DEFAULT_CONFIG sets it to
  false.
- resolveConfig now returns the full Config including verbose. Verbose
  layers with the same 'first defined value wins' precedence as the
  numeric fields.
- resolveVerbose is gone.
- ParsedCliArgs.verbose becomes boolean | undefined: undefined when -V
  was not passed, true when it was. parseCliArgs maps accordingly.
  This lets the layered resolver treat verbose uniformly.
- runKeeper takes a single Config arg. The internal logger reads from
  config.verbose at the top of runKeeper.
- move.ts no longer plumbs verbose separately; the single resolved
  Config drives everything.
- README How-it-works and Files-table entries updated to match.

Behavior verified for all five verbose-precedence cases (no file/no
flag, no file/-V, file:true/no flag, file:false/-V, file:false/no
flag) and config-error scenarios remain intact.
2026-06-17 12:45:46 -05:00

143 lines
5.6 KiB
TypeScript

/**
* 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";
/**
* 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;
}
/**
* 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
checkInterval: 10 * 1000, // Check every 10 seconds
stepDelay: 50, // ms between mouse steps
stepCount: 250, // pixels to move per sweep
verbose: false, // quiet by default; startup banner still prints
};
/**
* 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 ?? "";
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 = <T>(
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),
};
}