Files
Move/src/cli.ts
T
nokeo08 83d91c5630 Initial commit: move CLI (milestone 2)
- src/move.ts entry point with CLI parsing, --help, --version
- src/cli.ts: parseCliArgs, printHelp, ParsedCliArgs, CliError, VERSION
- src/config.ts: Config type, DEFAULT_CONFIG, resolveConfig
- src/keeper.ts: synthetic-activity sweep + idle-watch loop
- package.json bin entry + shebang for 'bun link' global install
- install.sh: contributor bootstrap (will be repurposed; see
  DISTRIBUTION-PLAN.md for the end-user installer design)
- DISTRIBUTION-PLAN.md captures the tabled end-user distribution work
2026-06-15 14:31:31 -05:00

154 lines
6.4 KiB
TypeScript

/**
* 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 <VERSION>` 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<string, string | boolean | undefined>;
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<string, string | boolean | undefined>;
} 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 <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}.
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
-n, --step-count <pixels> 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
`);
}