Turn the hardcoded straight-line sweep into a strategy system behind three
seams so new patterns are easy to add and, for the first time, testable
without nut.js or a real screen:
- src/device.ts: injectable Device seam over nut.js (autoDelayMs lives
here now); the only module that touches the native lib.
- src/strategies.ts: pure per-pattern path generators + registry + lenient
name resolution. Ships line, diagonal, jitter, walk,
arc, figureEight.
- src/executor.ts: single executePath driver owning bounds policy
(abort/clamp/reflect), pacing, interrupt detection, and
restore-on-clean.
keeper.ts's simulateActivity now selects a strategy and delegates to the
executor; the default `line` pattern is byte-for-byte the previous behavior.
New config surface, layered CLI > file > default with strict validation:
- -p/--pattern <name> movement strategy (names matched case/-/_-insensitive)
- -s/--step-size <px> pixels per step; stepCount is now a step *count*
Robustness for the new edge-seeking patterns: interrupt detection compares
against the last commanded (rounded) point with a 2px tolerance, and
clamp/reflect stay a couple pixels off the screen edge, so sub-pixel cursor
placement on scaled/multi-monitor displays isn't misread as user activity.
jitter's radius scales with sweep length so it moves at the default stepSize.
Tests: new suites for strategies, the executor (all bounds policies,
rounding, interrupt, tolerance, pacing), and the keeper loop; config and
configFile suites extended for pattern/stepSize. editor.test.ts moved to
tests/ for consistency. 64 pass.
196 lines
8.7 KiB
TypeScript
196 lines
8.7 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.
|
|
* -e, --edit Open the active config file in `$EDITOR`.
|
|
* Refuses if the file doesn't exist; refuses if
|
|
* `$EDITOR` is unset.
|
|
* -C, --config <path> 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).
|
|
* -n, --step-count Steps per sweep (count).
|
|
* -s, --step-size Pixels moved per step.
|
|
* -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
|
|
stepCount: number | undefined; // count
|
|
stepSize: number | undefined; // pixels
|
|
/** 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<string, string | boolean | undefined>;
|
|
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" },
|
|
"step-count": { type: "string", short: "n" },
|
|
"step-size": { type: "string", short: "s" },
|
|
pattern: { type: "string", short: "p" },
|
|
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),
|
|
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),
|
|
stepCount: parsePositiveNumber("step-count", values["step-count"] as string | undefined),
|
|
stepSize: parsePositiveNumber("step-size", values["step-size"] 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 <path> Load defaults from a JSON config file.
|
|
Default path: ${cfgPath}
|
|
-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 <count> Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}.
|
|
-s, --step-size <pixels> Pixels moved per step. Default: ${DEFAULT_CONFIG.stepSize}.
|
|
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
|
|
One of: ${PATTERN_NAMES.join(", ")}.
|
|
-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 --step-size 3
|
|
move --config ~/myprofile.json
|
|
`);
|
|
}
|