/** * cli.ts * ------ * Command-line argument parsing, validation, and help/version output for * the `move` CLI. Pure module: no side effects on import beyond reading * `package.json` once to populate `VERSION`. * * Flag table: * * -h, --help Prints `printHelp()` to stdout; entry exits 0. * -v, --version Prints `move ` to stdout; entry exits 0. * -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). * -n, --step-count Steps per sweep (pixels). * -V, --verbose Enable per-sweep / interrupt / bounds logging. * (`-V` capital because `-v` is `--version`.) * * Numeric overrides are layered onto `DEFAULT_CONFIG` by * `resolveConfig` in `config.ts`; this module only parses and validates. */ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { DEFAULT_CONFIG } 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). */ 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". */ export interface ParsedCliArgs { help: boolean; version: boolean; moveInterval: number | undefined; // seconds checkInterval: number | undefined; // seconds stepDelay: number | undefined; // milliseconds stepCount: number | undefined; // pixels verbose: boolean; } /** * 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. */ function parsePositiveNumber(name: string, raw: string | undefined): number | undefined { if (raw === undefined) return undefined; const n: number = Number(raw); if (!Number.isFinite(n) || n <= 0) { throw new CliError(`invalid value for --${name}: '${raw}' (expected a positive number)`); } return n; } /** * 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`. */ export function parseCliArgs(): ParsedCliArgs { let values: Record; try { const result = parseArgs({ args: process.argv.slice(2), options: { help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" }, "move-interval": { type: "string", short: "m" }, "check-interval": { type: "string", short: "c" }, "step-delay": { type: "string", short: "d" }, "step-count": { type: "string", short: "n" }, verbose: { type: "boolean", short: "V" }, }, strict: true, allowPositionals: false, }); values = result.values as Record; } catch (err: unknown) { // node:util throws TypeError for unknown options / missing values; // surface its message verbatim so the user sees exactly what was wrong. const msg: string = err instanceof Error ? err.message : String(err); throw new CliError(msg); } return { help: Boolean(values.help), version: Boolean(values.version), 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), stepCount: parsePositiveNumber("step-count", values["step-count"] as string | undefined), verbose: Boolean(values.verbose), }; } /** * 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. */ export const VERSION: string = (() => { // This module lives in `src/`, so `package.json` is one directory up. const here: string = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")) as { version: string }; return pkg.version; })(); /** * 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. */ export function printHelp(): void { const moveDefaultSec: number = DEFAULT_CONFIG.moveInterval / 1000; const checkDefaultSec: number = DEFAULT_CONFIG.checkInterval / 1000; process.stdout.write(`Usage: move [options] Keeps presence-tracking apps (e.g. Microsoft Teams) from going Away by nudging the mouse cursor after a configurable idle period. Options: -h, --help Show this help and exit. -v, --version Print version and exit. -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}. -n, --step-count Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}. -V, --verbose Log every sweep, interrupt, and bounds event (default prints only the startup banner). Examples: move move --move-interval 180 --check-interval 5 move -m 300 -V `); }