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
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* config.ts
|
||||
* ---------
|
||||
* Runtime configuration types, defaults, and the CLI->config resolver.
|
||||
*
|
||||
* The keeper is driven by a single `Config` object that carries the four
|
||||
* tunables it cares about. Defaults live in `DEFAULT_CONFIG`; CLI overrides
|
||||
* are layered on top by `resolveConfig` rather than mutating the defaults,
|
||||
* so the defaults stay genuinely constant and the resolved config stays
|
||||
* structurally typed.
|
||||
*
|
||||
* All fields are in their internal units (ms, pixels). The CLI exposes the
|
||||
* time-valued fields in seconds for ergonomics; `resolveConfig` performs
|
||||
* the seconds->ms conversion at the boundary so downstream code never has
|
||||
* to think about it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The shape of a resolved runtime configuration. `readonly` to make
|
||||
* accidental mutation a type error.
|
||||
*
|
||||
* - `moveInterval` — how long the mouse must be idle (no real movement)
|
||||
* before a synthetic sweep is triggered. Milliseconds.
|
||||
* - `checkInterval` — cadence of the idleness poll in the main loop.
|
||||
* Milliseconds.
|
||||
* - `stepDelay` — pause between individual synthetic mouse steps inside
|
||||
* a sweep. Also the window in which the user can
|
||||
* "interrupt" by moving the cursor. Milliseconds.
|
||||
* - `stepCount` — number of pixel-steps in a single sweep. Pixels.
|
||||
*/
|
||||
export interface Config {
|
||||
readonly moveInterval: number;
|
||||
readonly checkInterval: number;
|
||||
readonly stepDelay: number;
|
||||
readonly stepCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in defaults used when the user did not supply an explicit CLI
|
||||
* override for the corresponding flag.
|
||||
*/
|
||||
export const DEFAULT_CONFIG: Config = {
|
||||
moveInterval: 4 * 60 * 1000, // 4 minutes in milliseconds
|
||||
checkInterval: 10 * 1000, // Check every 10 seconds
|
||||
stepDelay: 50, // ms between mouse steps
|
||||
stepCount: 250, // pixels to move per sweep
|
||||
};
|
||||
|
||||
/**
|
||||
* Subset of `ParsedCliArgs` that `resolveConfig` actually consumes. Declared
|
||||
* locally instead of importing from `cli.ts` to keep the dependency arrow
|
||||
* pointing one way (cli -> config), which lets `config.ts` stay a leaf
|
||||
* module with no internal imports.
|
||||
*/
|
||||
export interface ConfigOverrides {
|
||||
readonly moveInterval: number | undefined; // seconds (CLI units)
|
||||
readonly checkInterval: number | undefined; // seconds (CLI units)
|
||||
readonly stepDelay: number | undefined; // milliseconds
|
||||
readonly stepCount: number | undefined; // pixels
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay user-supplied CLI values on top of `DEFAULT_CONFIG` and return a
|
||||
* resolved `Config`. Time-valued CLI inputs (move/check interval) are in
|
||||
* seconds; this is where they're converted to milliseconds for internal use.
|
||||
*
|
||||
* Any field left `undefined` in the overrides falls back to the default.
|
||||
*/
|
||||
export function resolveConfig(overrides: ConfigOverrides): Config {
|
||||
return {
|
||||
moveInterval: overrides.moveInterval !== undefined ? overrides.moveInterval * 1000 : DEFAULT_CONFIG.moveInterval,
|
||||
checkInterval: overrides.checkInterval !== undefined ? overrides.checkInterval * 1000 : DEFAULT_CONFIG.checkInterval,
|
||||
stepDelay: overrides.stepDelay ?? DEFAULT_CONFIG.stepDelay,
|
||||
stepCount: overrides.stepCount ?? DEFAULT_CONFIG.stepCount,
|
||||
};
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* keeper.ts
|
||||
* ---------
|
||||
* The actual "Teams Status Keeper" behavior: synthetic mouse activity with
|
||||
* real-user-wins semantics, plus the idle-watch loop that drives it.
|
||||
*
|
||||
* Runtime: Bun (uses `@nut-tree-fork/nut-js` for cross-platform mouse +
|
||||
* screen). Importing this module sets `mouse.config.autoDelayMs = 0` as a
|
||||
* side effect — see below.
|
||||
*
|
||||
* Logging policy:
|
||||
* - The startup banner in `runKeeper` is unconditional so the user always
|
||||
* sees confirmation that the process is alive.
|
||||
* - Every per-sweep / interrupt / bounds log is gated by `verbose` so the
|
||||
* default is quiet. Errors stay on `console.error` (unconditional, raised
|
||||
* by the entry point on unhandled rejection).
|
||||
*/
|
||||
|
||||
import { mouse, Point, screen } from "@nut-tree-fork/nut-js";
|
||||
|
||||
import type { Config } from "./config.ts";
|
||||
|
||||
/**
|
||||
* nut.js inserts a configurable delay after every action (default 100ms).
|
||||
* That default would silently more-than-double the duration of every
|
||||
* `setPosition` and `getPosition` call. We drive cadence ourselves via
|
||||
* `Config.stepDelay`, so disable nut.js's implicit delay entirely.
|
||||
*/
|
||||
mouse.config.autoDelayMs = 0;
|
||||
|
||||
/**
|
||||
* Promise-based `setTimeout` wrapper. Allows `await sleep(ms)` ergonomics.
|
||||
*
|
||||
* @param ms - Duration to wait, in milliseconds.
|
||||
*/
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise<void>((resolve: () => void): void => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
/**
|
||||
* Format the current local time as `HH:MM:SS` (24-hour, zero-padded).
|
||||
* Used for human-readable log lines. Date is intentionally omitted.
|
||||
*/
|
||||
const timestamp = (): string => {
|
||||
const d: Date = new Date();
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a verbose-gated logger. `info` is unconditional; `event` only fires
|
||||
* when the caller asked for verbose output. Returning a small object keeps
|
||||
* `simulateActivity` free of `if (verbose)` noise at every log site.
|
||||
*/
|
||||
function makeLogger(verbose: boolean): { info(msg: string): void; event(msg: string): void } {
|
||||
return {
|
||||
info: (msg: string): void => {
|
||||
console.log(msg);
|
||||
},
|
||||
event: (msg: string): void => {
|
||||
if (verbose) console.log(msg);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single synthetic mouse-activity sweep.
|
||||
*
|
||||
* Behavior:
|
||||
* 1. Snapshot the starting cursor position.
|
||||
* 2. Read current screen dimensions (re-read every call so monitor changes
|
||||
* are handled correctly).
|
||||
* 3. Pick a horizontal direction (`dx`) that keeps the sweep on-screen:
|
||||
* move right if there's room, otherwise move left. Vertical movement is
|
||||
* currently disabled (`dy = 0`) but the framework is in place for
|
||||
* richer patterns later.
|
||||
* 4. For each of `config.stepCount` steps:
|
||||
* - Compute the next target position.
|
||||
* - Defensive bounds check (belt-and-braces given the `dx` choice).
|
||||
* - Command nut.js to move the cursor there.
|
||||
* - Sleep `config.stepDelay` — also the user's interrupt window.
|
||||
* - Re-read the cursor. If it isn't where we put it, the user
|
||||
* touched the mouse: log (verbose) and return early, leaving the
|
||||
* cursor wherever the user moved it.
|
||||
* 5. On a clean full sweep, restore the cursor to the starting position
|
||||
* so the next idle-check sees "no movement" and doesn't misread the
|
||||
* synthetic activity as the user returning.
|
||||
*/
|
||||
async function simulateActivity(config: Config, log: ReturnType<typeof makeLogger>): Promise<void> {
|
||||
const start: Point = await mouse.getPosition();
|
||||
const screenWidth: number = await screen.width();
|
||||
const screenHeight: number = await screen.height();
|
||||
const dx: number = start.x + config.stepCount < screenWidth ? 1 : -1;
|
||||
const dy: number = 0;
|
||||
|
||||
log.event(`Simulating activity at ${timestamp()}...`);
|
||||
|
||||
for (let i: number = 1; i <= config.stepCount; i++) {
|
||||
const expected: Point = new Point(start.x + i * dx, start.y + i * dy);
|
||||
|
||||
if (expected.x < 0 || expected.x >= screenWidth || expected.y < 0 || expected.y >= screenHeight) {
|
||||
// Safety net for future non-linear movement patterns. With the
|
||||
// current straight-line sweep + `dx` selection above, this branch
|
||||
// should never fire.
|
||||
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await mouse.setPosition(expected);
|
||||
await sleep(config.stepDelay);
|
||||
|
||||
const current: Point = await mouse.getPosition();
|
||||
if (current.x !== expected.x || current.y !== expected.y) {
|
||||
// Cursor isn't where we put it -> real user activity. Abort
|
||||
// without snapping back, so we don't yank the cursor out from
|
||||
// under the user.
|
||||
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await mouse.setPosition(start);
|
||||
log.event("Mouse moved.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Main idle-watch loop. Runs forever; exits only on `Ctrl+C` (SIGINT) or
|
||||
* an unhandled rejection caught by the entry point.
|
||||
*
|
||||
* Algorithm:
|
||||
* - Track the last known cursor position (`lastPos`) and the timestamp of
|
||||
* the last observed real user movement (`lastActivity`).
|
||||
* - Every `config.checkInterval`:
|
||||
* * If the cursor moved since the last check, that's real user
|
||||
* activity: reset `lastActivity` and `lastPos`, skip the rest.
|
||||
* * Otherwise, if it's been at least `config.moveInterval` since the
|
||||
* last real activity, fire a synthetic sweep, then reset the
|
||||
* idleness clock so we wait another full `moveInterval` before
|
||||
* firing again.
|
||||
*
|
||||
* `simulateActivity` is designed so that its own synthetic movement never
|
||||
* counts as real activity: on a clean sweep it restores the cursor (so the
|
||||
* next position check matches), and on a user-interrupted sweep the next
|
||||
* iteration sees the user's new position and correctly resets the clock.
|
||||
*/
|
||||
export async function runKeeper(config: Config, verbose: boolean): Promise<void> {
|
||||
const log = makeLogger(verbose);
|
||||
log.info("Teams Status Keeper started. Press Ctrl+C to stop.");
|
||||
|
||||
let lastPos: Point = await mouse.getPosition();
|
||||
let lastActivity: number = Date.now();
|
||||
|
||||
while (true) {
|
||||
await sleep(config.checkInterval);
|
||||
const pos: Point = await mouse.getPosition();
|
||||
const now: number = Date.now();
|
||||
|
||||
if (pos.x !== lastPos.x || pos.y !== lastPos.y) {
|
||||
// Real user activity since the last check; reset the idleness clock.
|
||||
lastActivity = now;
|
||||
lastPos = pos;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (now - lastActivity >= config.moveInterval) {
|
||||
await simulateActivity(config, log);
|
||||
// `simulateActivity` either returns the cursor to its start
|
||||
// (clean sweep) or leaves it where the user moved it (interrupt).
|
||||
// Either way we reset the clock and require another full
|
||||
// moveInterval of inactivity before firing again.
|
||||
lastActivity = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* move.ts
|
||||
* -------
|
||||
* Entry point for the `move` CLI.
|
||||
*
|
||||
* Thin shim that ties the three logic modules together:
|
||||
* - `cli.ts` parses and validates `process.argv`.
|
||||
* - `config.ts` holds the default tunables and the `resolveConfig` overlay.
|
||||
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
|
||||
*
|
||||
* Order of operations:
|
||||
* 1. Parse CLI args. Bad input -> stderr message + short usage hint, exit 2.
|
||||
* 2. `--help` / `--version` short-circuit before any mouse work happens.
|
||||
* 3. Resolve CLI overrides on top of `DEFAULT_CONFIG` and hand the result
|
||||
* (plus the verbose flag) to `runKeeper`.
|
||||
* 4. Any unhandled rejection from `runKeeper` is logged and exits with
|
||||
* code 1 so it's catchable by shells / supervisors.
|
||||
*
|
||||
* 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`.
|
||||
*/
|
||||
|
||||
import { parseCliArgs, printHelp, VERSION } from "./cli.ts";
|
||||
import type { ParsedCliArgs } from "./cli.ts";
|
||||
import { resolveConfig } from "./config.ts";
|
||||
import { runKeeper } from "./keeper.ts";
|
||||
|
||||
let cliArgs: ParsedCliArgs;
|
||||
try {
|
||||
cliArgs = parseCliArgs();
|
||||
} catch (err: unknown) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (cliArgs.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
if (cliArgs.version) {
|
||||
process.stdout.write(`move ${VERSION}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const config = resolveConfig({
|
||||
moveInterval: cliArgs.moveInterval,
|
||||
checkInterval: cliArgs.checkInterval,
|
||||
stepDelay: cliArgs.stepDelay,
|
||||
stepCount: cliArgs.stepCount,
|
||||
});
|
||||
|
||||
runKeeper(config, cliArgs.verbose).catch((err: unknown): void => {
|
||||
console.error("Error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user