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:
2026-06-15 14:31:31 -05:00
commit 83d91c5630
12 changed files with 1886 additions and 0 deletions
+175
View File
@@ -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();
}
}
}