/** * 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. * -e, --edit Open the active config file in `$EDITOR`. * Refuses if the file doesn't exist; refuses if * `$EDITOR` is unset. * -C, --config Override the default config-file path. * -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). * -p, --pattern Movement strategy name (see strategies.ts). * -V, --verbose Enable per-sweep / interrupt / bounds logging. * (`-V` capital because `-v` is `--version`.) * * Numeric overrides are layered (CLI > file > 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, defaultConfigPath } from "./config.ts"; import { CliError } from "./errors.ts"; import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts"; /** * Result of `parseCliArgs`. Numeric fields are `undefined` when the user * did not supply the flag; this lets `resolveConfig` cleanly distinguish * "use the layer below" from "explicit override". */ export interface ParsedCliArgs { help: boolean; version: boolean; edit: boolean; config: string | undefined; moveInterval: number | undefined; // seconds checkInterval: number | undefined; // seconds stepDelay: number | undefined; // milliseconds /** Movement strategy name, validated against the registry. */ pattern: string | undefined; /** * `true` when `-V`/`--verbose` was passed; `undefined` when it was not. * `undefined` (not `false`) lets the layered resolver distinguish "user * did not specify" from a hypothetical "user explicitly turned off", * even though the CLI has no off-switch today. */ verbose: boolean | undefined; } /** * 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. */ 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; } /** * Validate a CLI-supplied movement-pattern name. Returns `undefined` when * the flag was not supplied; throws `CliError` naming the valid patterns * when the value isn't a registered strategy. */ function parsePatternName(raw: string | undefined): string | undefined { if (raw === undefined) return undefined; const canonical: string | null = resolvePatternName(raw); if (canonical === null) { throw new CliError(`invalid value for --pattern: '${raw}' (valid: ${PATTERN_NAMES.join(", ")})`); } return canonical; } /** * 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. */ 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" }, edit: { type: "boolean", short: "e" }, config: { type: "string", short: "C" }, "move-interval": { type: "string", short: "m" }, "check-interval": { type: "string", short: "c" }, "step-delay": { type: "string", short: "d" }, pattern: { type: "string", short: "p" }, 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), edit: Boolean(values.edit), config: values.config as string | undefined, 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), pattern: parsePatternName(values.pattern as string | undefined), verbose: values.verbose === true ? true : undefined, }; } /** * 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); the default * config-file path is computed by `defaultConfigPath`. Both make the help * text self-updating when their sources change. */ export function printHelp(): void { const moveDefaultSec: number = DEFAULT_CONFIG.moveInterval / 1000; const checkDefaultSec: number = DEFAULT_CONFIG.checkInterval / 1000; const cfgPath: string = defaultConfigPath(); 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. -e, --edit Open the config file in $EDITOR and exit. -C, --config Load defaults from a JSON config file. Default path: ${cfgPath} -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}. -p, --pattern Movement strategy. Default: ${DEFAULT_CONFIG.pattern}. One of: ${PATTERN_NAMES.join(", ")}. Each pattern defines its own size and speed. -V, --verbose Log every sweep, interrupt, and bounds event (default prints only the startup banner). Precedence (highest wins): CLI flags > config file > built-in defaults. Examples: move move --move-interval 180 --check-interval 5 move -m 300 -V move --pattern arc move --config ~/myprofile.json `); }