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
+68 -6
View File
@@ -95,17 +95,78 @@ out-of-bounds events.
Invalid input (unknown flag, missing value, non-positive number) prints an Invalid input (unknown flag, missing value, non-positive number) prints an
error to `stderr` and exits with code `2`. 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 <path>` 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 ## 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: modules:
- `src/move.ts` is a thin entry point: parses args, dispatches `--help` / - `src/move.ts` is a thin entry point: parses args, dispatches `--help` /
`--version`, resolves the runtime config, and calls `--version`, loads the config file, resolves the layered runtime
`runKeeper(config, verbose)`. config, and calls `runKeeper(config, verbose)`.
- `src/cli.ts` owns argument parsing, validation, and help/version output. - `src/cli.ts` owns argument parsing, validation, and help/version output.
- `src/config.ts` exports the `Config` type, `DEFAULT_CONFIG`, and the - `src/configFile.ts` owns optional JSON config-file loading + strict
`resolveConfig` overlay function. 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. - `src/keeper.ts` owns the synthetic-activity sweep and the idle-watch loop.
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`: 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`). | | `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/move.ts` | CLI entry point: parses args, dispatches help/version, starts the loop. |
| `src/cli.ts` | Argument parsing, validation, and help/version output. | | `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. | | `src/keeper.ts` | Synthetic-activity sweep and idle-watch loop. |
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. | | `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). | | `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
+29 -21
View File
@@ -9,6 +9,7 @@
* *
* -h, --help Prints `printHelp()` to stdout; entry exits 0. * -h, --help Prints `printHelp()` to stdout; entry exits 0.
* -v, --version Prints `move <VERSION>` 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. * -m, --move-interval Idle time (seconds) before a sweep fires.
* -c, --check-interval Cursor poll cadence (seconds). * -c, --check-interval Cursor poll cadence (seconds).
* -d, --step-delay Pause between synthetic steps (ms). * -d, --step-delay Pause between synthetic steps (ms).
@@ -16,7 +17,7 @@
* -V, --verbose Enable per-sweep / interrupt / bounds logging. * -V, --verbose Enable per-sweep / interrupt / bounds logging.
* (`-V` capital because `-v` is `--version`.) * (`-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. * `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 { fileURLToPath } from "node:url";
import { parseArgs } from "node:util"; 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). * Thrown when CLI input is invalid (unknown option, missing value, bad
* Distinct from runtime errors so the top-level entry can exit with code 2 * number, malformed config file). Distinct from runtime errors so the
* (user error) instead of code 1 (runtime failure). * entry point can exit with code 2 (user error) instead of code 1
* (runtime failure).
*/ */
export class CliError extends Error {} export class CliError extends Error {}
/** /**
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user did * Result of `parseCliArgs`. Numeric fields are `undefined` when the user
* not supply the flag; this lets `resolveConfig` cleanly distinguish "use the * did not supply the flag; this lets `resolveConfig` cleanly distinguish
* default" from "explicit override". * "use the layer below" from "explicit override".
*/ */
export interface ParsedCliArgs { export interface ParsedCliArgs {
help: boolean; help: boolean;
version: boolean; version: boolean;
config: string | undefined;
moveInterval: number | undefined; // seconds moveInterval: number | undefined; // seconds
checkInterval: number | undefined; // seconds checkInterval: number | undefined; // seconds
stepDelay: number | undefined; // milliseconds stepDelay: number | undefined; // milliseconds
@@ -50,10 +53,9 @@ export interface ParsedCliArgs {
} }
/** /**
* Validate a CLI-supplied numeric value. Returns `undefined` if the user did * Validate a CLI-supplied numeric value. Returns `undefined` if the user
* not supply the flag at all; throws `CliError` on anything that isn't a * did not supply the flag at all; throws `CliError` on anything that isn't
* positive finite number. Zero is rejected: every numeric tunable here is a * a positive finite number.
* duration or count where zero is meaningless or actively broken.
*/ */
function parsePositiveNumber(name: string, raw: string | undefined): number | undefined { function parsePositiveNumber(name: string, raw: string | undefined): number | undefined {
if (raw === undefined) return 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 * Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
* `parseArgs` in strict mode so unknown flags and missing values surface as * `parseArgs` in strict mode so unknown flags and missing values surface
* `CliError`s that the entry point can turn into exit code 2. * 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`.
*/ */
export function parseCliArgs(): ParsedCliArgs { export function parseCliArgs(): ParsedCliArgs {
let values: Record<string, string | boolean | undefined>; let values: Record<string, string | boolean | undefined>;
@@ -80,6 +79,7 @@ export function parseCliArgs(): ParsedCliArgs {
options: { options: {
help: { type: "boolean", short: "h" }, help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" }, version: { type: "boolean", short: "v" },
config: { type: "string", short: "C" },
"move-interval": { type: "string", short: "m" }, "move-interval": { type: "string", short: "m" },
"check-interval": { type: "string", short: "c" }, "check-interval": { type: "string", short: "c" },
"step-delay": { type: "string", short: "d" }, "step-delay": { type: "string", short: "d" },
@@ -100,6 +100,7 @@ export function parseCliArgs(): ParsedCliArgs {
return { return {
help: Boolean(values.help), help: Boolean(values.help),
version: Boolean(values.version), version: Boolean(values.version),
config: values.config as string | undefined,
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined), moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined), checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] 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 * Read the package version from `package.json` at runtime so help/version
* output stays in sync with the manifest without a build step. Resolved * 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 * relative to this module's own location so the lookup works regardless
* the caller's CWD. * of the caller's CWD.
*/ */
export const VERSION: string = (() => { export const VERSION: string = (() => {
// This module lives in `src/`, so `package.json` is one directory up. // 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 * Write the usage block to stdout. Default values are pulled from
* `DEFAULT_CONFIG` (converted to the units the CLI exposes) so the help * `DEFAULT_CONFIG` (converted to the units the CLI exposes); the default
* text never drifts from the actual defaults. * config-file path is computed by `defaultConfigPath`. Both make the help
* text self-updating when their sources change.
*/ */
export function printHelp(): void { export function printHelp(): void {
const moveDefaultSec: number = DEFAULT_CONFIG.moveInterval / 1000; const moveDefaultSec: number = DEFAULT_CONFIG.moveInterval / 1000;
const checkDefaultSec: number = DEFAULT_CONFIG.checkInterval / 1000; const checkDefaultSec: number = DEFAULT_CONFIG.checkInterval / 1000;
const cfgPath: string = defaultConfigPath();
process.stdout.write(`Usage: move [options] process.stdout.write(`Usage: move [options]
@@ -138,6 +141,8 @@ nudging the mouse cursor after a configurable idle period.
Options: Options:
-h, --help Show this help and exit. -h, --help Show this help and exit.
-v, --version Print version 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}. -m, --move-interval <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}.
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}. -c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}. -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 -V, --verbose Log every sweep, interrupt, and bounds event
(default prints only the startup banner). (default prints only the startup banner).
Precedence (highest wins): CLI flags > config file > built-in defaults.
Examples: Examples:
move move
move --move-interval 180 --check-interval 5 move --move-interval 180 --check-interval 5
move -m 300 -V move -m 300 -V
move --config ~/myprofile.json
`); `);
} }
+101 -28
View File
@@ -1,20 +1,26 @@
/** /**
* config.ts * 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 * The keeper is driven by a single `Config` object that carries the four
* tunables it cares about. Defaults live in `DEFAULT_CONFIG`; CLI overrides * tunables it cares about. Defaults live in `DEFAULT_CONFIG`; CLI and
* are layered on top by `resolveConfig` rather than mutating the defaults, * config-file overrides are layered on top by `resolveConfig` rather than
* so the defaults stay genuinely constant and the resolved config stays * mutating the defaults, so the defaults stay genuinely constant and the
* structurally typed. * resolved config stays structurally typed.
* *
* All fields are in their internal units (ms, pixels). The CLI exposes the * All `Config` fields are in their internal units (ms, pixels). The CLI and
* time-valued fields in seconds for ergonomics; `resolveConfig` performs * config file expose the time-valued fields in seconds for ergonomics;
* the seconds->ms conversion at the boundary so downstream code never has * `resolveConfig` performs the seconds->ms conversion at the boundary so
* to think about it. * 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 * The shape of a resolved runtime configuration. `readonly` to make
* accidental mutation a type error. * 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 * Built-in defaults used when neither the CLI nor the config file supplies
* override for the corresponding flag. * a value for a given field.
*/ */
export const DEFAULT_CONFIG: Config = { export const DEFAULT_CONFIG: Config = {
moveInterval: 4 * 60 * 1000, // 4 minutes in milliseconds 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 * Common shape for override layers (CLI args and config-file content).
* locally instead of importing from `cli.ts` to keep the dependency arrow *
* pointing one way (cli -> config), which lets `config.ts` stay a leaf * `undefined` means "this layer doesn't supply a value"; the next layer
* module with no internal imports. * 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 { export interface ConfigOverrides {
readonly moveInterval: number | undefined; // seconds (CLI units) readonly moveInterval: number | undefined;
readonly checkInterval: number | undefined; // seconds (CLI units) readonly checkInterval: number | undefined;
readonly stepDelay: number | undefined; // milliseconds readonly stepDelay: number | undefined;
readonly stepCount: number | undefined; // pixels readonly stepCount: number | undefined;
readonly verbose: boolean | undefined;
} }
/** /**
* Overlay user-supplied CLI values on top of `DEFAULT_CONFIG` and return a * Resolve the default config-file path per the XDG Base Directory Spec.
* resolved `Config`. Time-valued CLI inputs (move/check interval) are in * Honors `$XDG_CONFIG_HOME` if set; otherwise falls back to
* seconds; this is where they're converted to milliseconds for internal use. * `$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 { return {
moveInterval: overrides.moveInterval !== undefined ? overrides.moveInterval * 1000 : DEFAULT_CONFIG.moveInterval, moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval),
checkInterval: overrides.checkInterval !== undefined ? overrides.checkInterval * 1000 : DEFAULT_CONFIG.checkInterval, checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval),
stepDelay: overrides.stepDelay ?? DEFAULT_CONFIG.stepDelay, stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
stepCount: overrides.stepCount ?? DEFAULT_CONFIG.stepCount, 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;
}
+138
View File
@@ -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<string> = new Set<string>([
"moveInterval",
"checkInterval",
"stepDelay",
"stepCount",
"verbose",
]);
function isPlainObject(value: unknown): value is Record<string, unknown> {
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,
};
}
+43 -21
View File
@@ -4,18 +4,19 @@
* ------- * -------
* Entry point for the `move` CLI. * Entry point for the `move` CLI.
* *
* Thin shim that ties the three logic modules together: * Thin shim that ties the four logic modules together:
* - `cli.ts` parses and validates `process.argv`. * - `cli.ts` parses and validates `process.argv`.
* - `config.ts` holds the default tunables and the `resolveConfig` overlay. * - `configFile.ts` loads and validates the JSON config file.
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop. * - `config.ts` holds defaults and the layered `resolveConfig` overlay.
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
* *
* Order of operations: * Order of operations:
* 1. Parse CLI args. Bad input -> stderr message + short usage hint, exit 2. * 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
* 2. `--help` / `--version` short-circuit before any mouse work happens. * 2. `--help` / `--version` short-circuit before any I/O or mouse work.
* 3. Resolve CLI overrides on top of `DEFAULT_CONFIG` and hand the result * 3. Load + validate the config file (default XDG path, or `--config
* (plus the verbose flag) to `runKeeper`. * <path>` if supplied). Validation failures share the exit-2 path.
* 4. Any unhandled rejection from `runKeeper` is logged and exits with * 4. Resolve config (CLI > file > DEFAULT_CONFIG) and the verbose flag.
* code 1 so it's catchable by shells / supervisors. * 5. Run keeper. Any unhandled rejection exits 1.
* *
* Runtime: Bun (uses `@nut-tree-fork/nut-js` via `keeper.ts`). The shebang * 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`. * 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 { parseCliArgs, printHelp, VERSION } from "./cli.ts";
import type { ParsedCliArgs } 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"; 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; let cliArgs: ParsedCliArgs;
try { try {
cliArgs = parseCliArgs(); cliArgs = parseCliArgs();
} catch (err: unknown) { } catch (err: unknown) {
const msg: string = err instanceof Error ? err.message : String(err); failUser(err);
process.stderr.write(`move: ${msg}\nTry 'move --help' for more information.\n`);
process.exit(2);
} }
if (cliArgs.help) { if (cliArgs.help) {
@@ -44,14 +55,25 @@ if (cliArgs.version) {
process.exit(0); process.exit(0);
} }
const config = resolveConfig({ let fileOverrides: ConfigOverrides | null;
moveInterval: cliArgs.moveInterval, try {
checkInterval: cliArgs.checkInterval, fileOverrides = loadConfigFile(cliArgs.config);
stepDelay: cliArgs.stepDelay, } catch (err: unknown) {
stepCount: cliArgs.stepCount, 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); console.error("Error:", err);
process.exit(1); process.exit(1);
}); });