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:
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+37
-15
@@ -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.
|
||||||
|
* - `config.ts` holds defaults and the layered `resolveConfig` overlay.
|
||||||
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
|
* - `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;
|
||||||
|
try {
|
||||||
|
fileOverrides = loadConfigFile(cliArgs.config);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
failUser(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliOverrides: ConfigOverrides = {
|
||||||
moveInterval: cliArgs.moveInterval,
|
moveInterval: cliArgs.moveInterval,
|
||||||
checkInterval: cliArgs.checkInterval,
|
checkInterval: cliArgs.checkInterval,
|
||||||
stepDelay: cliArgs.stepDelay,
|
stepDelay: cliArgs.stepDelay,
|
||||||
stepCount: cliArgs.stepCount,
|
stepCount: cliArgs.stepCount,
|
||||||
});
|
verbose: cliArgs.verbose,
|
||||||
|
};
|
||||||
|
|
||||||
runKeeper(config, cliArgs.verbose).catch((err: unknown): void => {
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user