33 Commits
Author SHA1 Message Date
nokeo08 b019f25a42 Release 1.4.0 2026-08-17 16:15:44 -05:00
nokeo08 c8942bb380 Collapse bounds policies to reflect-only; drop abort and clamp
The executor kept every commanded point on-screen via a per-strategy
BoundsPolicy of abort / clamp / reflect. Measured against the real
strategies, the other two earned nothing: abort truncated a sweep at the
first edge (line on a narrow screen ran only 90 of 250 steps), and clamp
could park the cursor against an edge (a monotonic ramp stalled 162 steps
in a row) -- both counter to the program's whole purpose of keeping the
cursor moving. reflect bounces off the edge and keeps going, and is
already what line/diagonal need in loop mode. arc's declared clamp was
provably dead code (it clamps its own endpoint, so no sample ever leaves
the screen).

Collapse to reflect-only:
- strategies.ts: remove the BoundsPolicy type and the `bounds` field from
  the interface and all six strategies. Keep the local clamp() helper --
  it's arc's endpoint geometry, not an on-screen policy; docstring says so.
- executor.ts: resolveTarget loses its policy parameter and its null
  return and just reflects both axes; delete clampInt; SweepOutcome drops
  "aborted"; ExecuteOptions drops `bounds`; remove the Out of bounds log.
- keeper.ts: loopOpts is now { restore: false, loop: true } -- the
  reflect override added with loop mode is redundant.
- tests: drop the abort-outcome, clamp, and bounds-override tests; simplify
  fixed() to take no policy; add a regression test that a monotonic ramp
  past an edge never yields two identical points in a row (the guarantee
  that motivated removing clamp).

Behavior is unchanged for every pattern at normal cursor positions
(verified: line's normal sweep is byte-identical). The only differences
are at a screen edge, where motion now bounces instead of stopping. No
config keys, flags, or pattern names changed.

Docs updated to match, including in-code comments, the README strategies
table (Bounds column removed) and verbose description, the sequence
diagram (resolveTarget signature + getPosition/width ordering + a loop-mode
note), and a CHANGELOG Changed entry.
2026-08-17 15:53:49 -05:00
nokeo08 7e632b3e9d Add loop mode (--loop): repeat movement until user activity
Introduce a continuous "loop" setting so a triggered sweep keeps the
cursor moving until the user moves the mouse (or Ctrl+C), instead of
firing a single sweep.

- strategies.ts: add optional `loopPath` to MovementStrategy; give `line`
  and `diagonal` infinite loop generators that pick a direction once and
  ramp forever (4px/step). Their finite `path` and declared `bounds` are
  unchanged, so single-sweep behavior is identical.
- executor.ts: add ExecuteOptions { restore?, bounds?, loop? }. Omitting
  options reproduces the original single-sweep contract exactly.
- keeper.ts: in loop mode, run an infinite loopPath once (stopped only by
  interruption) or chain a finite path cycle after cycle; force `reflect`
  bounds for every pattern and suppress the between-cycle restore, so
  line/diagonal bounce edge-to-edge instead of stopping at the first edge.
- config plumbing: new boolean `loop` through config.default.json,
  config.ts, configFile.ts, cli.ts (-l/--loop), and move.ts, mirroring
  the existing `verbose` precedence.
- docs: README loop-mode section + usage/validation updates; CHANGELOG
  Unreleased entry.
- tests: loopPath generators, executor options (bounds override, loop
  selection, restore suppression), config/configFile loop plumbing, and
  keeper-level loop behavior (ramps far vs. bounded single-sweep, chained
  cycles). 79 pass.
2026-08-17 14:36:18 -05:00
nokeo08 1ad724cd33 Release 1.3.3
install.sh now installs the newest published tag by default instead of
tracking the master branch, so 'curl ... | sh' installs a real release and
reports its version. The tag is resolved from the Gitea tags API, falling
back to master if the lookup fails. MOVE_VERSION still pins an explicit ref.
2026-08-17 10:58:26 -05:00
nokeo08 b9669269bc Release 1.3.2
Interactive installer: prompt before replacing an existing install or
overwriting an existing config, reading answers from /dev/tty so it works
under 'curl ... | sh'. Falls back to the prior non-interactive contract
when no terminal is available. MOVE_FORCE=1 skips all prompts; new
MOVE_RESEED_CONFIG=1 reseeds the config unattended (old file kept as .bak).

Fix: install.sh no longer wipes a working install before downloading. The
tree is built in a staging dir and swapped into place only once complete,
so a failed download/extract/build leaves the existing install intact.
2026-08-17 09:28:13 -05:00
nokeo08 68e64eeaef Release 1.3.1 2026-08-14 15:09:59 -05:00
nokeo08 d0528b4a92 docs: fix mermaid parse error in happy-path diagram
Mermaid treats ';' as a statement separator, so the label
'lastPos (== start; re-sync)' terminated mid-line and the parser then
expected an arrow (the 'Parse error on line 59' the preview showed).
Removed the semicolon and, defensively, replaced 'Iterable<Point>' with
plain text so the angle brackets aren't interpreted as an HTML tag in the
rendered label.
2026-08-14 13:50:21 -05:00
nokeo08 c002a6d902 Fix stale comments to match current code
Audited comments project-wide against the current implementation:

- scripts/install.sh, scripts/uninstall.sh: header curl URLs pointed at a
  root-level install.sh/uninstall.sh, but the files live under scripts/ —
  the documented command 404'd. Corrected to the scripts/ path (matching
  the README and the actual file location).
- src/move.ts: header said it ties "four logic modules" and omitted
  editor.ts; the --edit terminal action was also missing from the order of
  operations. Both corrected.
- src/config.ts: numeric Config fields are all milliseconds now; dropped the
  stale "pixels" unit left over from stepCount/stepSize.

Comments-only (plus two script header lines); tsc clean, 64 tests pass.
2026-08-14 13:39:15 -05:00
nokeo08 41903ebaf1 docs: add execution happy-path sequence diagram 2026-08-14 13:28:43 -05:00
nokeo08 819cc5a5fb Set 1.3.0 release date 2026-08-14 13:05:33 -05:00
nokeo08 ec33648e74 Remove stepCount/stepSize; patterns own their geometry
The stepCount and stepSize knobs were two controls for one quantity users
actually care about (reach), and the number of steps is an implementation
detail nobody meaningfully tunes. Each pattern has a natural size and
resolution — a jitter is inherently small, an arc a broad curve — so those
now live as constants in each strategy rather than as global config.

- strategies.ts: each pattern defines its own step count and size; MoveContext
  drops `config` down to pure geometry (start/width/height/rng), and the
  module no longer imports Config at all (dissolving the type-only-import
  cycle workaround). line stays byte-for-byte: 250 one-pixel steps.
- executor.ts: executePath takes `config` for pacing (stepDelay); the path
  itself needs nothing from it.
- config.ts / cli.ts / move.ts / config.default.json: drop stepCount and
  stepSize from the type, seed, validation, resolver, CLI flags (-n, -s),
  and help. stepDelay stays as the one pacing lever.
- configFile.ts: tolerate the removed keys instead of rejecting them — every
  pre-1.3.0 install seeded stepCount, so a hard "unknown key" failure on
  upgrade is avoided. They're ignored with a one-line stderr notice; genuine
  unknown keys still error.

The -n/--step-count CLI flag (shipped since 1.0.0) is now an unknown option;
config files degrade gracefully, command lines don't. Stays in the unpushed
1.3.0 release. 64 tests pass.
2026-08-14 12:56:22 -05:00
nokeo08 db3310c247 Add pluggable movement strategies (v1.3.0)
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.
2026-08-13 15:36:22 -05:00
nokeo08 7777b16540 Add CHANGELOG.md 2026-06-29 12:27:16 -05:00
nokeo08 cff1c482a3 v1.2.0
Notable changes since v1.1.1:
- New -e/--edit flag opens the active config file in $EDITOR.
- Lazy import of keeper.ts so --help and --version skip the nut.js
  native load on cold start.
- Various audit cleanups: failRuntime() mirror, named Logger interface,
  errors.ts module, autoDelayMs moved out of module-load side effects,
  defaultConfigPath guards against unset HOME, noUncheckedIndexedAccess
  enabled in tsconfig.
- Added Bun test suite (34 tests across resolveConfig, loadConfigFile,
  editConfig).
- scripts/dev-setup.sh made POSIX-portable.
2026-06-17 22:20:59 -05:00
nokeo08 10dcc1791a Add -e/--edit flag: open config file in $EDITOR
New module src/editor.ts handles the flag end-to-end:

  - editorCommand(editor, path) returns the sh -c argv that lets the
    shell tokenize multi-word $EDITOR values like 'code --wait'.
    Extracted so editor.test.ts can verify construction without
    actually launching an editor.
  - editConfig(path) checks $EDITOR is set, checks the target file
    exists, spawns 'sh -c <editor> "$@" -- <path>' with stdio
    inherited, and exits with the editor's status code.

Refuses (CliError -> exit 2) when:
  - $EDITOR is unset or empty.
  - The target config file doesn't exist. (Same recovery hint as
    elsewhere: 'run move once or reinstall'.)

src/cli.ts adds the flag to the parser and printHelp(). src/move.ts
dispatches it after --version and before config-load. Resolution
mirrors the loader: --config <path> wins, else defaultConfigPath().

8 new tests in src/editor.test.ts cover the pure helper and both
refusal paths; the spawn success path is verified via manual
'EDITOR=true move -e' (would otherwise kill the test process).

README Usage block, Configuration section (new Editing subsection),
and Files table all updated to match.
2026-06-17 22:20:27 -05:00
nokeo08 cc7a487aca Lazy-import keeper.ts so --help and --version skip nut.js load
keeper.ts statically imports @nut-tree-fork/nut-js, which dlopens a
sizeable native .node binary. That load dominated cold-start: ~1.2s
for 'move --version' immediately after install, vs ~125ms warm.
For a flag that just prints a string, that's all overhead.

Change: dynamic 'await import("./keeper.ts")' placed after the
--help and --version short-circuits. The dynamic import is wrapped
in try/catch so import-time failures (e.g., missing native binary,
unsupported architecture) route through the same failRuntime() path
as anything thrown by the loop.

Expected cold start for --help and --version drops to ~50-150ms
(Bun + reading a handful of small files, no native module load).
move with no flags pays the same load cost as today.

tsc --noEmit and the test suite remain clean.
2026-06-17 21:57:42 -05:00
nokeo08 10172f11b0 Bump package.json to 1.1.1 to match next release tag
The CLI reads its version string from package.json at runtime, so
'move --version' was printing 1.0.0 even after the v1.1.0 tag landed.
Bumping the manifest here so v1.1.1 (carrying the tests/ move and the
TS LSP fix) ships with matching output:

  $ move --version
  move 1.1.1

Discipline going forward: bump package.json BEFORE creating a tag.
2026-06-17 21:45:00 -05:00
nokeo08 add4a2d60c tsconfig: explicit 'types': ['bun'] for VS Code LSP
bun:test types live in 'bun-types', which is pulled in transitively by
@types/bun via a /// <reference types="bun-types" /> directive. bunx tsc
finds this via auto-discovery, but VS Code's TS Language Server doesn't
always honor auto-discovered @types/* packages under moduleResolution:
bundler, so it shows a phantom 'Cannot find module bun:test' error in
the editor.

Adding 'types': ['bun'] to compilerOptions makes the inclusion explicit
and matches what 'bun init' generates for new projects. tsc still
passes; the bun test suite still passes; the editor LSP now resolves
bun:test correctly.
2026-06-17 21:15:41 -05:00
nokeo08 fec35a2333 Move tests out of src/ into a top-level tests/ directory
src/ now contains only source code; tests live alongside it under
tests/. Bun's test runner discovers '**/*.test.ts' so no test-runner
config change is needed.

Changes:
  - tests/config.test.ts        (was src/config.test.ts)
  - tests/configFile.test.ts    (was src/configFile.test.ts)
  - Imports updated: ./config.ts -> ../src/config.ts (likewise for
    configFile.ts and errors.ts).
  - tsconfig.json include adds 'tests/**/*.ts' so tsc type-checks
    the test files too.

All 25 tests still pass; tsc clean.
2026-06-17 21:05:20 -05:00
nokeo08 9617758d8b Enable noUncheckedIndexedAccess in tsconfig
Tighter type-checking: array indexing and dynamic property access now
return T | undefined rather than T. Catches bugs where code assumes a
key exists without checking.

No code changes were needed — the existing modules already either:
  - cast through Record<string, unknown> (configFile.ts), where access
    is already 'unknown';
  - cast values from parseArgs to '... | undefined' (cli.ts), already
    nullable; or
  - use static, statically-known fields (Config, ConfigOverrides,
    DEFAULT_CONFIG).

tsc --noEmit remains clean. The full test suite still passes.
2026-06-17 16:35:33 -05:00
nokeo08 94d963d8ef Make scripts/dev-setup.sh POSIX-portable
The contributor bootstrap script was bash-only ('#!/usr/bin/env bash',
'set -euo pipefail', '[[ ... == ... ]]') while install.sh and
uninstall.sh are POSIX sh. Consistency lines them up:

  - shebang -> '#!/usr/bin/env sh'
  - 'set -euo pipefail' -> 'set -eu' (pipefail isn't POSIX; not needed
    here either, no risky pipelines in this script)
  - '[[ ... == ... ]]' -> '[ ... = ... ]'

Also added a 'bun run test' hint to the post-install workflow note now
that there's a test suite to run.
2026-06-17 16:35:04 -05:00
nokeo08 1a857de5ed Add Bun tests for resolveConfig and loadConfigFile
Two new test files exercise the layered config resolver and the JSON
config-file loader:

  src/config.test.ts        25 cases:
    - resolveConfig precedence (CLI > file > default) per field
    - seconds-to-ms conversion at the resolver boundary
    - verbose precedence across all four cells
    - defaultConfigPath: XDG honored, HOME fallback, empty-as-unset,
      throws CliError when both are missing

  src/configFile.test.ts    11 cases:
    - valid file -> overrides, with undefined for unspecified keys
    - missing explicit path throws
    - malformed JSON / non-object root throws with file path
    - unknown key / wrong type / non-positive number all throw with
      the offending key named
    - default path missing -> null (silent default)

Runs via 'bun test' (or 'bun run test', now wired in package.json).
All 25 tests pass on the current codebase.
2026-06-17 16:34:27 -05:00
nokeo08 5af253283e Introduce failRuntime() to mirror failUser() in move.ts
The two stderr-fatal paths used to be asymmetric: failUser() is a named
helper with 'move:' prefix and a 'Try --help' hint, while the runKeeper
catch was an inline 'console.error("Error:", err)' + process.exit(1).

failRuntime() now sits next to failUser(), so the entry-point reads as
'one of two well-named fatal paths.' It also prefers err.stack when
available, giving better diagnostics for nut.js / Accessibility-permission
failures than the previous formatter.

Same observable behavior on the success path; failure messages on the
runtime path are now more debuggable.
2026-06-17 16:32:29 -05:00
nokeo08 7120c06bbe Introduce named Logger interface in keeper.ts
Replaces 'ReturnType<typeof makeLogger>' with a small named interface so
simulateActivity's signature reads as (config, log: Logger) instead of a
type-derivation chain. Also makes it cleaner to substitute a mock logger
if simulateActivity is ever unit-tested.

No behavior change.
2026-06-17 16:24:56 -05:00
nokeo08 d5656efe5b Move 'mouse.config.autoDelayMs = 0' from module-load to runKeeper
The assignment used to run at import time, mutating the shared nut.js
singleton the second anything imported keeper.ts. Moved into the top
of runKeeper(), where the only caller actually needs it.

Same observable behavior — runKeeper is called exactly once per process
— but keeper.ts is now import-side-effect-free, which makes it
straightforward to import for tests or future tooling without touching
global mouse state.
2026-06-17 16:24:24 -05:00
nokeo08 8eac51cd45 Extract CliError into src/errors.ts
CliError used to live in cli.ts and was imported by configFile.ts purely
to grab a one-line class — the dependency arrow said 'config-file loader
depends on the CLI parser' when really it just needed a shared error
type.

Moving CliError to its own errors.ts module flattens the graph:

  errors.ts  (no internal deps)
    |
    +-- cli.ts
    +-- configFile.ts
    +-- config.ts  (will use it in the next commit)

cli.ts re-exports CliError for any consumer that prefers to keep
importing it from there; the canonical home is now errors.ts.
2026-06-17 16:22:33 -05:00
nokeo08 7714a6ad1a Seed default config on install; share defaults JSON with runtime
The defaults now live in a single file, scripts/config.default.json:

- src/config.ts imports it via 'with { type: "json" }' and derives
  DEFAULT_CONFIG (with seconds->ms conversion at the boundary), so the
  CLI help text always matches what the seeded user config contains.
- scripts/install.sh copies the same file to
  $XDG_CONFIG_HOME/move/config.json only if no file is already there.
  Existing configs (yours or from a previous install) are never
  overwritten.
- scripts/uninstall.sh does not touch the user config file at all,
  following the strict Unix convention. It does print a one-line
  notice pointing at the path so the user can rm -rf the dir
  themselves if they want a fully-clean removal.
- tsconfig.json gains resolveJsonModule:true so tsc accepts the JSON
  import.
- A small runtime assertion in src/config.ts (assertSeedShape) fails
  loudly if the seed file is missing keys or has wrong types.
- README Configuration section documents the seed behavior; Uninstall
  section documents the leave-config-behind policy with the rm-it-
  yourself command; Files table gains scripts/config.default.json.
2026-06-17 13:13:49 -05:00
nokeo08 940113019d Move verbose into Config; drop resolveVerbose
Verbose was awkwardly a separate runKeeper argument with a separate
resolveVerbose helper, even though it's just another tunable on the
same precedence ladder as the numeric fields. This commit collapses it.

Changes:

- Config gains 'readonly verbose: boolean'. DEFAULT_CONFIG sets it to
  false.
- resolveConfig now returns the full Config including verbose. Verbose
  layers with the same 'first defined value wins' precedence as the
  numeric fields.
- resolveVerbose is gone.
- ParsedCliArgs.verbose becomes boolean | undefined: undefined when -V
  was not passed, true when it was. parseCliArgs maps accordingly.
  This lets the layered resolver treat verbose uniformly.
- runKeeper takes a single Config arg. The internal logger reads from
  config.verbose at the top of runKeeper.
- move.ts no longer plumbs verbose separately; the single resolved
  Config drives everything.
- README How-it-works and Files-table entries updated to match.

Behavior verified for all five verbose-precedence cases (no file/no
flag, no file/-V, file:true/no flag, file:false/-V, file:false/no
flag) and config-error scenarios remain intact.
2026-06-17 12:45:46 -05:00
nokeo08 ccc136f727 Sync README Usage block with current --help
The static usage example in README.md was added in milestone 1 and
hand-maintained. When -C/--config landed in the JSON-config-file
commit, printHelp() was updated but this block wasn't. This commit
brings them back in sync and adds the precedence line.
2026-06-17 12:42:37 -05:00
nokeo08 df9305c6ac Add JSON config file support (XDG-respecting)
End users can now set defaults in a config file at:

  ${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json

CLI flags continue to win when both are set:

  CLI flags  >  config file  >  built-in defaults

Schema mirrors the CLI flag names and units. Loader is strict: unknown
keys, wrong types, and non-positive numerics are rejected with a clear
message naming the file and key, and the process exits 2.

Changes:

- New src/configFile.ts: existence-aware loader + strict schema
  validation. Throws CliError; the entry point converts those to exit 2.
- src/config.ts: ConfigOverrides gains 'verbose'; resolveConfig takes
  both file and CLI override layers; new defaultConfigPath() honors
  XDG_CONFIG_HOME; new resolveVerbose() layers verbose with the
  presence-only-CLI semantics documented.
- src/cli.ts: -C/--config <path> flag. printHelp prints the default
  config path and the precedence rule.
- src/move.ts: loads the config file (default XDG path or --config),
  passes both override layers into resolveConfig, resolves verbose
  separately, exits 2 on any validation failure.
- README: new Configuration section with path, precedence, example,
  validation rules, and the verbose CLI-can't-turn-off limitation.
  Files table gains src/configFile.ts.
2026-06-17 12:27:50 -05:00
nokeo08 c9645b374c Move installer scripts into scripts/ directory
- install.sh, uninstall.sh, dev-setup.sh now live under scripts/.
- dev-setup.sh's 'cd $(dirname $0)' updated to '.../..' so it lands
  at the repo root regardless of CWD.
- README install/uninstall curl URLs now reference scripts/<name>.sh.
- README contributor section and Files table updated to match.

Behavior is unchanged. The curl one-liner URL changes from
.../master/install.sh to .../master/scripts/install.sh; users following
the pre-v1.0.2 README will get a 404 from the old URL.
2026-06-17 12:17:32 -05:00
nokeo08 b43d5aad2d Add curl-pipe installer, uninstaller, and contributor bootstrap
End-user installation collapses to a single command:

  curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/install.sh | sh

Changes:

- install.sh rewritten as a POSIX sh, curl-pipeable end-user installer.
  Honors XDG_DATA_HOME and XDG_BIN_HOME (de facto). Idempotent via a
  version-marker file at $INSTALL_DIR/.installed-version; MOVE_FORCE=1
  overrides. Hard-fails if Bun is missing (no auto-install). Includes
  a safety guard refusing rm -rf on too-broad install dirs.
- uninstall.sh added; same curl-pipe pattern, shared XDG path
  resolution, leaves Bun alone.
- dev-setup.sh added (= the previous install.sh content, retitled for
  contributors and pointing at bun run start / bun link / install.sh).
- README updated: curl one-liner Install section, XDG behavior tables,
  new Uninstall section, new 'For contributors' section, Files table
  rows for all three scripts.
2026-06-17 11:55:03 -05:00
nokeo08 081f94088d Remove distribution plan (resolved)
The plan is being implemented in the following commit. The working-notes
document has served its purpose and is removed to keep master focused
on shipping artifacts.
2026-06-17 11:48:36 -05:00
26 changed files with 3498 additions and 499 deletions
+199
View File
@@ -0,0 +1,199 @@
# Changelog
All notable changes to `move` are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.0] - 2026-08-17
### Added
- Loop mode: `-l` / `--loop` (and the `loop` config key) keep the mouse
moving after a sweep is triggered until real user activity is detected,
instead of firing a single sweep. In loop mode the cursor is never restored
between iterations, so `line` and `diagonal` bounce edge-to-edge across the
screen (a roaming-DVD effect) rather than stopping at the first edge.
Patterns with a finite path (`jitter`, `walk`, `arc`, `figureEight`) chain
that path cycle after cycle. Interruption remains mouse-movement only.
### Changed
- Simplified on-screen confinement to a single policy: the executor now
reflects every pattern's out-of-range coordinates back inside the screen.
The `abort` and `clamp` bounds policies (and the per-strategy `bounds`
field) were removed. `abort` truncated a sweep at the first edge and `clamp`
could park the cursor against an edge — both counter to keeping the cursor
moving — while `reflect` bounces and keeps going. Behavior is unchanged for
every pattern at normal cursor positions; the only differences are at a
screen edge, where motion now bounces instead of stopping. No config keys,
flags, or pattern names changed.
## [1.3.3] - 2026-08-17
### Changed
- `install.sh` now installs the newest published tag by default instead of
tracking the `master` branch, so the plain `curl ... | sh` one-liner
installs a real release and reports its version (e.g. `v1.3.2`). The tag is
resolved from the Gitea tags API; if that lookup fails (offline, API
unreachable, or no tags yet) it falls back to `master`, preserving the old
behavior. Set `MOVE_VERSION` to pin an explicit branch or tag as before.
## [1.3.2] - 2026-08-17
### Added
- `install.sh` is now interactive. When it finds an existing install and a
controlling terminal is available, it reports what's there and asks before
replacing it, instead of leaving `MOVE_FORCE` as the only control. If a
config file already exists it asks separately whether to reseed it from the
shipped defaults. Both questions are asked before anything is downloaded or
deleted, so declining changes nothing.
- `MOVE_RESEED_CONFIG=1` overwrites the user config with the shipped defaults
without prompting, for unattended use. The previous file is kept as
`config.json.bak`; the same backup is written when reseeding is confirmed
at the prompt.
### Changed
- `MOVE_FORCE=1` now means "skip every prompt and reinstall unconditionally".
It deliberately does not touch the user config, so automation that
reinstalls the CLI can't take customizations down with it.
- Existing-install detection looks for the install tree and the wrapper, not
just the `.installed-version` marker, so a half-finished or hand-moved
install is caught rather than silently overwritten.
### Fixed
- Installing a *different* version over an existing one used to wipe and
replace it with no warning; only an exact version match was ever reported.
That case now prompts. With no terminal (CI, cron, container builds) the
previous non-interactive behavior is preserved exactly: an identical
version is a no-op, a different version is replaced.
## [1.3.1] - 2026-08-14
### Added
- `docs/execution-happy-path.md`: a sequence diagram (plus invariants) tracing
a clean idle-triggered sweep end to end, linked from the README.
### Fixed
- Stale comments corrected to match the current code: the `install.sh` /
`uninstall.sh` header curl URLs pointed at a nonexistent repo-root path
(they live under `scripts/`), so the documented command 404'd; `move.ts`'s
module list omitted `editor.ts` and the `--edit` step; and `config.ts` still
described a removed pixel unit.
## [1.3.0] - 2026-08-14
### Added
- Pluggable movement strategies. New `-p, --pattern <name>` flag and
`pattern` config key select how the cursor moves: `line` (default,
unchanged behavior), `diagonal`, `jitter`, `walk`, `arc`, `figureEight`.
Each pattern owns its own size and step count as constants; there is no
user knob for sweep magnitude.
- Pattern names are matched leniently: case and separators are ignored, so
`figureEight`, `figure-eight`, `figure_eight`, and `FIGUREEIGHT` are all
accepted (on the CLI and in the config file) and resolve to the canonical
name.
- `src/device.ts`: injectable `Device` seam over nut.js, enabling unit
tests for movement without the native binary or a real screen.
- `src/strategies.ts`: pure, per-pattern path generators plus the registry
and name validation.
- `src/executor.ts`: single `executePath` driver owning bounds policy
(`abort`/`clamp`/`reflect`), pacing, interrupt detection, and restore.
- Test suites for strategies, the executor (all bounds policies, rounding,
interrupt), and the keeper loop.
### Changed
- `simulateActivity` no longer hardcodes a straight-line sweep; it selects a
strategy from the registry and delegates execution to `executePath`. The
default `line` pattern is byte-for-byte the previous behavior.
- Interrupt detection now compares against the last *commanded* (rounded)
point rather than an ideal target, so fractional/curved paths don't
self-trip.
- `mouse.config.autoDelayMs = 0` moved from `runKeeper` into
`createNutDevice` — the single place nut.js is wired up.
- `runKeeper(config, device?)` accepts an injected device for testing.
- Interrupt detection tolerates a small (2px) gap between the commanded and
read-back cursor position, and the `clamp`/`reflect` patterns stay a few
pixels off the screen edge. Together these avoid false "user activity"
aborts from sub-pixel cursor placement on scaled or multi-monitor setups,
which the new edge-seeking patterns would otherwise hit. `line` (policy
`abort`) is unaffected.
### Removed
- `-n, --step-count` flag and the `stepCount` / `stepSize` config keys. Sweep
size and step count are now intrinsic to each movement pattern, not user
knobs. Config files that still contain these keys keep working: the loader
ignores them with a one-line notice instead of rejecting them, so existing
installs (all seeded with `stepCount`) don't break on upgrade. The removed
CLI flag, however, is a hard error like any other unknown option.
## [1.2.0] - 2026-06-17
### Added
- `-e, --edit` flag opens the resolved config file in `$EDITOR`.
### Changed
- `keeper.ts` (and `@nut-tree-fork/nut-js`) is lazy-imported, so `--help`
and `--version` skip the nut.js load and start ~10x faster.
## [1.1.1] - 2026-06-17
### Changed
- Tests moved from `src/` to a top-level `tests/` directory.
- `tsconfig.json` sets `"types": ["bun"]` so VS Code resolves `bun:test`.
### Fixed
- `package.json` version now matches the published tag.
## [1.1.0] - 2026-06-17
### Added
- JSON config file support at `${XDG_CONFIG_HOME:-~/.config}/move/config.json`.
Precedence: CLI flags > config file > defaults. Strict validation.
- `-C, --config <path>` to override the default config path.
- Installer seeds `config.json` with project defaults on fresh install only.
- Bun test suite for `resolveConfig` and `loadConfigFile`.
### Changed
- Installer scripts now live in `scripts/`.
- `verbose` is now a first-class `Config` field; `resolveVerbose` removed.
- `CliError` extracted into `src/errors.ts`.
- `defaultConfigPath()` throws when both `$XDG_CONFIG_HOME` and `$HOME` are unset.
- `mouse.config.autoDelayMs = 0` moved into `runKeeper` (no module-load side effect).
- Runtime errors go through a `failRuntime` helper that mirrors `failUser`.
- `keeper.ts` declares a named `Logger` interface.
- `dev-setup.sh` is now POSIX `sh`.
- `tsconfig.json`: enabled `noUncheckedIndexedAccess` and `resolveJsonModule`.
## [1.0.1] - 2026-06-17
### Added
- End-user `install.sh` runnable via `curl ... | sh`. XDG-respecting, idempotent.
- `uninstall.sh` removes the wrapper and install tree; leaves Bun and user
config alone.
- `dev-setup.sh` for contributors.
### Removed
- `DISTRIBUTION-PLAN.md` (design notes, superseded by the implementation).
## [1.0.0] - 2026-06-15
Initial release.
### Added
- `move` CLI for keeping presence-tracking apps marked Available by nudging
the cursor after a configurable idle period.
- Flags: `-h/--help`, `-v/--version`, `-m/--move-interval`,
`-c/--check-interval`, `-d/--step-delay`, `-n/--step-count`, `-V/--verbose`.
- Quiet-by-default logging.
- Source split into `src/{move,cli,config,keeper}.ts`.
- `bin` entry + shebang so `bun link` registers `move` globally.
[1.4.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.3...v1.4.0
[1.3.3]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.2...v1.3.3
[1.3.2]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.1...v1.3.2
[1.3.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.0...v1.3.1
[1.3.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.2.0...v1.3.0
[1.2.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.1...v1.2.0
[1.1.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.0...v1.1.1
[1.1.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.0.1...v1.1.0
[1.0.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.0.0...v1.0.1
[1.0.0]: https://gitea.cahlen.com/nokeo08/Move/releases/tag/v1.0.0
-222
View File
@@ -1,222 +0,0 @@
# Distribution Plan for `move`
**Status:** Tabled 2026-06-15. Revisit after the initial Gitea push lands
and the repo is publicly reachable at <https://gitea.cahlen.com/nokeo08/Move>.
This document captures the analysis and decisions for distributing the
`move` CLI as an end-user-friendly tool, so we can resume without redoing
the discussion.
---
## Decisions locked in
| Decision | Choice |
| -------- | ------ |
| Primary hosting | Self-hosted Gitea (`https://gitea.cahlen.com/nokeo08/Move`) |
| Default branch | `master` |
| Audience | Anyone who finds the repo (broader public-ish) |
| Effort budget for now | Smallest viable curl-able `install.sh` |
| Bun handling | Fail with a clear message when missing (no auto-install) |
| Reinstall behavior | Idempotent via a version-marker file; `MOVE_FORCE=1` overrides |
| Install location | `~/.local/bin` + `~/.local/share/move` (XDG, no sudo), env-overridable |
| `move` shim | Bash wrapper script, not symlink |
| PATH rc files | Never modified; print instructions if PATH isn't right |
| Uninstaller | In scope; same `curl ... \| sh` pattern |
| npm publishing | Out of scope |
| Standalone binaries (`bun build --compile`) | Future work; depends on nut.js native-bundling testing |
| Homebrew tap | Future work; cheap once releases exist |
---
## Distribution-option survey (from the discussion)
Four options were weighed:
1. **Compiled standalone binary** (`bun build --compile`). Best UX (single
binary, no Bun needed) but needs per-platform builds, a release
pipeline, code signing on macOS, and depends on nut.js's prebuilt native
`.node` files bundling correctly under `--compile`**unverified**.
2. **Curl-piped installer****chosen**. Single command, no clone,
source-and-Bun model. Conventions used by bun, deno, rustup, nvm.
Slight cold-start cost vs. compiled binary; tradeoff accepted.
3. **Package-manager distribution** (`bun add -g`, brew). `move` is taken
on npm; brew is macOS-centric; both add publishing overhead. Out of
scope for first cut.
4. **Status quo** (clone + `./install.sh` + `bun link`). Developer flow
only; will be renamed to `dev-setup.sh`.
---
## Target end-user UX
```sh
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/install.sh | sh
move --help
```
One command. No clone. No `bun link`. No `chmod`. After this the user has
a global `move` on their PATH.
---
## `install.sh` design (end-user, curl-able)
- **Shebang:** `#!/usr/bin/env sh` (POSIX).
- **Strict mode:** `set -eu`. Trap on errors with a clear
"install failed at <step>" message.
### Top-to-bottom behavior
1. **Platform detection:**
- `uname -s``Darwin` / `Linux` / else → "unsupported OS" + exit 1.
- `uname -m``arm64` / `x86_64` (mapped to `x64`) / else →
"unsupported arch" + exit 1.
- nut.js supports darwin-arm64/x64, linux-x64, win32-x64. WSL users
fall under Linux.
2. **Bun check:**
- `command -v bun` present → continue.
- Absent → print
`Error: bun is not installed. Install from https://bun.sh (e.g. 'curl -fsSL https://bun.sh/install | bash'), then re-run.`
→ exit 1.
3. **Resolve paths:**
- `INSTALL_DIR=${MOVE_PREFIX:-$HOME/.local/share/move}`
- `BIN_DIR=${MOVE_BIN_DIR:-$HOME/.local/bin}`
- `mkdir -p` both.
4. **Resolve version:**
- `VERSION=${MOVE_VERSION:-master}`.
5. **Idempotence check:**
- If `$INSTALL_DIR/.installed-version` exists AND its content equals
`$VERSION` AND `${MOVE_FORCE:-0}` is not `1`:
- Print `move <version> already installed at $BIN_DIR/move. Set MOVE_FORCE=1 to reinstall, or pass a different MOVE_VERSION.`
- Exit 0.
6. **Clean install dir:** `rm -rf "$INSTALL_DIR"/*` (preserving the dir
itself so user-set permissions on it are kept).
7. **Download source:**
- `TARBALL="https://gitea.cahlen.com/nokeo08/Move/archive/${VERSION}.tar.gz"`
- `curl -fsSL "$TARBALL" -o "$TMP"` (mktemp) → on failure, fail
loudly with the offending URL.
- `tar -xzf "$TMP" -C "$INSTALL_DIR" --strip-components=1`
- `rm -f "$TMP"`
8. **Install deps:** `cd "$INSTALL_DIR" && bun install --production`.
`--production` skips devDependencies (TypeScript, @types/bun); saves
tens of MB and seconds per install.
9. **Drop the wrapper:**
```sh
cat > "$BIN_DIR/move" <<EOF
#!/usr/bin/env sh
exec bun "$INSTALL_DIR/src/move.ts" "\$@"
EOF
chmod +x "$BIN_DIR/move"
```
Wrapper, not a symlink, so the path is absolute and stable regardless
of how the user's shell resolves things.
10. **Write version marker:** `printf '%s\n' "$VERSION" > "$INSTALL_DIR/.installed-version"`.
11. **PATH sanity check:**
- If `$BIN_DIR` is not on `$PATH`, print:
`Warning: $BIN_DIR is not on your PATH. Add this to your shell rc: export PATH="$BIN_DIR:$PATH"`
- Do **not** mutate the user's rc files — that breaks the "clean
uninstall" contract.
12. **macOS Accessibility hint** (carried over from current `install.sh`).
13. **Final message:** `Installed move $VERSION at $BIN_DIR/move. Run 'move --help' to get started.`
### Env vars
| Var | Default | Purpose |
| --- | ------- | ------- |
| `MOVE_VERSION` | `master` | Branch or tag to fetch from Gitea. |
| `MOVE_PREFIX` | `$HOME/.local/share/move` | Source install location. |
| `MOVE_BIN_DIR` | `$HOME/.local/bin` | Wrapper install location. |
| `MOVE_FORCE` | unset | Force reinstall even if version marker matches. |
---
## `uninstall.sh` design
```sh
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/uninstall.sh | sh
```
**Behavior:**
1. `set -eu`.
2. Respect `MOVE_PREFIX` and `MOVE_BIN_DIR` env vars (same defaults as
install).
3. `rm -f "$BIN_DIR/move"`.
4. `rm -rf "$INSTALL_DIR"`.
5. Print `Uninstalled. (Bun was not touched.)`.
Does **not** uninstall Bun — that's the user's runtime, not ours.
---
## `dev-setup.sh` design (renamed from current `install.sh`)
The current `install.sh` is appropriate for contributors but inappropriate
as the end-user installer. Rename to `dev-setup.sh`. Content stays nearly
identical:
- Verify Bun is installed (hard-error if missing).
- `bun install`.
- macOS Accessibility hint.
- Trailing message updated to:
`Done. Dev workflow: 'bun run start' to run, or 'bun link' to install the global 'move' command. End-user installer is install.sh.`
---
## README changes (when we resume)
- `## Install` section becomes the curl one-liner targeting `master`, plus
a short note about env-var overrides and a pointer to uninstall.
- `## Run` section collapses to `move` / `move --help` for the global
install path.
- New `## Uninstall` section with the curl-pipe uninstall command.
- New `## For contributors` section absorbing the existing
`bun run start` / `bun run src/move.ts` / `bun link` examples; points
at `dev-setup.sh`.
- `## Files` table updates: add `install.sh` (end-user, curl-able),
`uninstall.sh` (end-user, curl-able), `dev-setup.sh` (contributor
bootstrap).
---
## Verification plan (when we resume)
1. `sh -n install.sh` and `sh -n uninstall.sh` — POSIX syntax check.
2. Local end-to-end with overridden paths:
```sh
MOVE_PREFIX=/tmp/movetest MOVE_BIN_DIR=/tmp/movetest/bin ./install.sh
/tmp/movetest/bin/move --help
./install.sh # second run: already-installed message
MOVE_FORCE=1 ./install.sh # forced reinstall
```
3. Uninstall against the test prefix; confirm nothing left under it.
4. Re-run existing acceptance tests (`tsc`, `--help`, `--version`,
`--bogus`) — should be unaffected; no source changes in this milestone.
5. After committing/pushing, end-to-end against the actual Gitea URL.
---
## Caveat: Gitea archive URL shape
The plan assumes
`https://gitea.cahlen.com/nokeo08/Move/archive/<ref>.tar.gz` returns a
tarball whose top-level directory contains the project files (peeled by
`--strip-components=1`). This is the standard Gitea archive format, but
if your Gitea version produces a different layout the installer will need
a small tweak. The installer should error loudly with the offending URL
if extraction fails.
---
## Open future work (out of scope for the resume session)
- Switch `MOVE_VERSION` default from `master` to "latest tag" via the
Gitea API (`/api/v1/repos/nokeo08/Move/tags`) once tagged releases are
routine.
- Investigate `bun build --compile` + nut.js native-binary bundling for
true zero-Bun installs. If it works, layer prebuilt binaries onto the
curl installer so users without Bun also get one-command installs.
- Homebrew tap for macOS users (cheap once a release pipeline exists).
- Possible: PowerShell installer (`install.ps1`) for native Windows users
without WSL.
+335 -60
View File
@@ -11,7 +11,7 @@ cursor leaves the position the script just commanded.
## Requirements
- [Bun](https://bun.sh) >= 1.0.0
- macOS, Linux, or Windows (relies on
- macOS or Linux (relies on
[`@nut-tree-fork/nut-js`](https://github.com/nut-tree-fork/nut.js) for
cross-platform mouse + screen control)
- On macOS: Accessibility permission for the terminal running Bun
@@ -20,38 +20,89 @@ cursor leaves the position the script just commanded.
## Install
```sh
./install.sh
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
```
The installer verifies `bun` is on `PATH` and runs `bun install`. It does
not auto-install Bun; if Bun is missing it prints a hard error pointing at
<https://bun.sh>.
This installs the latest tagged release from Gitea (the installer resolves
it automatically), runs `bun install --production` under the install dir,
and drops a `move` wrapper on your bin dir. If the latest tag can't be
determined — offline, or the API is unreachable — it falls back to the
`master` branch. Pin an exact ref with `MOVE_VERSION` (see below).
The installer respects the XDG Base Directory Specification:
- Source lives at `$XDG_DATA_HOME/move` (default `~/.local/share/move`).
- Wrapper goes to `$XDG_BIN_HOME/move` (default `~/.local/bin/move`).
`XDG_BIN_HOME` is the widely-recognized de facto convention; XDG itself
doesn't standardize a user bin dir.
### Reinstalling over an existing install
The installer never replaces an existing install silently. When it finds
one and it can reach a terminal, it tells you what's there and asks:
```
==> Found an existing move install (v1.2.0) at /home/you/.local/share/move
Replace it with v1.3.2? [Y/n]
```
If a config file already exists, it asks separately whether to overwrite
it with the shipped defaults (default: no). Both questions come *before*
anything is downloaded or deleted, so declining costs you nothing.
This works under `curl ... | sh` too: the prompts read from `/dev/tty`
rather than stdin, which the piped script itself occupies.
With no terminal available — CI, cron, a container build — there's nobody
to ask, so the installer falls back to its long-standing behavior: an
identical version is a no-op, a different version is replaced, and your
config is left alone. Use the env vars below to drive it explicitly.
Env vars (all optional):
| Var | Default | Purpose |
| --- | ------- | ------- |
| `MOVE_VERSION` | latest tag | Branch or tag to install; auto-resolves to the newest tag, falling back to `master`. Pin with e.g. `v1.0.0`. |
| `MOVE_FORCE` | unset | Set to `1` to skip every prompt and reinstall unconditionally. Never touches your config. |
| `MOVE_RESEED_CONFIG` | unset | Set to `1` to overwrite your config with the shipped defaults without asking. The old file is kept as `config.json.bak`. |
| `XDG_DATA_HOME` | `$HOME/.local/share` | Where the source tree is installed (under `move/`). |
| `XDG_BIN_HOME` | `$HOME/.local/bin` | Where the `move` wrapper is placed. |
| `XDG_CONFIG_HOME` | `$HOME/.config` | Where the config file lives (under `move/`). |
Bun must already be installed; the installer fails with a clear pointer
to <https://bun.sh> if it isn't.
If your bin dir isn't on `PATH`, the installer prints the line to add to
your shell rc. It will not modify rc files for you.
## Run
```sh
bun run start
```
Or directly:
```sh
bun run src/move.ts
```
Or install it as a global `move` command via Bun's bin-linking:
```sh
bun link
move
move --help
```
`bun link` registers the `bin.move` entry from `package.json` (`./src/move.ts`,
which carries `#!/usr/bin/env bun`) as an executable on your `PATH`.
Stop with `Ctrl+C`. If `SIGINT` arrives mid-sweep, the cursor stays at
whichever step was last commanded — by design.
## Uninstall
```sh
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/uninstall.sh | sh
```
Removes the wrapper at `$XDG_BIN_HOME/move` and the install tree at
`$XDG_DATA_HOME/move`. Bun stays — it's your runtime, not ours.
Your config file at `$XDG_CONFIG_HOME/move/config.json` is **intentionally
left behind**, whether you customized it or never touched the seeded
defaults. The uninstaller prints a one-line notice pointing at the path
so you can remove it manually if you want:
```sh
rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/move"
```
## Usage
```text
@@ -60,41 +111,186 @@ Usage: move [options]
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: see the Configuration section.
-m, --move-interval <seconds> Idle time before a sweep fires. Default: 240.
-c, --check-interval <seconds> Cursor poll cadence. Default: 10.
-d, --step-delay <ms> Pause between synthetic steps. Default: 50.
-n, --step-count <pixels> Steps per sweep. Default: 250.
-V, --verbose Log every sweep, interrupt, and bounds event
-p, --pattern <name> Movement strategy. Default: line.
One of: line, diagonal, jitter, walk, arc,
figureEight. Each pattern defines its own
size and speed.
-V, --verbose Log every sweep and interrupt
(default prints only the startup banner).
-l, --loop Loop mode: once a sweep is triggered,
keep moving until you move the mouse (or
Ctrl+C), instead of firing a single sweep.
Precedence (highest wins): CLI flags > config file > built-in defaults.
```
Run `bun run src/move.ts --help` for the full text.
All flags are wired. Numeric overrides are layered onto the defaults via
Run `move --help` for the resolved default config-file path on your
system. Numeric overrides are layered onto the defaults via
`resolveConfig` in `src/config.ts`; time-valued inputs (`-m`, `-c`) are
expressed in seconds at the CLI boundary and converted to milliseconds
internally.
Logging is **quiet by default**: only the startup banner ("Teams Status
Keeper started…") and any error from an unhandled rejection print on a
default run. `-V` / `--verbose` opens up per-sweep, user-interrupt, and
out-of-bounds events.
default run. `-V` / `--verbose` opens up per-sweep and user-interrupt
events.
Invalid input (unknown flag, missing value, non-positive number) prints an
error to `stderr` and exits with code `2`.
## Configuration
`move` reads an optional JSON config file at:
```
${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json
```
The installer seeds this file with the default values on a fresh install,
**only if no file already exists at that path**. An existing config —
yours or from a previous install — is never overwritten silently: the
installer asks first, and replaces it only if you say yes (or if you set
`MOVE_RESEED_CONFIG=1`), keeping the old file as `config.json.bak` either
way. `MOVE_FORCE=1` reinstalls the software but leaves your config alone.
If you remove the file later, `move` still works: missing defaults fall
back to the values baked into the binary (which match what was seeded,
since both come from `scripts/config.default.json`).
Pass `-C` / `--config <path>` to point at a different file; in that mode
the file must exist.
### Precedence
```
CLI flags > config file > built-in defaults
```
CLI flags always win. The config file fills in any flag the user didn't
pass on the command line. Built-in defaults fill in anything the file
doesn't set.
### Example
```jsonc
{
"moveInterval": 240,
"checkInterval": 10,
"stepDelay": 50,
"pattern": "line",
"verbose": false,
"loop": false
}
```
All keys are optional; supply only the ones you want to override. Keys
and units mirror the CLI flags exactly: `moveInterval` and
`checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a
movement strategy name, `verbose` and `loop` are booleans.
> The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are
> tolerated for backward compatibility: they're ignored with a one-line
> notice rather than rejected, so a config seeded by an older install keeps
> working. Sweep size and step count are now properties of each pattern.
### Editing
```sh
move -e # or --edit
move --edit --config /path/to/another.json
```
Opens the active config file in `$EDITOR` (honors flags in the value,
so `EDITOR="code --wait"` and `EDITOR=vim` both work). Refuses with
exit `2` if:
- `$EDITOR` is unset or empty.
- The target file doesn't exist. (Run `move` once or reinstall to
re-seed the default file.)
The editor's own exit code is propagated, so you can chain
`move -e && move` to validate-by-running after every edit.
### Validation
The loader is strict:
- Root must be a JSON object.
- Unknown keys are rejected (catches typos like `"movInterval"`).
- Numeric values must be finite and strictly positive.
- `pattern` must resolve to a registered strategy name. Matching ignores
case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight`
are equivalent.
- `verbose` must be a boolean.
- `loop` must be a boolean.
Any validation failure prints a message naming the file and the offending
key to `stderr` and exits `2`.
### Loop mode (`--loop`)
By default a triggered sweep runs once and stops. With `-l` / `--loop` (or
`"loop": true` in the config file) the movement instead repeats until you
move the mouse (or press `Ctrl+C`) — a "keep moving until I'm back" mode.
It pairs naturally with the roaming patterns:
```sh
move --pattern diagonal --loop # roaming-DVD bounce around the screen
move --pattern figureEight --loop # traces the eight over and over
```
In loop mode the cursor is never restored between iterations, so `line` and
`diagonal` bounce edge-to-edge across the whole screen (the executor keeps
every pattern on-screen by reflecting off the edges) instead of ending at
the first edge. Interruption is detected via mouse movement only — there is
no keyboard hook — so if you resume by typing without touching the mouse,
the cursor keeps cycling until you nudge it or stop the process.
### Known limitation: `verbose` and `loop` can be turned on but not off from the CLI
`--verbose` and `--loop` are presence-only flags (there is no
`--no-verbose` / `--no-loop`). If the config file sets `"verbose": true` or
`"loop": true`, the CLI cannot force it back off in that invocation.
Workarounds: edit the file, or point at a different file with `--config`.
## How it works
The source lives under `src/`, split into an entry point plus three logic
For a step-by-step trace of a clean sweep, see the
[execution happy-path sequence diagram](docs/execution-happy-path.md).
The source lives under `src/`, split into an entry point plus logic
modules:
- `src/move.ts` is a thin entry point: parses args, dispatches `--help` /
`--version`, resolves the runtime config, and calls
`runKeeper(config, verbose)`.
`--version`, loads the config file, resolves the layered runtime
config, and calls `runKeeper(config)`.
- `src/cli.ts` owns argument parsing, validation, and help/version output.
- `src/config.ts` exports the `Config` type, `DEFAULT_CONFIG`, and the
`resolveConfig` overlay function.
- `src/keeper.ts` owns the synthetic-activity sweep and the idle-watch loop.
- `src/configFile.ts` owns optional JSON config-file loading + strict
schema validation.
- `src/config.ts` exports the `Config` type (which carries every tunable
including `verbose`), `DEFAULT_CONFIG`, `defaultConfigPath`, and the
layered `resolveConfig` overlay function.
- `src/keeper.ts` owns the idle-watch loop and the per-sweep glue that
wires a strategy to the executor.
Movement itself is split across three seams so patterns are easy to add
and everything but the raw nut.js call is unit-testable:
- `src/device.ts` is the I/O boundary: a `Device` interface
(`getPosition`/`setPosition`/`width`/`height`/`sleep`) plus the nut.js
implementation. It's the *only* module that imports nut.js, and it's
injectable, so tests drive the loop and executor with a fake.
- `src/strategies.ts` holds the pure movement patterns — each a generator
of target points given a start, screen size, config, and RNG — plus the
registry and name validation. Adding a pattern is one pure function.
- `src/executor.ts` is the single `executePath` driver: it rounds targets,
reflects any off-screen coordinate back inside, paces steps, detects
real-user interruption, and restores the cursor on a clean sweep.
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
@@ -103,7 +299,7 @@ Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
| `moveInterval` | `4 * 60_000` | `-m`, `--move-interval` | Idle time (ms) required before a synthetic sweep fires. |
| `checkInterval` | `10_000` | `-c`, `--check-interval` | How often (ms) the main loop polls the cursor for real activity. |
| `stepDelay` | `50` | `-d`, `--step-delay` | Pause (ms) between individual synthetic steps in a sweep. |
| `stepCount` | `250` | `-n`, `--step-count` | Pixel-steps per sweep. |
| `pattern` | `"line"` | `-p`, `--pattern` | Movement strategy name (see Movement strategies below). |
`-m` and `-c` are accepted in seconds at the CLI; `resolveConfig` converts
to milliseconds before handing the resolved `Config` to `runKeeper`.
@@ -118,43 +314,122 @@ to milliseconds before handing the resolved `Config` to `runKeeper`.
- Otherwise, if `now - lastActivity >= config.moveInterval`, call
`simulateActivity` and reset the idleness clock.
### Synthetic sweep (`simulateActivity`)
### Synthetic sweep (`simulateActivity` + `executePath`)
1. Read the starting position and current screen dimensions.
2. Pick a horizontal direction (`dx = +1` if there's room to the right,
else `-1`) so the sweep stays on-screen. Vertical is `dy = 0` for now.
3. For each of `config.stepCount` steps:
- Compute and bounds-check the next target.
1. `simulateActivity` snapshots the starting position and current screen
dimensions (re-read every sweep so monitor changes are handled), looks
up `config.pattern` in the strategy registry, and builds a `MoveContext`.
2. It hands the strategy and context to `executePath`, which drives the
sweep. For each target the strategy yields:
- Round to whole pixels and reflect any off-screen coordinate back inside
the travel range, so the cursor bounces off the edges and keeps moving.
- Move the cursor there, sleep `config.stepDelay`.
- Re-read the cursor. If it isn't where we put it, the user moved it —
log (when `--verbose`) and return early without snapping back.
4. On a clean full sweep, restore the cursor to its starting position so
- Re-read the cursor. If it isn't at the point we *just commanded*, the
user moved it — log (when `--verbose`) and return early without
snapping back.
3. On a clean full sweep, restore the cursor to its starting position so
the next idle-check sees "no movement" and doesn't misread the synthetic
activity as real user input.
In loop mode (`--loop`) step 2 repeats until the user interrupts: a
pattern with an infinite `loopPath` (`line`, `diagonal`) runs that single
never-ending path, while the others chain their finite path cycle after
cycle. The restore in step 3 is skipped so successive cycles flow from where
the last left off.
Comparing against the last commanded (rounded) point — not the strategy's
ideal, possibly fractional target — is what lets curved and stochastic
patterns run without every rounded step looking like user activity. The
comparison also allows a small (2px) tolerance, and the travel range stays a
couple of pixels off the screen edge, so sub-pixel cursor placement on scaled
or multi-monitor displays isn't misread as the user grabbing the mouse.
### Movement strategies
`config.pattern` selects one of the generators in `src/strategies.ts`:
| Name | Motion | Steps | Size |
| ------------- | ------------------------------------------------------------- | ----- | ----------- |
| `line` | Straight horizontal sweep (the original behavior). | 250 | 250px |
| `diagonal` | Straight line on both axes toward the roomiest corner. | 250 | 250px/axis |
| `jitter` | Small random hops within a tight radius of the start. | 80 | 30px radius |
| `walk` | Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step |
| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px |
| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide |
Every pattern is kept on-screen the same way: the executor reflects any
coordinate that would fall past a screen edge back inside, so motion bounces
instead of stopping. Strategies therefore never bound their own output —
they emit ideal geometry and let the executor confine it.
Each pattern owns its geometry — how many steps it takes and how far it
reaches — as constants in `src/strategies.ts`. Those are properties of the
pattern, not user preferences, so there is no knob for sweep size or step
count; `stepDelay` (the per-step pause) is the only pacing lever, and it
scales every pattern's total duration. To add a pattern, write one pure
generator and register it — the executor supplies on-screen reflection,
pacing, interrupt, and restore for free.
### Why `mouse.config.autoDelayMs = 0`
nut.js inserts a 100ms delay after every action by default. With two mouse
calls per step that would silently more-than-double the duration of a
sweep. The script controls cadence itself via `config.stepDelay`, so the
implicit delay is disabled at module load (a side effect of importing
`keeper.ts`).
sweep. The code controls cadence itself via `config.stepDelay`, so the
implicit delay is disabled in `createNutDevice` — the single place nut.js
is wired up. Importing the movement modules stays side-effect-free.
## For contributors
Clone the repo and bootstrap a dev environment:
```sh
git clone https://gitea.cahlen.com/nokeo08/Move.git
cd Move
./scripts/dev-setup.sh
```
`scripts/dev-setup.sh` verifies Bun is installed and runs `bun install`
(with devDependencies, unlike the end-user `scripts/install.sh`). It
operates at the repo root regardless of the CWD you invoke it from.
Run from the source tree:
```sh
bun run start # via the package.json script
bun run src/move.ts # direct
bun run src/move.ts --help
```
Or install a global `move` pointed at your checkout:
```sh
bun link
move --help
```
## Files
| File | Purpose |
| ------------------- | ----------------------------------------------------------------------- |
| `src/move.ts` | CLI entry point: parses args, dispatches help/version, starts the loop. |
| `src/cli.ts` | Argument parsing, validation, and help/version output. |
| `src/config.ts` | `Config` type, `DEFAULT_CONFIG`, and `resolveConfig` overlay. |
| `src/keeper.ts` | Synthetic-activity sweep and idle-watch loop. |
| `install.sh` | Bootstrap: verify Bun, `bun install`, print macOS permission hint. |
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
| `bun.lock` | Bun's lockfile. Commit this. |
| `LICENSE` | GPLv3 license text. |
| `CHANGES.md` | History of the post-port review fixes. |
| `MouseMover.java` | Original Java source, kept for reference. |
| File | Purpose |
| ------------------- | ----------------------------------------------------------------------------- |
| `scripts/install.sh` | End-user installer; curl-pipeable from Gitea. |
| `scripts/uninstall.sh` | End-user uninstaller; curl-pipeable from Gitea. |
| `scripts/dev-setup.sh` | Contributor bootstrap (verify Bun + `bun install`). |
| `scripts/config.default.json`| Single source of truth for default values: imported by `src/config.ts` and copied to `$XDG_CONFIG_HOME/move/config.json` on a fresh install. |
| `src/move.ts` | CLI entry point: parses args, dispatches help/version, starts the loop. |
| `src/cli.ts` | Argument parsing, validation, and help/version output. |
| `src/config.ts` | `Config` type (carries every tunable, including `verbose`), `DEFAULT_CONFIG` (derived from `scripts/config.default.json`), `defaultConfigPath`, and the layered `resolveConfig` overlay. |
| `src/configFile.ts` | Optional JSON config-file loader with strict schema validation. |
| `src/editor.ts` | `move --edit`: opens the active config file in `$EDITOR`. |
| `src/errors.ts` | Shared error types (`CliError`). |
| `src/keeper.ts` | Idle-watch loop + per-sweep glue (selects a strategy, calls the executor). |
| `src/device.ts` | `Device` I/O seam over nut.js (`Point`, `createNutDevice`); the only nut.js importer. |
| `src/strategies.ts` | Pure movement-pattern generators, the strategy registry, and name validation. |
| `src/executor.ts` | `executePath` driver: on-screen reflection, pacing, interrupt detection, restore. |
| `docs/execution-happy-path.md` | Sequence diagram + invariants for a clean sweep. |
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
| `bun.lock` | Bun's lockfile. Commit this. |
| `LICENSE` | GPLv3 license text. |
## License
+103
View File
@@ -0,0 +1,103 @@
# Execution: the happy path
This traces one full idle-triggered sweep that completes cleanly — the
"happy path" where the machine is idle long enough to fire, the configured
pattern runs to exhaustion, and no real user activity interrupts it.
For the module breakdown and the three seams (`device` / `strategies` /
`executor`), see the "How it works" section of the [README](../README.md).
```mermaid
sequenceDiagram
autonumber
participant Entry as move.ts
participant Keeper as runKeeper
participant Sim as simulateActivity
participant Strat as Strategy<br/>(e.g. line)
participant Exec as executePath
participant Dev as Device<br/>(nut.js)
Note over Entry: startup (args → config)
Entry->>Entry: parseCliArgs()
Entry->>Entry: loadConfigFile()
Entry->>Entry: resolveConfig(file, cli)
Entry->>Keeper: runKeeper(config)
Keeper->>Dev: createNutDevice()
Note right of Dev: sets mouse.config.autoDelayMs = 0
Keeper->>Keeper: log.info(banner)
Keeper->>Dev: getPosition()
Dev-->>Keeper: lastPos
Note over Keeper: lastActivity = now
loop every checkInterval (until idle long enough)
Keeper->>Dev: sleep(checkInterval)
Keeper->>Dev: getPosition()
Dev-->>Keeper: pos
Note over Keeper: pos == lastPos (no user movement)<br/>now - lastActivity ≥ moveInterval → fire
end
Keeper->>Sim: simulateActivity(config, log, dev)
Sim->>Dev: width()
Dev-->>Sim: width
Sim->>Dev: height()
Dev-->>Sim: height
Sim->>Dev: getPosition()
Dev-->>Sim: start
Note over Sim: strategy = STRATEGIES[config.pattern]<br/>ctx = { start, width, height, rng }
Sim->>Exec: executePath(strategy, ctx, dev, log, config)
Exec->>Strat: path(ctx)
Strat-->>Exec: iterable of Points
loop for each target point (clean run)
Exec->>Exec: resolveTarget(target) → point (reflected on-screen)
Exec->>Dev: setPosition(point)
Exec->>Dev: sleep(stepDelay)
Exec->>Dev: getPosition()
Dev-->>Exec: current
Note over Exec: |current - point| ≤ 2px → not the user, continue
end
Note over Exec: path exhausted, no interruption
Exec->>Dev: setPosition(round(start))
Note right of Exec: restore cursor to origin
Exec-->>Sim: "completed"
Sim-->>Keeper: (done)
Keeper->>Dev: getPosition()
Dev-->>Keeper: lastPos (equals start, re-synced)
Note over Keeper: lastActivity = now<br/>loop continues
```
## Invariants this path relies on
- **`createNutDevice()` is the only nut.js touchpoint.** It disables nut.js's
100ms auto-delay so `executePath` owns cadence via `stepDelay`.
- **The strategy is pure.** `path(ctx)` yields ideal points from geometry
alone (`start` / `width` / `height` / `rng`); it never touches the device,
which is what makes every pattern unit-testable without a screen.
- **Every step re-reads the cursor** and compares it against the *commanded*
point (not the strategy's ideal, possibly fractional target) within a 2px
tolerance. On the happy path each check passes, so the loop runs to
exhaustion. A mismatch beyond tolerance is real user activity and returns
`"interrupted"` without restoring — the branch this diagram omits.
- **Clean completion restores the cursor to `round(start)`.** That is why the
follow-up `getPosition()` in `runKeeper` re-syncs `lastPos` to the origin as
a no-op, and the next idle check sees no net movement (so the synthetic
sweep is never mistaken for the user returning).
- **On-screen confinement is uniform.** `resolveTarget` reflects any
coordinate past a screen edge back inside the travel range — the sole,
per-pattern-independent policy. A strategy emits ideal geometry and never
bounds its own output.
## Loop mode (`--loop`)
This diagram is the single-sweep path (`config.loop === false`). Under
`--loop`, `simulateActivity` instead repeats the step loop until the user
interrupts: a pattern with an infinite `loopPath` (`line`, `diagonal`) runs
that one never-ending path, while the others chain their finite `path` cycle
after cycle, re-reading the cursor as the next `start` each time. The restore
in the final step is skipped (`restore: false`), so successive cycles flow
from where the last left off. Everything else — reflection, pacing, and the
per-step interrupt check — is identical to the sweep traced above.
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "move",
"version": "1.0.0",
"version": "1.4.0",
"private": true,
"license": "GPL-3.0-only",
"type": "module",
@@ -11,7 +11,8 @@
"bun": ">=1.0.0"
},
"scripts": {
"start": "bun run src/move.ts"
"start": "bun run src/move.ts",
"test": "bun test"
},
"dependencies": {
"@nut-tree-fork/nut-js": "^4.2.2"
+8
View File
@@ -0,0 +1,8 @@
{
"moveInterval": 240,
"checkInterval": 10,
"stepDelay": 50,
"pattern": "line",
"verbose": false,
"loop": false
}
+21 -9
View File
@@ -1,6 +1,9 @@
#!/usr/bin/env bash
#!/usr/bin/env sh
#
# install.sh - bootstrap the Teams Status Keeper project.
# dev-setup.sh - contributor bootstrap for the `move` repo.
#
# Use this when you have cloned the repo and want a working development
# environment. End users should use `install.sh` (curl-pipeable) instead.
#
# Verifies Bun is installed, then runs `bun install` to fetch dependencies
# (notably `@nut-tree-fork/nut-js`, which ships prebuilt native binaries
@@ -8,13 +11,18 @@
#
# This script is idempotent: re-running it just re-resolves the dependency
# tree against the existing `bun.lock`.
#
# POSIX sh; no bashisms. Matches the style of install.sh / uninstall.sh.
# Fail fast on any error, unset variable, or failed pipe stage.
set -euo pipefail
# Fail fast on any error or unset variable. (`pipefail` is bash-only and
# not strictly needed here; this script doesn't pipe in failure-prone
# ways.)
set -eu
# Always operate relative to the script's own directory so the install works
# regardless of the caller's CWD.
cd "$(dirname "$0")"
# Operate at the repo root regardless of the caller's CWD. This script
# lives under scripts/, so pop up one level to land at the project root
# before running bun install.
cd "$(dirname "$0")/.."
echo "==> Checking for Bun..."
if ! command -v bun >/dev/null 2>&1; then
@@ -30,12 +38,16 @@ echo "==> Installing dependencies..."
bun install
echo
echo "Done. Run with: bun run start"
echo "Done. Dev workflow:"
echo " - 'bun run start' to run from the source tree."
echo " - 'bun run test' to run the test suite."
echo " - 'bun link' to install a global 'move' command pointed at this checkout."
echo "End-user installer is install.sh (curl-pipeable from Gitea)."
# macOS gates synthetic mouse events behind the Accessibility permission.
# Without this hint, the first run silently fails to move the cursor and
# the user has no obvious next step.
if [[ "$(uname)" == "Darwin" ]]; then
if [ "$(uname)" = "Darwin" ]; then
echo "Note: macOS will prompt for Accessibility permission on first mouse move."
echo " Grant it under System Settings > Privacy & Security > Accessibility."
fi
+406
View File
@@ -0,0 +1,406 @@
#!/usr/bin/env sh
#
# install.sh - End-user installer for the `move` CLI.
#
# Curl-pipe ready:
#
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
#
# What it does:
# 1. Detect platform; bail on anything @nut-tree-fork/nut-js doesn't ship.
# 2. Require Bun; fail with a clear hint if missing (no auto-install).
# 3. Resolve XDG-compliant install paths.
# 4. Detect an existing install and, on a terminal, ask before replacing it.
# 5. Download and build in a temp staging dir; swap it over the install
# dir only once it's complete, so a failed run can't destroy a working
# install.
# 6. `bun install --production` (skips devDependencies).
# 7. Drop a small wrapper script as `move` on the user's bin dir.
# 8. Seed the user's config file with defaults if one doesn't already exist
# at $XDG_CONFIG_HOME/move/config.json; if one does, offer to reseed it.
# 9. Verify PATH, surface macOS Accessibility hint, print final status.
#
# Interactivity:
# When a controlling terminal is available, an existing install is never
# replaced without asking, and an existing config is never overwritten
# without asking. Both questions are put up front, before anything is
# downloaded or deleted, so declining costs nothing. With no terminal
# (CI, cron, container build) the script falls back to its historical
# non-interactive contract: an identical version is a no-op, a different
# version is replaced, and the config is left alone.
#
# Env vars (all optional):
# MOVE_VERSION Branch or tag to install. Default: the newest tag
# published to the repo, falling back to the master
# branch if that can't be determined.
# MOVE_FORCE Set to 1 to skip every prompt and reinstall
# unconditionally. Does not touch the user config.
# MOVE_RESEED_CONFIG Set to 1 to overwrite the user config with the
# shipped defaults without asking. The previous file
# is saved alongside it as config.json.bak.
# XDG_DATA_HOME Source install root (default $HOME/.local/share).
# Final source location is $XDG_DATA_HOME/move.
# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin).
# Final binary location is $XDG_BIN_HOME/move.
# XDG_CONFIG_HOME User config root (default $HOME/.config).
# Default config file is $XDG_CONFIG_HOME/move/config.json.
#
# POSIX sh; no bashisms. Note the absence of `local`: helper functions use
# `_`-prefixed globals, which POSIX sh leaves us with.
set -eu
REPO_OWNER="nokeo08"
REPO_NAME="Move"
GITEA_HOST="gitea.cahlen.com"
# Left empty when unset so it can be resolved to the latest tag once the
# prerequisite tools are confirmed present (see "Resolve version" below).
MOVE_VERSION="${MOVE_VERSION:-}"
MOVE_FORCE="${MOVE_FORCE:-0}"
MOVE_RESEED_CONFIG="${MOVE_RESEED_CONFIG:-0}"
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move"
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/move"
CONFIG_FILE="$CONFIG_DIR/config.json"
WRAPPER="$BIN_DIR/move"
die() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
# Refuse to operate on a directory that points somewhere catastrophic.
# The XDG_* env vars are broadly-scoped and the user could set them to
# anything; our paths always culminate in `.../move`, but a malformed
# XDG var could still resolve to something like '/move' which we don't
# want to `rm -rf` or otherwise mass-write into.
assert_safe_dir() {
case "$1" in
'' | '/' | "$HOME" | "$HOME/" | '/move')
die "refusing to operate on '$1' (too broad)"
;;
esac
}
# Print the newest tag name published to the repo (e.g. v1.3.2), or return
# non-zero if it can't be determined. The Gitea tags API lists newest first,
# so with ?limit=1 the sole entry is the latest tag; grep+sed pull its "name"
# without a jq dependency. Any failure -- offline, API error, no tags, a
# missing grep/sed -- collapses to a non-zero return and lets the caller fall
# back to the master branch.
resolve_latest_tag() {
_tags_url="https://$GITEA_HOST/api/v1/repos/$REPO_OWNER/$REPO_NAME/tags?limit=1"
_json=$(curl -fsSL --max-time 10 "$_tags_url" 2>/dev/null) || return 1
_tag=$(printf '%s' "$_json" | grep -o '"name":"[^"]*"' | head -n 1 | sed 's/.*:"//; s/"$//')
[ -n "$_tag" ] || return 1
printf '%s\n' "$_tag"
}
# --- Interactive prompt support ----------------------------------------------
#
# The documented entry point is `curl -fsSL ... | sh`, which means stdin is
# the *script source itself*. Reading a prompt answer from stdin would
# consume the rest of the program and truncate execution mid-run, so every
# prompt reads from /dev/tty directly.
#
# Detecting whether that's possible needs a real open(2) attempt. A `[ -r
# /dev/tty ]` test is not enough: the device node exists and is mode 0666
# even in contexts with no controlling terminal (cron, CI, container
# builds), where opening it fails with ENXIO. The probe runs in a subshell
# because a redirection failure on `exec` -- a special built-in -- exits a
# non-interactive shell outright under POSIX.
if (: >/dev/tty) 2>/dev/null; then
INTERACTIVE=1
else
INTERACTIVE=0
fi
# confirm PROMPT DEFAULT -> 0 for yes, 1 for no.
#
# DEFAULT is 'y' or 'n' and is taken on a bare Enter or on EOF (^D), so the
# loop can't spin forever against a closed terminal. Prompts are written to
# /dev/tty rather than stdout so they stay visible when the caller redirects
# our output.
confirm() {
_prompt="$1"
_default="$2"
case "$_default" in
y) _hint='[Y/n]' ;;
*) _hint='[y/N]' ;;
esac
while :; do
printf '%s %s ' "$_prompt" "$_hint" > /dev/tty
if ! IFS= read -r _reply < /dev/tty; then
printf '\n' > /dev/tty
_reply=''
fi
if [ -z "$_reply" ]; then
_reply="$_default"
fi
case "$_reply" in
[yY] | [yY][eE][sS]) return 0 ;;
[nN] | [nN][oO]) return 1 ;;
*) printf "Please answer 'y' or 'n'.\n" > /dev/tty ;;
esac
done
}
# --- Prerequisite tools ------------------------------------------------------
for tool in curl tar mktemp; do
if ! command -v "$tool" >/dev/null 2>&1; then
die "$tool is required (not found in PATH)"
fi
done
# --- Platform detection ------------------------------------------------------
OS=$(uname -s)
ARCH=$(uname -m)
case "$OS" in
Darwin|Linux) ;;
*) die "unsupported OS '$OS' (move supports macOS and Linux)" ;;
esac
case "$ARCH" in
arm64|aarch64|x86_64|amd64) ;;
*) die "unsupported architecture '$ARCH'" ;;
esac
# --- Bun check (hard fail; no auto-install) ----------------------------------
if ! command -v bun >/dev/null 2>&1; then
cat >&2 <<EOF
Error: bun is not installed.
Install it from https://bun.sh
(e.g. 'curl -fsSL https://bun.sh/install | bash')
then re-run this installer.
EOF
exit 1
fi
BUN_VERSION=$(bun --version)
printf '==> Using bun %s\n' "$BUN_VERSION"
# --- Resolve version to install ----------------------------------------------
#
# With no explicit MOVE_VERSION, default to the newest published tag so the
# plain one-liner installs a real release and reports its version (e.g.
# v1.3.2) rather than tracking the moving `master` branch. If the lookup
# fails -- offline, API unreachable, or no tags yet -- fall back to master,
# preserving the historical behavior instead of aborting.
if [ -z "$MOVE_VERSION" ]; then
if MOVE_VERSION=$(resolve_latest_tag); then
printf '==> Latest release is %s\n' "$MOVE_VERSION"
else
MOVE_VERSION=master
printf '==> Could not determine latest release; installing from master\n' >&2
fi
fi
# --- Existing install check --------------------------------------------------
#
# Both questions this script can ask are asked here, before anything is
# downloaded, deleted, or written. Declining therefore costs the user
# nothing, and no prompt appears minutes into a `bun install`.
assert_safe_dir "$INSTALL_DIR"
assert_safe_dir "$CONFIG_DIR"
INSTALLED_VERSION=''
if [ -f "$INSTALL_DIR/.installed-version" ]; then
INSTALLED_VERSION=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '')
fi
# Look wider than the version marker: a half-finished or hand-edited install
# can leave a tree or a wrapper behind without one, and steamrolling that
# silently is precisely what this check exists to prevent.
FOUND_EXISTING=0
if [ -d "$INSTALL_DIR" ] || [ -e "$WRAPPER" ] || [ -L "$WRAPPER" ]; then
FOUND_EXISTING=1
fi
# Decided here, applied at the end -- the seed file it copies from only
# exists once the tarball has been extracted.
RESEED_CONFIG="$MOVE_RESEED_CONFIG"
if [ "$FOUND_EXISTING" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then
if [ -n "$INSTALLED_VERSION" ]; then
printf '==> Found an existing move install (%s) at %s\n' \
"$INSTALLED_VERSION" "$INSTALL_DIR"
else
printf '==> Found an existing move install at %s (version unknown)\n' \
"$INSTALL_DIR"
fi
if [ "$INTERACTIVE" = "1" ]; then
# The defaults below are chosen so that a bare Enter reproduces
# exactly what this script did before it learned to ask: skip when
# the version is identical, replace when it differs.
if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then
REPLACE_PROMPT="Reinstall move $MOVE_VERSION over it?"
REPLACE_DEFAULT=n
else
REPLACE_PROMPT="Replace it with $MOVE_VERSION?"
REPLACE_DEFAULT=y
fi
if ! confirm "$REPLACE_PROMPT" "$REPLACE_DEFAULT"; then
printf 'Leaving the existing install alone. Nothing was changed.\n'
exit 0
fi
else
# Nowhere to ask, so fall back to the historical contract.
if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then
printf 'move %s is already installed at %s.\n' "$MOVE_VERSION" "$WRAPPER"
printf 'Set MOVE_FORCE=1 to reinstall, or set MOVE_VERSION to a different ref.\n'
exit 0
fi
printf '==> No terminal available; replacing %s with %s\n' \
"${INSTALLED_VERSION:-unknown}" "$MOVE_VERSION"
fi
fi
if [ -e "$CONFIG_FILE" ] && [ "$RESEED_CONFIG" != "1" ] &&
[ "$INTERACTIVE" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then
printf '==> A config file already exists at %s\n' "$CONFIG_FILE"
if confirm 'Overwrite it with the shipped defaults?' n; then
RESEED_CONFIG=1
fi
fi
# --- Stage, download, build --------------------------------------------------
#
# Everything is assembled in a temp staging dir first; the existing install
# is removed only once the staged tree is fully built and ready to swap in.
# A failed download, extract, or `bun install` therefore leaves a working
# install untouched -- unlike the old flow, which wiped INSTALL_DIR before
# the download even started and left nothing behind on any failure.
mkdir -p "$BIN_DIR"
DATA_ROOT=$(dirname "$INSTALL_DIR")
mkdir -p "$DATA_ROOT"
TARBALL_TMP=$(mktemp) || die "could not create temp file"
# Stage on the same filesystem as INSTALL_DIR so the final swap is a rename,
# not a cross-device copy.
STAGE_DIR=$(mktemp -d "$DATA_ROOT/.move-stage.XXXXXX") ||
{ rm -f "$TARBALL_TMP"; die "could not create staging dir under $DATA_ROOT"; }
# On any exit, clean up the tarball and any leftover staging dir. After a
# successful swap STAGE_DIR has been renamed away, so the rm -rf is a no-op.
cleanup() {
rm -f "$TARBALL_TMP"
rm -rf "$STAGE_DIR"
}
trap cleanup EXIT INT TERM
TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz"
printf '==> Downloading %s\n' "$TARBALL_URL"
if ! curl -fsSL "$TARBALL_URL" -o "$TARBALL_TMP"; then
die "could not download $TARBALL_URL (check MOVE_VERSION='$MOVE_VERSION' and network)"
fi
printf '==> Extracting source\n'
if ! tar -xzf "$TARBALL_TMP" -C "$STAGE_DIR" --strip-components=1; then
die "could not extract tarball from $TARBALL_URL"
fi
printf '==> Installing runtime dependencies (bun install --production)\n'
(cd "$STAGE_DIR" && bun install --production)
# Sanity-check the staged tree before we disturb the existing install: a
# truncated or wrong tarball that's missing the config seed should fail here,
# while the old install is still intact and swappable-out.
if [ ! -f "$STAGE_DIR/scripts/config.default.json" ]; then
die "downloaded tree is missing scripts/config.default.json (bad MOVE_VERSION='$MOVE_VERSION'?)"
fi
# Record the version inside the staged tree so the install is self-consistent
# the instant it lands.
printf '%s\n' "$MOVE_VERSION" > "$STAGE_DIR/.installed-version"
# --- Swap staged tree into place ---------------------------------------------
#
# The only destructive step, kept as late as possible: the window where
# INSTALL_DIR is absent is just this rm + rename, not the whole build.
assert_safe_dir "$INSTALL_DIR"
printf '==> Installing to %s\n' "$INSTALL_DIR"
rm -rf "$INSTALL_DIR"
if ! mv "$STAGE_DIR" "$INSTALL_DIR"; then
die "could not move staged install into place at $INSTALL_DIR"
fi
# --- Drop the wrapper --------------------------------------------------------
printf '==> Writing wrapper to %s\n' "$WRAPPER"
cat > "$WRAPPER" <<EOF
#!/usr/bin/env sh
exec bun "$INSTALL_DIR/src/move.ts" "\$@"
EOF
chmod +x "$WRAPPER"
# --- Seed user config file ---------------------------------------------------
#
# The defaults file shipped with the source tree (scripts/config.default.json)
# is also the single source of truth for the runtime defaults loaded by
# src/config.ts, so seeding a fresh user file from the same place keeps the
# CLI behavior and the user-visible config in sync.
#
# Policy: an existing user config is never overwritten *silently*. It is
# replaced only on an explicit answer to the prompt above or an explicit
# MOVE_RESEED_CONFIG=1, and even then the previous file is kept as a .bak
# rather than destroyed. Everything else -- MOVE_FORCE=1, a non-interactive
# run -- leaves it untouched, so automation that reinstalls the software
# can't take a user's customizations down with it. The uninstaller follows
# the matching policy of never removing the config at all.
SEED_SRC="$INSTALL_DIR/scripts/config.default.json"
if [ ! -f "$SEED_SRC" ]; then
die "default config seed missing from install tree: $SEED_SRC"
fi
mkdir -p "$CONFIG_DIR"
if [ ! -e "$CONFIG_FILE" ]; then
cp "$SEED_SRC" "$CONFIG_FILE"
printf '==> Wrote default config to %s\n' "$CONFIG_FILE"
elif [ "$RESEED_CONFIG" = "1" ]; then
cp "$CONFIG_FILE" "$CONFIG_FILE.bak"
cp "$SEED_SRC" "$CONFIG_FILE"
printf '==> Reseeded %s (previous file saved as %s)\n' \
"$CONFIG_FILE" "$CONFIG_FILE.bak"
else
printf '==> Config already exists at %s; leaving it alone\n' "$CONFIG_FILE"
fi
# --- PATH sanity check -------------------------------------------------------
case ":$PATH:" in
*":$BIN_DIR:"*) BIN_ON_PATH=1 ;;
*) BIN_ON_PATH=0 ;;
esac
if [ "$BIN_ON_PATH" = "0" ]; then
printf '\nNote: %s is not on your PATH. Add this to your shell rc:\n' "$BIN_DIR"
printf ' export PATH="%s:$PATH"\n' "$BIN_DIR"
fi
# --- macOS Accessibility hint ------------------------------------------------
if [ "$OS" = "Darwin" ]; then
printf '\nNote: macOS will prompt for Accessibility permission on first mouse move.\n'
printf ' Grant it under System Settings > Privacy & Security > Accessibility.\n'
fi
# --- Final message -----------------------------------------------------------
printf '\nInstalled move %s at %s.\n' "$MOVE_VERSION" "$WRAPPER"
printf "Run 'move --help' to get started.\n"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env sh
#
# uninstall.sh - End-user uninstaller for the `move` CLI.
#
# Curl-pipe ready:
#
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/uninstall.sh | sh
#
# Removes the `move` wrapper from $XDG_BIN_HOME and the install tree from
# $XDG_DATA_HOME/move. Does NOT remove Bun — that's your runtime, not ours.
# Also leaves the user config file at $XDG_CONFIG_HOME/move/config.json
# intact (Unix convention; user customizations are not ours to delete).
# A notice is printed pointing at the file so you can remove it yourself
# if desired.
#
# Env vars (must match what install.sh used):
# XDG_DATA_HOME Source install root (default $HOME/.local/share).
# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin).
# XDG_CONFIG_HOME User config root (default $HOME/.config).
#
# POSIX sh; no bashisms.
set -eu
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move"
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/move"
CONFIG_FILE="$CONFIG_DIR/config.json"
WRAPPER="$BIN_DIR/move"
die() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
# Same safety guard as install.sh: refuse to wipe a directory that points
# somewhere catastrophic.
case "$INSTALL_DIR" in
'' | '/' | "$HOME" | "$HOME/" | '/move')
die "refusing to operate on INSTALL_DIR='$INSTALL_DIR' (too broad)"
;;
esac
REMOVED_SOMETHING=0
if [ -e "$WRAPPER" ] || [ -L "$WRAPPER" ]; then
rm -f "$WRAPPER"
printf 'Removed %s\n' "$WRAPPER"
REMOVED_SOMETHING=1
fi
if [ -d "$INSTALL_DIR" ]; then
rm -rf "$INSTALL_DIR"
printf 'Removed %s\n' "$INSTALL_DIR"
REMOVED_SOMETHING=1
fi
if [ "$REMOVED_SOMETHING" = "0" ]; then
printf 'Nothing to remove. Checked %s and %s.\n' "$WRAPPER" "$INSTALL_DIR"
if [ -e "$CONFIG_FILE" ]; then
printf 'Note: config file at %s was left in place.\n' "$CONFIG_FILE"
printf ' Remove it manually with: rm -rf %s\n' "$CONFIG_DIR"
fi
exit 0
fi
if [ -e "$CONFIG_FILE" ]; then
printf '\nNote: your config file at %s was left in place.\n' "$CONFIG_FILE"
printf ' Remove it manually with: rm -rf %s\n' "$CONFIG_DIR"
fi
printf '\nUninstalled. (Bun was not touched.)\n'
+80 -34
View File
@@ -9,14 +9,20 @@
*
* -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 (pixels).
* -V, --verbose Enable per-sweep / interrupt / bounds logging.
* -p, --pattern Movement strategy name (see strategies.ts).
* -V, --verbose Enable per-sweep / interrupt logging.
* (`-V` capital because `-v` is `--version`.)
* -l, --loop Loop mode: once triggered, keep moving
* until the user moves the mouse (or Ctrl+C).
*
* Numeric overrides are layered onto `DEFAULT_CONFIG` by
* Numeric overrides are layered (CLI > file > DEFAULT_CONFIG) by
* `resolveConfig` in `config.ts`; this module only parses and validates.
*/
@@ -25,35 +31,43 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { DEFAULT_CONFIG } from "./config.ts";
import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts";
import { CliError } from "./errors.ts";
import { PATTERN_NAMES, resolvePatternName } from "./strategies.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".
* 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; // pixels
verbose: boolean;
/** 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;
/**
* `true` when `-l`/`--loop` was passed; `undefined` when it was not.
* Same `undefined`-not-`false` rationale as `verbose`.
*/
loop: 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. Zero is rejected: every numeric tunable here is a
* duration or count where zero is meaningless or actively broken.
* 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;
@@ -64,13 +78,24 @@ function parsePositiveNumber(name: string, raw: string | undefined): number | un
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.
*
* Numeric flags are stored as `string` by `parseArgs` and then validated by
* `parsePositiveNumber`.
* `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>;
@@ -80,11 +105,14 @@ export function parseCliArgs(): ParsedCliArgs {
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" },
pattern: { type: "string", short: "p" },
verbose: { type: "boolean", short: "V" },
loop: { type: "boolean", short: "l" },
},
strict: true,
allowPositionals: false,
@@ -100,19 +128,22 @@ export function parseCliArgs(): ParsedCliArgs {
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),
verbose: Boolean(values.verbose),
pattern: parsePatternName(values.pattern as string | undefined),
verbose: values.verbose === true ? true : undefined,
loop: values.loop === 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.
* 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.
@@ -123,12 +154,14 @@ export const VERSION: string = (() => {
/**
* 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.
* `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]
@@ -138,16 +171,29 @@ 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 <pixels> Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}.
-V, --verbose Log every sweep, interrupt, and bounds event
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
One of: ${PATTERN_NAMES.join(", ")}.
Each pattern defines its own size and speed.
-V, --verbose Log every sweep and interrupt
(default prints only the startup banner).
-l, --loop Loop mode: once a sweep is triggered,
keep moving until you move the mouse (or
Ctrl+C), instead of firing a single sweep.
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 --pattern diagonal --loop
move --config ~/myprofile.json
`);
}
+175 -35
View File
@@ -1,20 +1,36 @@
/**
* config.ts
* ---------
* Runtime configuration types, defaults, and the CLI->config resolver.
* Runtime configuration types, defaults, the layered resolver, and the
* default config-file path.
*
* 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.
* The keeper is driven by a single `Config` object that carries every
* tunable it cares about, including the `verbose` flag. Defaults live in
* `DEFAULT_CONFIG`; CLI and config-file 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.
* All numeric `Config` fields are in their internal units (milliseconds).
* The CLI and config file expose 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.
*
* Layering precedence (highest wins):
* CLI overrides > file overrides > DEFAULT_CONFIG
*/
import { join } from "node:path";
import { CliError } from "./errors.ts";
import { isPatternName, type PatternName } from "./strategies.ts";
// Single source of truth for default values. The same file ships in the
// install tree and is copied to $XDG_CONFIG_HOME/move/config.json on a
// fresh install (only if no config exists there yet). Values use the CLI
// units (seconds for time fields, ms for stepDelay); the seconds->ms
// conversion happens below where DEFAULT_CONFIG is built.
import seedRaw from "../scripts/config.default.json" with { type: "json" };
/**
* The shape of a resolved runtime configuration. `readonly` to make
* accidental mutation a type error.
@@ -26,51 +42,175 @@
* - `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.
* - `pattern` — name of the movement strategy to use (see
* `strategies.ts`; e.g. `line`, `walk`, `arc`). Each
* pattern owns its own size and step count.
* - `verbose` — whether per-sweep / interrupt events are logged. The
* startup banner is always printed.
* - `loop` — loop mode: once a sweep is triggered, keep
* repeating the movement until the user moves the mouse
* (or Ctrl+C), rather than firing a single sweep. See
* `keeper.ts` for how the pattern is repeated.
*/
export interface Config {
readonly moveInterval: number;
readonly checkInterval: number;
readonly stepDelay: number;
readonly stepCount: number;
readonly pattern: PatternName;
readonly verbose: boolean;
readonly loop: boolean;
}
/**
* Built-in defaults used when the user did not supply an explicit CLI
* override for the corresponding flag.
* Shape of `scripts/config.default.json` after parsing. The cast below
* trusts the file's structure; `assertSeedShape` performs a small runtime
* sanity check at import time so a corrupted seed file fails loudly
* instead of silently producing `NaN` or `undefined` defaults.
*/
interface SeedShape {
moveInterval: number; // seconds
checkInterval: number; // seconds
stepDelay: number; // milliseconds
pattern: string; // strategy name
verbose: boolean;
loop: boolean;
}
function assertSeedShape(raw: unknown): asserts raw is SeedShape {
if (typeof raw !== "object" || raw === null) {
throw new Error("scripts/config.default.json: root must be an object");
}
const r = raw as Record<string, unknown>;
for (const key of ["moveInterval", "checkInterval", "stepDelay"] as const) {
const v = r[key];
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) {
throw new Error(`scripts/config.default.json: '${key}' must be a positive finite number (got ${JSON.stringify(v)})`);
}
}
if (typeof r.pattern !== "string" || !isPatternName(r.pattern)) {
throw new Error(`scripts/config.default.json: 'pattern' must be a known strategy name (got ${JSON.stringify(r.pattern)})`);
}
if (typeof r.verbose !== "boolean") {
throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`);
}
if (typeof r.loop !== "boolean") {
throw new Error(`scripts/config.default.json: 'loop' must be a boolean (got ${JSON.stringify(r.loop)})`);
}
}
assertSeedShape(seedRaw);
const seed: SeedShape = seedRaw;
/**
* Built-in defaults used when neither the CLI nor the config file supplies
* a value for a given field. Derived from `scripts/config.default.json`
* (the single source of truth); time-valued fields are converted from
* seconds to milliseconds here so the rest of the codebase works in
* internal units.
*/
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
moveInterval: seed.moveInterval * 1000,
checkInterval: seed.checkInterval * 1000,
stepDelay: seed.stepDelay,
pattern: seed.pattern,
verbose: seed.verbose,
loop: seed.loop,
};
/**
* 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.
* Common shape for override layers (CLI args and config-file content).
*
* `undefined` means "this layer doesn't supply a value"; the next layer
* down (file overrides, then DEFAULT_CONFIG) is consulted in that case.
*
* Numeric fields are in CLI / config-file units:
* moveInterval, checkInterval — seconds
* stepDelay — milliseconds
*
* `pattern` is a strategy name (`string | undefined`) and `verbose` is
* `boolean | undefined`, so every field shares the same "first defined
* value wins" precedence logic.
*
* For the CLI specifically, `verbose` is `undefined` when `-V/--verbose`
* was not passed and `true` when it was. There is no CLI off-switch
* today, so CLI `false` doesn't occur — a file-set `verbose: true` cannot
* be overridden back to false from the command line (see the Configuration
* section of the README). `loop` behaves identically: `-l/--loop` sets it
* `true`, and a file-set `loop: true` can't be switched off from the CLI.
*/
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
readonly moveInterval: number | undefined;
readonly checkInterval: number | undefined;
readonly stepDelay: number | undefined;
readonly pattern: string | undefined;
readonly verbose: boolean | undefined;
readonly loop: boolean | undefined;
}
/**
* 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.
* Resolve the default config-file path per the XDG Base Directory Spec.
* Honors `$XDG_CONFIG_HOME` if set; otherwise falls back to
* `$HOME/.config`. The file itself is always `move/config.json` under
* that base.
*
* Any field left `undefined` in the overrides falls back to the default.
* Computed at call time (not at module load) so tests can override
* `XDG_CONFIG_HOME` after import.
*/
export function resolveConfig(overrides: ConfigOverrides): Config {
export function defaultConfigPath(): string {
const xdg = process.env.XDG_CONFIG_HOME;
if (xdg && xdg.length > 0) {
return join(xdg, "move", "config.json");
}
const home = process.env.HOME;
if (!home || home.length === 0) {
// Neither var is set; we don't have a sensible fallback. Throwing
// CliError lets the entry-point's normal handler surface this as
// 'move: cannot resolve default config path: ...' + exit 2 instead
// of silently producing '/.config/move/config.json' and bewildering
// the user with a downstream 'file not found' message.
throw new CliError(
"cannot resolve default config path: neither $XDG_CONFIG_HOME nor $HOME is set",
);
}
return join(home, ".config", "move", "config.json");
}
/**
* Overlay file overrides (lowest priority) and CLI overrides (highest)
* on top of `DEFAULT_CONFIG` and return a resolved `Config`. Time-valued
* numeric inputs are in seconds; this is where they're converted to
* milliseconds for internal use.
*
* For each field, the first layer that supplies a defined value wins:
* CLI -> file -> DEFAULT_CONFIG
*/
export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides): Config {
const pickSeconds = (
cliVal: number | undefined,
fileVal: number | undefined,
fallbackMs: number,
): number => {
if (cliVal !== undefined) return cliVal * 1000;
if (fileVal !== undefined) return fileVal * 1000;
return fallbackMs;
};
const pickRaw = <T>(
cliVal: T | undefined,
fileVal: T | undefined,
fallback: T,
): T => {
if (cliVal !== undefined) return cliVal;
if (fileVal !== undefined) return fileVal;
return fallback;
};
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,
moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval),
checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval),
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
loop: pickRaw(cli.loop, file?.loop, DEFAULT_CONFIG.loop),
};
}
+183
View File
@@ -0,0 +1,183 @@
/**
* configFile.ts
* -------------
* JSON config file loading + strict validation.
*
* Default path: ${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json
*
* Schema (all keys optional; matching CLI flag names and units):
*
* moveInterval number seconds, positive
* checkInterval number seconds, positive
* stepDelay number milliseconds, positive
* pattern string a registered strategy name
* verbose boolean
* loop boolean
*
* Unknown keys, wrong types, and non-positive numerics are rejected with a
* `CliError` so the entry point can exit 2 (user error) with a clear
* message pointing at the offending file. The removed `stepCount` /
* `stepSize` keys are the exception: they're tolerated (ignored with a
* one-line notice) so an older seeded config keeps working after upgrade.
*
* Return semantics:
* - `null` when no `explicitPath` was passed and the default path does
* not exist. This is the "user has no config" happy path.
* - A `ConfigOverrides` when a file was found and validated.
* - Throws `CliError` if a problem is detected (missing explicit path,
* bad JSON, wrong shape, unknown keys, invalid values).
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { defaultConfigPath, type ConfigOverrides } from "./config.ts";
import { CliError } from "./errors.ts";
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
"moveInterval",
"checkInterval",
"stepDelay",
"pattern",
"verbose",
"loop",
]);
/**
* Keys that used to be valid but have since been removed. They're tolerated
* (not rejected like a genuine unknown key) so upgrading doesn't hard-fail a
* config that was seeded with them — every pre-1.3.0 install has `stepCount`
* in its file. They no longer do anything: sweep size and step count are now
* properties of each movement pattern. A one-line notice points the user at
* the file so they can remove them at leisure.
*/
const DEPRECATED_KEYS: ReadonlySet<string> = new Set<string>([
"stepCount",
"stepSize",
]);
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requirePositiveNumber(name: string, raw: unknown, path: string): number {
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
throw new CliError(
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (expected a positive number)`,
);
}
return raw;
}
function requireBoolean(name: string, raw: unknown, path: string): boolean {
if (typeof raw !== "boolean") {
throw new CliError(
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (expected a boolean)`,
);
}
return raw;
}
function requirePatternName(name: string, raw: unknown, path: string): string {
const canonical: string | null = typeof raw === "string" ? resolvePatternName(raw) : null;
if (canonical === null) {
throw new CliError(
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (valid: ${PATTERN_NAMES.join(", ")})`,
);
}
return canonical;
}
/**
* Load and validate the config file. See module docstring for return
* semantics.
*
* @param explicitPath - If provided (e.g., from `--config`), the file
* must exist and validate. If `undefined`, fall
* back to `defaultConfigPath()`; a missing default
* file is silent (returns `null`).
*/
export function loadConfigFile(explicitPath: string | undefined): ConfigOverrides | null {
const required: boolean = explicitPath !== undefined;
const path: string = explicitPath ?? defaultConfigPath();
if (!existsSync(path)) {
if (required) {
throw new CliError(`config file not found: ${path}`);
}
return null;
}
if (!statSync(path).isFile()) {
throw new CliError(`config path is not a regular file: ${path}`);
}
let raw: string;
try {
raw = readFileSync(path, "utf-8");
} catch (err: unknown) {
const msg: string = err instanceof Error ? err.message : String(err);
throw new CliError(`could not read config file ${path}: ${msg}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err: unknown) {
const msg: string = err instanceof Error ? err.message : String(err);
throw new CliError(`config file ${path} is not valid JSON: ${msg}`);
}
if (!isPlainObject(parsed)) {
throw new CliError(`config file ${path} must contain a JSON object at the root`);
}
// Strict mode: reject any key we don't know about (catches typos like
// 'movInterval'), but tolerate keys we've since removed — collect those
// and warn once, rather than hard-failing a config seeded by an older
// install.
const deprecatedFound: string[] = [];
for (const key of Object.keys(parsed)) {
if (ALLOWED_KEYS.has(key)) continue;
if (DEPRECATED_KEYS.has(key)) {
deprecatedFound.push(key);
continue;
}
const allowed: string = [...ALLOWED_KEYS].join(", ");
throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`);
}
if (deprecatedFound.length > 0) {
const names: string = deprecatedFound.map((k) => `'${k}'`).join(", ");
process.stderr.write(
`move: ignoring obsolete key(s) ${names} in ${path}\n` +
` (sweep size is now defined by each movement pattern)\n`,
);
}
return {
moveInterval:
"moveInterval" in parsed
? requirePositiveNumber("moveInterval", parsed.moveInterval, path)
: undefined,
checkInterval:
"checkInterval" in parsed
? requirePositiveNumber("checkInterval", parsed.checkInterval, path)
: undefined,
stepDelay:
"stepDelay" in parsed
? requirePositiveNumber("stepDelay", parsed.stepDelay, path)
: undefined,
pattern:
"pattern" in parsed
? requirePatternName("pattern", parsed.pattern, path)
: undefined,
verbose:
"verbose" in parsed
? requireBoolean("verbose", parsed.verbose, path)
: undefined,
loop:
"loop" in parsed
? requireBoolean("loop", parsed.loop, path)
: undefined,
};
}
+89
View File
@@ -0,0 +1,89 @@
/**
* device.ts
* ---------
* The I/O seam between the movement machinery and the outside world.
*
* Everything that actually touches `@nut-tree-fork/nut-js` lives here and
* nowhere else. The strategies (`strategies.ts`) and the execution driver
* (`executor.ts`) are written against the `Device` interface, which makes
* them pure and unit-testable without the nut.js native binary or a real
* screen — a fake `Device` is enough.
*
* `Point` is deliberately a plain `{ x, y }` structure rather than nut.js's
* `Point` class, so no module outside this one has to import nut.js just to
* describe a coordinate. `createNutDevice` converts to nut.js's `Point`
* when it commands the cursor.
*/
/**
* A screen coordinate in pixels. Plain data (not nut.js's `Point` class) so
* strategies, the executor, and tests never need a nut.js import.
*/
export interface Point {
readonly x: number;
readonly y: number;
}
/**
* The capabilities the movement machinery needs from the host system:
* read/write the cursor, learn the screen size, and wait.
*
* The production implementation (`createNutDevice`) is backed by nut.js;
* tests substitute a fake that records calls and returns scripted values.
*/
export interface Device {
/** Current cursor position. */
getPosition(): Promise<Point>;
/** Move the cursor to `p`. */
setPosition(p: Point): Promise<void>;
/** Current primary-screen width in pixels. */
width(): Promise<number>;
/** Current primary-screen height in pixels. */
height(): Promise<number>;
/** Resolve after `ms` milliseconds. */
sleep(ms: number): Promise<void>;
}
/**
* Promise-based `setTimeout`. Shared default sleep used by the nut.js
* device and available for reuse.
*
* @param ms - Duration to wait, in milliseconds.
*/
export const sleep = (ms: number): Promise<void> =>
new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, ms);
});
/**
* Build the production `Device` backed by nut.js.
*
* Importing nut.js dlopens a sizeable native `.node` binary, so this is a
* function (not a module-level singleton): callers that never move the
* mouse (`--help`, `--version`) never pay for it, and `move.ts` already
* defers the whole `keeper.ts` import behind those short-circuits.
*
* Side effect: sets `mouse.config.autoDelayMs = 0`. nut.js otherwise
* inserts a 100ms delay after every action, which — with two cursor calls
* per step — would silently more-than-double every sweep. We drive cadence
* ourselves via `stepDelay`, so the implicit delay is disabled here, at the
* single point where nut.js is actually wired up.
*/
export async function createNutDevice(): Promise<Device> {
const { mouse, Point: NutPoint, screen } = await import("@nut-tree-fork/nut-js");
mouse.config.autoDelayMs = 0;
return {
getPosition: async (): Promise<Point> => {
const p = await mouse.getPosition();
return { x: p.x, y: p.y };
},
setPosition: async (p: Point): Promise<void> => {
await mouse.setPosition(new NutPoint(p.x, p.y));
},
width: (): Promise<number> => screen.width(),
height: (): Promise<number> => screen.height(),
sleep,
};
}
+83
View File
@@ -0,0 +1,83 @@
/**
* editor.ts
* ---------
* `move --edit` support: open the active config file in `$EDITOR`.
*
* Refuses (CliError -> exit 2) when:
* - `$EDITOR` is unset or empty.
* - The target config file does not exist.
*
* Otherwise spawns `$EDITOR <path>` with the terminal attached, waits for
* it to exit, and propagates its exit code.
*
* Editor command parsing: `$EDITOR` is often a single word (`vim`,
* `nano`) but can include flags (`code --wait`, `emacs -nw`). We delegate
* to the shell so the value's own quoting / word-splitting Just Works:
*
* sh -c '<editor> "$@"' -- <path>
*
* The editor string is interpolated into the script body, so the shell
* tokenizes it normally (splitting `code --wait` into argv elements).
* The `--` placeholder takes the `$0` slot so `"$@"` is just our path.
* Same approach git uses to invoke `GIT_EDITOR`.
*
* Caveat: because `$EDITOR` is interpolated, shell metacharacters in its
* value WILL be interpreted (this matches git/vipe/most tools). That is a
* non-issue under the standard threat model — the user sets `$EDITOR`
* themselves — and would be impossible to handle differently without
* writing our own POSIX tokenizer.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { CliError } from "./errors.ts";
/**
* Pure helper that builds the argv we hand to the shell. Extracted so
* `editor.test.ts` can verify the construction without actually launching
* an editor.
*/
export function editorCommand(editor: string, path: string): readonly string[] {
// Editor is interpolated into the script body so the shell tokenizes
// multi-word values like 'code --wait'. The '--' takes the $0 slot;
// path becomes $1 / "$@".
return ["sh", "-c", `${editor} "$@"`, "--", path];
}
/**
* Launch `$EDITOR` on the given config path. Never returns: exits with the
* editor's status code (or 1 if it was killed by a signal).
*/
export function editConfig(path: string): never {
const editor = process.env.EDITOR;
if (editor === undefined || editor.length === 0) {
throw new CliError(
"$EDITOR is not set. Set it (e.g., 'export EDITOR=vim') and re-run.",
);
}
if (!existsSync(path)) {
throw new CliError(
`no config file at ${path}. Run 'move' once to start using defaults, or reinstall to re-seed the file.`,
);
}
// We build the argv via the pure helper, then unpack to satisfy
// spawnSync's (command, args, options) signature.
const argv: readonly string[] = editorCommand(editor, path);
const [command, ...args] = argv;
if (command === undefined) {
// Defensive: editorCommand always returns a non-empty array.
throw new CliError("internal: editor command construction produced an empty argv");
}
const result = spawnSync(command, args, { stdio: "inherit" });
if (result.error !== undefined) {
throw new CliError(`failed to launch $EDITOR: ${result.error.message}`);
}
// status is `number | null` (null when signal-killed). Exit 1 in the
// null case so the caller sees a non-zero, machine-readable status.
process.exit(result.status ?? 1);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* errors.ts
* ---------
* Cross-cutting error types used by parsers, loaders, and config resolution.
* Kept in its own module so feature modules can import error types without
* pulling in unrelated implementation code (e.g., the JSON loader doesn't
* need to depend on the CLI parser just to throw a typed error).
*/
/**
* Thrown when user-supplied input is invalid: unknown CLI option, missing
* value, non-positive number, malformed config file, unresolvable default
* path, etc. 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 {}
+197
View File
@@ -0,0 +1,197 @@
/**
* executor.ts
* -----------
* The single execution driver shared by every movement strategy.
*
* A strategy (`strategies.ts`) says *where* to go; this module owns
* *everything else* about carrying a sweep out against a `Device`:
*
* - round each ideal target to whole pixels,
* - keep it on-screen by reflecting coordinates that fall past an edge,
* - command the cursor and pace it with `stepDelay`,
* - detect real-user interruption after each step,
* - restore the cursor to the origin on a clean run.
*
* Writing this once means new patterns inherit correct real-user-wins,
* on-screen, and restore semantics for free. It's pure with respect to I/O —
* all side effects go through the injected `Device`, so it's unit-testable
* with a fake.
*
* Interrupt detection compares the re-read cursor against the *last
* commanded (rounded) point*, never the strategy's ideal (possibly
* fractional) target. That's what lets curved/stochastic patterns work
* without every rounded step being misread as "the user moved the mouse".
*/
import type { Config } from "./config.ts";
import type { Device, Point } from "./device.ts";
import type { MoveContext, MovementStrategy } from "./strategies.ts";
/**
* Minimal log surface used by the executor and the keeper loop.
*
* - `info(msg)` prints unconditionally (startup banner, fatal notes).
* - `event(msg)` prints only under `--verbose` / `verbose: true`.
*/
export interface Logger {
info(msg: string): void;
event(msg: string): void;
}
/**
* How a sweep ended:
* - `completed` — full path ran and the cursor was restored to start.
* - `interrupted` — real user activity detected mid-sweep; the sweep stopped
* without snapping back.
*/
export type SweepOutcome = "completed" | "interrupted";
/**
* Per-call knobs for `executePath`. All optional; the defaults reproduce the
* original single-sweep behavior exactly, so every existing caller and test
* is unaffected.
*
* - `restore` — restore the cursor to `ctx.start` after a clean sweep.
* Default `true`. Loop (`--loop`) mode passes `false`:
* chained cycles must not snap back between iterations, and an
* infinite `loopPath` never reaches the restore anyway.
* - `loop` — prefer the strategy's infinite `loopPath` when it defines
* one. Falls back to `path` when the strategy has no
* `loopPath`, so a plain chained-repeat caller can pass this
* unconditionally.
*/
export interface ExecuteOptions {
readonly restore?: boolean;
readonly loop?: boolean;
}
/**
* Slack, in pixels, allowed between the coordinate we commanded and the one
* we read back before calling it real-user activity. Absorbs the sub-pixel
* placement error the OS can introduce on scaled or multi-monitor setups; a
* genuine user movement is far larger than this.
*/
const READBACK_TOLERANCE: number = 2;
/**
* Pixels to inset the travel range from each screen edge. Keeps edge-seeking
* patterns off the literal first/last pixel, where DPI scaling and
* multi-monitor boundaries most often make the OS place the cursor a hair off
* what we commanded (which the readback check would then misread as the user).
*/
const EDGE_MARGIN: number = 2;
/**
* The inclusive `[lo, hi]` integer range an axis of length `max` may travel:
* `[0, max - 1]` inset by `EDGE_MARGIN` on each side. Screens too small to
* inset fall back to the full range so the math never inverts.
*/
function travelRange(max: number): { lo: number; hi: number } {
const hiEdge: number = max - 1;
if (hiEdge - 2 * EDGE_MARGIN < 1) return { lo: 0, hi: Math.max(0, hiEdge) };
return { lo: EDGE_MARGIN, hi: hiEdge - EDGE_MARGIN };
}
/**
* Mirror `v` into the inset travel range for `max` as a triangle wave, so
* values past an edge bounce back inside instead of running off it. This is
* the sole on-screen policy: a coordinate that overshoots an edge reflects
* back in, so a pattern keeps moving instead of parking against the boundary.
*/
function reflectInt(v: number, max: number): number {
const { lo, hi } = travelRange(max);
const span: number = hi - lo;
if (span <= 0) return lo;
const period: number = 2 * span;
const m: number = (((Math.round(v) - lo) % period) + period) % period;
return lo + (m <= span ? m : period - m);
}
/**
* Resolve a strategy's ideal (possibly fractional, possibly off-screen) target
* to an on-screen integer pixel by reflecting each axis into its travel range.
*/
function resolveTarget(p: Point, width: number, height: number): Point {
return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) };
}
/**
* Format the current local time as `HH:MM:SS` for log lines.
*/
function 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())}`;
}
/**
* Run one sweep: drive `strategy.path(ctx)` to completion (or early exit)
* against `device`.
*
* Contract, per step:
* 1. Resolve the ideal target to an on-screen integer by reflecting it
* into the travel range.
* 2. Command the cursor there and sleep `config.stepDelay` — also the
* user's interrupt window.
* 3. Re-read the cursor. If it isn't at the point we just commanded, the
* user moved it: return `interrupted` without restoring.
*
* On a clean run the cursor is restored to `ctx.start` so the next
* idle-check sees no net movement, and `completed` is returned — unless
* `options.restore === false` (loop mode), in which case the cursor is
* left where the last step put it.
*
* `options` (all optional, see `ExecuteOptions`) let loop mode reuse this
* same driver: `loop` selects the strategy's infinite `loopPath`, and
* `restore` suppresses the snap-back. Omitting `options` reproduces the
* original single-sweep contract exactly.
*
* `config` supplies only the pacing (`stepDelay`); a strategy's geometry is
* entirely self-contained, so the path itself needs nothing from it.
*/
export async function executePath(
strategy: MovementStrategy,
ctx: MoveContext,
device: Device,
log: Logger,
config: Config,
options?: ExecuteOptions,
): Promise<SweepOutcome> {
const { start, width, height } = ctx;
const path: Iterable<Point> =
options?.loop && strategy.loopPath ? strategy.loopPath(ctx) : strategy.path(ctx);
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
for (const target of path) {
const point: Point = resolveTarget(target, width, height);
await device.setPosition(point);
await device.sleep(config.stepDelay);
const current: Point = await device.getPosition();
if (
Math.abs(current.x - point.x) > READBACK_TOLERANCE ||
Math.abs(current.y - point.y) > READBACK_TOLERANCE
) {
// Cursor isn't where we last put it -> real user activity. Stop
// without snapping back, so we don't yank it from under the user.
//
// The comparison allows a small tolerance rather than demanding an
// exact match: on scaled (fractional-DPI) or multi-monitor setups
// the OS can place the cursor a pixel off the coordinate we
// commanded, and edge-seeking patterns reach exactly the
// coordinates where that's most likely. A real user moves far more
// than a couple of pixels, so this doesn't meaningfully weaken
// real-user-wins.
log.event(`User activity detected at ${timestamp()}; stopping simulation.`);
return "interrupted";
}
}
if (options?.restore !== false) {
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
log.event("Mouse moved.");
}
return "completed";
}
+94 -108
View File
@@ -1,59 +1,40 @@
/**
* keeper.ts
* ---------
* The actual "Teams Status Keeper" behavior: synthetic mouse activity with
* real-user-wins semantics, plus the idle-watch loop that drives it.
* The "Teams Status Keeper" behavior: the idle-watch loop plus the
* per-sweep glue that ties a movement strategy to the execution driver.
*
* 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.
* The mechanics are split across three seams so this file stays small and
* the interesting parts stay testable:
* - `device.ts` — the nut.js I/O boundary (injected here).
* - `strategies.ts` — pure "where to move" pattern generators.
* - `executor.ts` — the "how to move" driver (on-screen reflection,
* timing, interrupt detection, restore).
*
* `runKeeper` takes an optional `Device` so tests can drive the loop with a
* fake; production supplies the nut.js device. Importing this module is
* side-effect-free: nut.js isn't touched until `createNutDevice()` runs.
*
* 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).
* sees the process is alive.
* - Per-sweep / interrupt lines are gated by `config.verbose` (see
* `makeLogger`). Errors stay on `console.error`, raised by the entry
* point on unhandled rejection.
*/
import { mouse, Point, screen } from "@nut-tree-fork/nut-js";
import { createNutDevice, type Device, type Point } from "./device.ts";
import { executePath, type Logger, type SweepOutcome } from "./executor.ts";
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
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.
* Build a verbose-gated `Logger`. `info` is unconditional; `event` only
* fires when the caller asked for verbose output. Returning a small object
* keeps call sites free of `if (verbose)` noise at every log line.
*/
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 } {
function makeLogger(verbose: boolean): Logger {
return {
info: (msg: string): void => {
console.log(msg);
@@ -65,63 +46,57 @@ function makeLogger(verbose: boolean): { info(msg: string): void; event(msg: str
}
/**
* Perform a single synthetic mouse-activity sweep.
* Perform synthetic mouse activity once the keeper decides the cursor is
* idle.
*
* 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.
* Snapshots the screen (re-read every call so monitor changes are handled)
* and selects the configured strategy from the registry. An unknown
* `config.pattern` falls back to the default strategy defensively; validation
* at the CLI / config-file boundary should prevent that from ever happening.
*
* Single-sweep mode (`config.loop === false`) runs exactly one sweep via
* `executePath`, which owns on-screen reflection, pacing, interrupt
* detection, and restore-on-clean — unchanged from before loop mode existed.
*
* Loop mode (`config.loop === true`) keeps the cursor moving until the
* user moves the mouse (or Ctrl+C). The cursor is never restored between
* iterations (`restore: false`). Patterns that define an infinite `loopPath`
* (`line`, `diagonal`) run it once and are stopped only by interruption; the
* rest have their finite `path` chained, re-read from the cursor's current
* position each cycle. Per-cycle event logs are suppressed to avoid unbounded
* output — one line brackets the run at each end.
*/
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;
async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> {
const width: number = await device.width();
const height: number = await device.height();
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
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;
}
if (!config.loop) {
const start: Point = await device.getPosition();
const ctx: MoveContext = { start, width, height, rng: Math.random };
await executePath(strategy, ctx, device, log, config);
return;
}
await mouse.setPosition(start);
log.event("Mouse moved.");
log.event(`Loop mode (${strategy.name}); repeating until you move the mouse.`);
const cycleLog: Logger = { info: log.info, event: (): void => {} };
const loopOpts = { restore: false, loop: true };
let cycles = 0;
let outcome: SweepOutcome;
do {
const start: Point = await device.getPosition();
const ctx: MoveContext = { start, width, height, rng: Math.random };
outcome = await executePath(strategy, ctx, device, cycleLog, config, loopOpts);
cycles++;
// Spin guard for the chained-repeat path: a finite strategy that
// yielded nothing would otherwise return "completed" instantly in a
// tight loop. Sleeping one stepDelay makes that harmless. An infinite
// loopPath never returns "completed", so this branch is skipped there.
if (outcome === "completed") await device.sleep(config.stepDelay);
} while (outcome === "completed");
log.event(`Loop run ended after ${cycles} cycle(s): ${outcome}.`);
}
/**
@@ -139,21 +114,26 @@ async function simulateActivity(config: Config, log: ReturnType<typeof makeLogge
* 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.
* `simulateActivity` (via `executePath`) is designed so its own synthetic
* movement never counts as real activity: on a clean sweep it restores the
* cursor, and on a user-interrupted sweep the next iteration sees the
* user's new position and correctly resets the clock.
*
* @param config - Resolved runtime config.
* @param device - I/O device; defaults to the production nut.js device.
*/
export async function runKeeper(config: Config, verbose: boolean): Promise<void> {
const log = makeLogger(verbose);
export async function runKeeper(config: Config, device?: Device): Promise<void> {
const dev: Device = device ?? (await createNutDevice());
const log = makeLogger(config.verbose);
log.info("Teams Status Keeper started. Press Ctrl+C to stop.");
let lastPos: Point = await mouse.getPosition();
let lastPos: Point = await dev.getPosition();
let lastActivity: number = Date.now();
while (true) {
await sleep(config.checkInterval);
const pos: Point = await mouse.getPosition();
await dev.sleep(config.checkInterval);
const pos: Point = await dev.getPosition();
const now: number = Date.now();
if (pos.x !== lastPos.x || pos.y !== lastPos.y) {
@@ -164,12 +144,18 @@ export async function runKeeper(config: Config, verbose: boolean): Promise<void>
}
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.
await simulateActivity(config, log, dev);
// The sweep either restored the cursor to its start (clean) or
// left it where the user moved it (interrupt). Either way, reset
// the clock and require another full moveInterval of inactivity
// before firing again.
lastActivity = Date.now();
// Re-sync lastPos to where the cursor actually ended. After a
// clean sweep this is a no-op (it was restored to start). After
// an interrupt it snaps lastPos to the user's position, so the
// next poll doesn't re-read that same displacement and count it a
// second time as fresh activity.
lastPos = await dev.getPosition();
}
}
}
+109 -27
View File
@@ -4,18 +4,29 @@
* -------
* 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.
* Thin shim that ties the logic modules together:
* - `cli.ts` parses and validates `process.argv`.
* - `configFile.ts` loads and validates the JSON config file.
* - `config.ts` holds defaults and the layered `resolveConfig` overlay.
* - `editor.ts` backs `--edit` (open the config file in `$EDITOR`).
* - `keeper.ts` owns the idle-watch loop and drives the movement
* machinery (device / strategy / executor).
*
* 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.
* 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
* 2. `--help` / `--version` short-circuit before any I/O, config load, or
* mouse work. `keeper.ts` is also lazy-imported (see below) so these
* flags don't pay the cost of loading the nut.js native binary.
* 3. `--edit` opens the resolved config file in `$EDITOR` and is a
* terminal action (propagates the editor's exit code).
* 4. Load + validate the config file (default XDG path, or `--config
* <path>` if supplied). Validation failures share the exit-2 path.
* 5. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose
* lives inside `Config` and is layered with the same precedence as
* the numeric fields.
* 6. Lazy-import `keeper.ts` (dynamic import keeps nut.js out of the
* `--help` / `--version` startup path) and run it. Any unhandled
* rejection — from the import itself or from the loop — exits 1.
*
* 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`.
@@ -23,20 +34,61 @@
import { parseCliArgs, printHelp, VERSION } from "./cli.ts";
import type { ParsedCliArgs } from "./cli.ts";
import { resolveConfig } from "./config.ts";
import { runKeeper } from "./keeper.ts";
import { defaultConfigPath, resolveConfig } from "./config.ts";
import type { ConfigOverrides } from "./config.ts";
import { loadConfigFile } from "./configFile.ts";
import { editConfig } from "./editor.ts";
let cliArgs: ParsedCliArgs;
try {
cliArgs = parseCliArgs();
} catch (err: unknown) {
// `keeper.ts` is intentionally NOT statically imported here. It transitively
// pulls in `@nut-tree-fork/nut-js`, which in turn dlopens a sizeable native
// `.node` binary. On a cold first run that load dominates startup (~1 s on
// macOS). For `--help` and `--version` we never actually need nut.js, so we
// defer the import to the only branch that actually runs the keeper loop.
// See the dynamic `await import("./keeper.ts")` near the bottom of the file.
/**
* Print a user-error message and exit 2. Used for anything that comes from
* invalid input: unknown CLI flags, bad numbers, malformed or missing
* config files, unresolvable default paths. The accompanying "Try 'move
* --help'" pointer is appropriate for these cases.
*
* Returns `never` so callers can invoke it without TypeScript flagging
* "variable might be undefined" downstream.
*/
function failUser(err: unknown): never {
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);
}
/**
* Print a runtime-failure message and exit 1. Used for anything the user
* couldn't have prevented from the command line: nut.js errors, missing
* Accessibility permission on macOS, unexpected exceptions from the
* keeper loop. Prefers the stack trace when available since these failures
* usually need a developer to interpret.
*/
function failRuntime(err: unknown): never {
const detail: string = err instanceof Error ? (err.stack ?? err.message) : String(err);
process.stderr.write(`move: runtime error: ${detail}\n`);
process.exit(1);
}
let cliArgs: ParsedCliArgs;
try {
cliArgs = parseCliArgs();
} catch (err: unknown) {
failUser(err);
}
if (cliArgs.help) {
printHelp();
// printHelp() resolves defaultConfigPath(), which can throw CliError
// when neither $XDG_CONFIG_HOME nor $HOME is set.
try {
printHelp();
} catch (err: unknown) {
failUser(err);
}
process.exit(0);
}
if (cliArgs.version) {
@@ -44,14 +96,44 @@ if (cliArgs.version) {
process.exit(0);
}
const config = resolveConfig({
moveInterval: cliArgs.moveInterval,
checkInterval: cliArgs.checkInterval,
stepDelay: cliArgs.stepDelay,
stepCount: cliArgs.stepCount,
});
if (cliArgs.edit) {
// Edit is a fully terminal action: open the config file in $EDITOR and
// hand the user's terminal over. Path resolution mirrors loadConfigFile's:
// honor `--config <path>` if set, else use the XDG default.
try {
const path: string = cliArgs.config ?? defaultConfigPath();
editConfig(path);
} catch (err: unknown) {
failUser(err);
}
}
runKeeper(config, cliArgs.verbose).catch((err: unknown): void => {
console.error("Error:", err);
process.exit(1);
});
let fileOverrides: ConfigOverrides | null;
try {
fileOverrides = loadConfigFile(cliArgs.config);
} catch (err: unknown) {
failUser(err);
}
const cliOverrides: ConfigOverrides = {
moveInterval: cliArgs.moveInterval,
checkInterval: cliArgs.checkInterval,
stepDelay: cliArgs.stepDelay,
pattern: cliArgs.pattern,
verbose: cliArgs.verbose,
loop: cliArgs.loop,
};
const config = resolveConfig(fileOverrides, cliOverrides);
// Dynamic import so nut.js and the rest of the keeper machinery aren't
// loaded for invocations that exit early (--help, --version, validation
// failures). The `try` covers import-time failures too (e.g., a missing
// nut.js native binary), routing them through the same runtime-failure
// path as anything raised by the loop itself.
try {
const { runKeeper } = await import("./keeper.ts");
runKeeper(config).catch(failRuntime);
} catch (err: unknown) {
failRuntime(err);
}
+330
View File
@@ -0,0 +1,330 @@
/**
* strategies.ts
* -------------
* The movement-pattern seam: pure generators of cursor targets.
*
* A `MovementStrategy` describes *where* the cursor should go, as an
* iterable of ideal `Point`s starting from the sweep's origin. It performs
* no I/O, no timing, and no interrupt handling — that all belongs to the
* executor (`executor.ts`). This split is what makes patterns trivial to
* add (write one pure generator) and trivial to test (feed a deterministic
* `rng`, assert the emitted points).
*
* Coordinates emitted here may be fractional and may fall past a screen
* edge; the executor rounds to whole pixels and reflects any out-of-range
* coordinate back inside, so a pattern bounces off the edges and keeps
* moving. Strategies never need to bound their own output.
*
* Each pattern owns its own geometry — how many steps it takes, how far it
* reaches, how tight its radius is — as module-private constants below. Those
* are properties of the pattern, not user preferences: a jitter is inherently
* small and twitchy, an arc inherently a broad curve. There is deliberately
* no user knob for sweep size or step count; the cadence (`stepDelay`) is the
* only tunable, and it lives in the executor, not here. As a result this
* module needs nothing from `Config` and imports only `Point`.
*/
import type { Point } from "./device.ts";
/**
* Everything a strategy needs to generate a path. Screen dimensions and the
* start point are snapshotted per sweep by the caller; `rng` is injected so
* stochastic strategies are deterministic under test.
*/
export interface MoveContext {
/** Cursor position at the start of the sweep. */
readonly start: Point;
/** Primary-screen width in pixels. */
readonly width: number;
/** Primary-screen height in pixels. */
readonly height: number;
/** Uniform [0, 1) source. Defaults to `Math.random`; tests inject a fake. */
readonly rng: () => number;
}
/**
* A named movement pattern.
*
* - `name` — registry key, also the value accepted by `--pattern` / the
* `pattern` config key.
* - `path` — pure generator of ideal (possibly fractional) targets,
* emitted in visiting order. Should not re-emit `start`.
* - `loopPath` — optional infinite variant for loop mode (`--loop`).
* A pattern defines it when its finite `path` doesn't chain
* cleanly under repetition: `line`/`diagonal` re-derive their
* direction from the cursor's position every cycle, so chained
* repetition oscillates in a band near an edge instead of
* crossing the screen. An infinite generator picks its
* direction once and ramps forever; the executor reflects the
* monotonic ramp into an edge-to-edge bounce. Absent this,
* loop mode simply chains `path` — correct for patterns whose
* finite path is a self-contained cyclic unit (`jitter`,
* `walk`, `arc`, `figureEight`). The executor stops either
* kind on real user activity; an infinite `loopPath` therefore
* only ever ends by interruption.
*/
export interface MovementStrategy {
readonly name: string;
path(ctx: MoveContext): Iterable<Point>;
loopPath?(ctx: MoveContext): Iterable<Point>;
}
/**
* Clamp `v` into the inclusive pixel range `[0, max - 1]`. This is a geometry
* helper for `arc` (choosing a well-formed on-screen endpoint and control
* point), NOT an on-screen bounds policy — the executor keeps every commanded
* point on-screen by reflecting, uniformly for all patterns.
*/
function clamp(v: number, max: number): number {
if (v < 0) return 0;
if (v > max - 1) return max - 1;
return v;
}
/**
* `line` — the original behavior, preserved exactly.
*
* Pick a horizontal direction that keeps the sweep on-screen (right if
* there's room, else left) and walk `LINE_STEPS` single-pixel steps with no
* vertical movement. 250 one-pixel steps is byte-for-byte the sweep the
* keeper produced before movement patterns existed. The direction choice
* keeps the finite sweep on-screen, so the executor's reflection never
* actually engages for it.
*
* In loop mode `loopPath` ramps x in one direction forever; the direction
* never matters because the executor reflects the ramp edge to edge.
* `LINE_LOOP_STEP` is several pixels per step rather than one so a screen
* crossing takes seconds, not minutes, at the default cadence.
*/
const LINE_STEPS = 250;
const LINE_LOOP_STEP = 4;
export const line: MovementStrategy = {
name: "line",
*path(ctx: MoveContext): Generator<Point> {
const { start, width } = ctx;
const dx: number = start.x + LINE_STEPS < width ? 1 : -1;
for (let i = 1; i <= LINE_STEPS; i++) {
yield { x: start.x + i * dx, y: start.y };
}
},
*loopPath(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
let x: number = start.x;
for (;;) {
x += LINE_LOOP_STEP;
yield { x, y: start.y };
}
},
};
/**
* `diagonal` — straight line on both axes at once. Each axis's direction is
* chosen independently by available room, so the sweep heads toward the
* roomiest corner and stays on-screen. 250 single-pixel steps per axis
* (≈250px reach), matching `line`'s magnitude.
*
* In loop mode `loopPath` ramps both axes forever, and the executor reflects
* them. Because the x and y travel ranges have different spans, their
* triangle waves have different periods, so the path precesses across the
* whole screen — the roaming-DVD bounce — rather than retracing one 45° line.
*/
const DIAGONAL_STEPS = 250;
const DIAGONAL_LOOP_STEP = 4;
export const diagonal: MovementStrategy = {
name: "diagonal",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height } = ctx;
const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1;
const dy: number = start.y + DIAGONAL_STEPS < height ? 1 : -1;
for (let i = 1; i <= DIAGONAL_STEPS; i++) {
yield { x: start.x + i * dx, y: start.y + i * dy };
}
},
*loopPath(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
let x: number = start.x;
let y: number = start.y;
for (;;) {
x += DIAGONAL_LOOP_STEP;
y += DIAGONAL_LOOP_STEP;
yield { x, y };
}
},
};
/**
* `jitter` — many small random hops within a tight radius of the start.
* Subtle "fidget" activity rather than a broad sweep. The radius is large
* enough that every hop is a real, distinct pixel move rather than rounding
* onto the pixel the cursor already occupies. The executor restores the
* cursor to `start` after a clean run, so the net displacement is zero.
*/
const JITTER_STEPS = 80;
const JITTER_RADIUS = 30;
export const jitter: MovementStrategy = {
name: "jitter",
*path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx;
for (let i = 1; i <= JITTER_STEPS; i++) {
const angle: number = rng() * 2 * Math.PI;
const r: number = rng() * JITTER_RADIUS;
yield { x: start.x + Math.cos(angle) * r, y: start.y + Math.sin(angle) * r };
}
},
};
/**
* `walk` — an unbounded cumulative random walk: each step adds a random
* per-axis delta in `[-WALK_STEP, +WALK_STEP]`. The per-step magnitude is
* deliberately several pixels so the walk actually roams — a ±1px walk over
* this many steps would drift only ~√N pixels net. The generator lets the
* position drift freely; the executor mirrors it back on-screen, so the
* cursor bounces off the edges instead of escaping.
*/
const WALK_STEPS = 200;
const WALK_STEP = 4;
export const walk: MovementStrategy = {
name: "walk",
*path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx;
let x: number = start.x;
let y: number = start.y;
for (let i = 1; i <= WALK_STEPS; i++) {
x += (rng() * 2 - 1) * WALK_STEP;
y += (rng() * 2 - 1) * WALK_STEP;
yield { x, y };
}
},
};
/**
* `arc` — a smooth quadratic Bézier curve from the start to a random
* on-screen endpoint `ARC_REACH` pixels away, bowed out by a control point
* offset perpendicular to the straight path. `ARC_STEPS` samples keep the
* curve smooth. Produces natural, hand-like curved motion.
*/
const ARC_STEPS = 120;
const ARC_REACH = 300;
export const arc: MovementStrategy = {
name: "arc",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height, rng } = ctx;
// Endpoint: a random direction, `ARC_REACH` away, clamped on-screen.
const angle: number = rng() * 2 * Math.PI;
const endX: number = clamp(start.x + Math.cos(angle) * ARC_REACH, width);
const endY: number = clamp(start.y + Math.sin(angle) * ARC_REACH, height);
// Control point: midpoint pushed along the perpendicular so the path
// bows rather than running straight. Direction/magnitude randomized.
const midX: number = (start.x + endX) / 2;
const midY: number = (start.y + endY) / 2;
const perpX: number = -(endY - start.y);
const perpY: number = endX - start.x;
const perpLen: number = Math.hypot(perpX, perpY) || 1;
const bow: number = (rng() * 2 - 1) * ARC_REACH * 0.5;
const ctrlX: number = clamp(midX + (perpX / perpLen) * bow, width);
const ctrlY: number = clamp(midY + (perpY / perpLen) * bow, height);
for (let i = 1; i <= ARC_STEPS; i++) {
const t: number = i / ARC_STEPS;
const u: number = 1 - t;
yield {
x: u * u * start.x + 2 * u * t * ctrlX + t * t * endX,
y: u * u * start.y + 2 * u * t * ctrlY + t * t * endY,
};
}
},
};
/**
* `figureEight` — traces a Gerono lemniscate (a figure-eight) around the
* start point over one full period, so it returns to the origin.
* `FIG8_AMP` sets its half-width (≈250px across); `FIG8_STEPS` samples keep
* the curve smooth.
*/
const FIG8_STEPS = 90;
const FIG8_AMP = 125;
export const figureEight: MovementStrategy = {
name: "figureEight",
*path(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
for (let i = 1; i <= FIG8_STEPS; i++) {
const t: number = (2 * Math.PI * i) / FIG8_STEPS;
yield {
x: start.x + FIG8_AMP * Math.sin(t),
y: start.y + FIG8_AMP * Math.sin(t) * Math.cos(t),
};
}
},
};
/**
* The registry of every selectable movement pattern, keyed by name. Adding
* a strategy is a one-line addition here plus its definition above.
*/
export const STRATEGIES: Readonly<Record<string, MovementStrategy>> = {
line,
diagonal,
jitter,
walk,
arc,
figureEight,
};
/** Pattern used when neither the CLI nor the config file selects one. */
export const DEFAULT_PATTERN = "line";
/** All valid pattern names, for validation messages and help text. */
export const PATTERN_NAMES: readonly string[] = Object.keys(STRATEGIES);
/**
* The set of valid `--pattern` / `pattern` values as a string-literal-ish
* type. Kept as `string` at the type level (the registry is the runtime
* source of truth); `isPatternName` is the guard callers use.
*/
export type PatternName = string;
/** True when `name` is an exact, registered strategy key. */
export function isPatternName(name: string): boolean {
return Object.prototype.hasOwnProperty.call(STRATEGIES, name);
}
/**
* Normalize a pattern name for lenient user-facing matching: lowercase and
* strip separators (`-`, `_`, whitespace) so `figure-eight`, `figure_eight`,
* and `FIGUREEIGHT` all collapse onto the same key as `figureEight`.
*/
const normalizePattern = (s: string): string => s.toLowerCase().replace(/[-_\s]/g, "");
/**
* Map of normalized name -> canonical registry key. Built once at module
* load. The assertion below guards against two registered names collapsing
* to the same normalized form (e.g. a future `"figure_eight"` alongside
* `"figureEight"`), which would otherwise let one silently shadow the other.
*/
const CANONICAL_PATTERNS: ReadonlyMap<string, string> = new Map(
PATTERN_NAMES.map((n) => [normalizePattern(n), n]),
);
if (CANONICAL_PATTERNS.size !== PATTERN_NAMES.length) {
throw new Error(
"strategies.ts: two pattern names collide after normalization; rename one so they differ by more than case/separators",
);
}
/**
* Resolve loose user input to the canonical registry key, or `null` when no
* registered strategy matches. Used at the CLI and config-file validation
* boundaries so `Config.pattern` is always a canonical key and the keeper's
* direct `STRATEGIES[pattern]` lookup needs no normalization of its own.
*/
export function resolvePatternName(name: string): string | null {
return CANONICAL_PATTERNS.get(normalizePattern(name)) ?? null;
}
+144
View File
@@ -0,0 +1,144 @@
/**
* config.test.ts
* --------------
* Unit tests for the layered config resolver and the default-path helper.
* Run via `bun test` (or `bun run test`).
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { DEFAULT_CONFIG, defaultConfigPath, resolveConfig } from "../src/config.ts";
import type { ConfigOverrides } from "../src/config.ts";
import { CliError } from "../src/errors.ts";
const NONE: ConfigOverrides = {
moveInterval: undefined,
checkInterval: undefined,
stepDelay: undefined,
pattern: undefined,
verbose: undefined,
loop: undefined,
};
describe("resolveConfig", () => {
test("returns DEFAULT_CONFIG when neither layer supplies a value", () => {
expect(resolveConfig(null, NONE)).toEqual(DEFAULT_CONFIG);
});
test("CLI value wins over file value", () => {
const file: ConfigOverrides = { ...NONE, moveInterval: 60 };
const cli: ConfigOverrides = { ...NONE, moveInterval: 30 };
const cfg = resolveConfig(file, cli);
expect(cfg.moveInterval).toBe(30 * 1000); // CLI 30s -> 30000ms
});
test("file value wins over default when CLI is undefined", () => {
const file: ConfigOverrides = { ...NONE, moveInterval: 60 };
const cfg = resolveConfig(file, NONE);
expect(cfg.moveInterval).toBe(60 * 1000); // file 60s -> 60000ms
});
test("seconds-to-ms conversion at the boundary for time-valued fields", () => {
const cli: ConfigOverrides = { ...NONE, moveInterval: 5, checkInterval: 2 };
const cfg = resolveConfig(null, cli);
expect(cfg.moveInterval).toBe(5000);
expect(cfg.checkInterval).toBe(2000);
});
test("stepDelay passes through untouched (no unit conversion)", () => {
const cli: ConfigOverrides = { ...NONE, stepDelay: 75 };
const cfg = resolveConfig(null, cli);
expect(cfg.stepDelay).toBe(75);
});
test("pattern: CLI wins over file, file wins over default", () => {
expect(resolveConfig({ ...NONE, pattern: "arc" }, { ...NONE, pattern: "walk" }).pattern).toBe("walk");
expect(resolveConfig({ ...NONE, pattern: "arc" }, NONE).pattern).toBe("arc");
expect(resolveConfig(null, NONE).pattern).toBe(DEFAULT_CONFIG.pattern);
});
test("verbose: CLI true wins over file false", () => {
const cfg = resolveConfig(
{ ...NONE, verbose: false },
{ ...NONE, verbose: true },
);
expect(cfg.verbose).toBe(true);
});
test("verbose: file true wins over default (no CLI)", () => {
const cfg = resolveConfig({ ...NONE, verbose: true }, NONE);
expect(cfg.verbose).toBe(true);
});
test("verbose: file false wins over default (no CLI)", () => {
const cfg = resolveConfig({ ...NONE, verbose: false }, NONE);
expect(cfg.verbose).toBe(false);
});
test("verbose: falls back to DEFAULT_CONFIG.verbose when neither set", () => {
const cfg = resolveConfig(null, NONE);
expect(cfg.verbose).toBe(DEFAULT_CONFIG.verbose);
});
test("loop: CLI true wins over file false", () => {
const cfg = resolveConfig({ ...NONE, loop: false }, { ...NONE, loop: true });
expect(cfg.loop).toBe(true);
});
test("loop: file true wins over default (no CLI)", () => {
const cfg = resolveConfig({ ...NONE, loop: true }, NONE);
expect(cfg.loop).toBe(true);
});
test("loop: falls back to DEFAULT_CONFIG.loop when neither set", () => {
const cfg = resolveConfig(null, NONE);
expect(cfg.loop).toBe(DEFAULT_CONFIG.loop);
});
});
describe("defaultConfigPath", () => {
let savedXdg: string | undefined;
let savedHome: string | undefined;
beforeEach(() => {
savedXdg = process.env.XDG_CONFIG_HOME;
savedHome = process.env.HOME;
});
afterEach(() => {
if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = savedXdg;
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
});
test("honors XDG_CONFIG_HOME when set", () => {
process.env.XDG_CONFIG_HOME = "/custom/xdg";
process.env.HOME = "/should/not/be/used";
expect(defaultConfigPath()).toBe("/custom/xdg/move/config.json");
});
test("falls back to $HOME/.config when XDG_CONFIG_HOME is unset", () => {
delete process.env.XDG_CONFIG_HOME;
process.env.HOME = "/u/test";
expect(defaultConfigPath()).toBe("/u/test/.config/move/config.json");
});
test("treats empty XDG_CONFIG_HOME as unset (per XDG spec)", () => {
process.env.XDG_CONFIG_HOME = "";
process.env.HOME = "/u/test";
expect(defaultConfigPath()).toBe("/u/test/.config/move/config.json");
});
test("throws CliError when both XDG_CONFIG_HOME and HOME are unset", () => {
delete process.env.XDG_CONFIG_HOME;
delete process.env.HOME;
expect(() => defaultConfigPath()).toThrow(CliError);
});
test("throws CliError when both XDG_CONFIG_HOME and HOME are empty", () => {
process.env.XDG_CONFIG_HOME = "";
process.env.HOME = "";
expect(() => defaultConfigPath()).toThrow(CliError);
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* configFile.test.ts
* ------------------
* Unit tests for the JSON config-file loader.
* Run via `bun test` (or `bun run test`).
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadConfigFile } from "../src/configFile.ts";
import { CliError } from "../src/errors.ts";
let TMP: string;
beforeAll(() => {
TMP = mkdtempSync(join(tmpdir(), "move-cfg-test-"));
});
afterAll(() => {
rmSync(TMP, { recursive: true, force: true });
});
function writeFixture(name: string, body: string): string {
const p = join(TMP, name);
writeFileSync(p, body);
return p;
}
describe("loadConfigFile (explicit path)", () => {
test("returns parsed overrides for a valid file", () => {
const path = writeFixture(
"valid.json",
JSON.stringify({ moveInterval: 60, verbose: true }),
);
const result = loadConfigFile(path);
expect(result).not.toBeNull();
// The bang is justified by the not-null assertion above.
expect(result!.moveInterval).toBe(60);
expect(result!.verbose).toBe(true);
// Fields not in the file are undefined.
expect(result!.checkInterval).toBeUndefined();
expect(result!.stepDelay).toBeUndefined();
expect(result!.pattern).toBeUndefined();
});
test("returns all-undefined overrides for an empty object", () => {
const path = writeFixture("empty.json", "{}");
const result = loadConfigFile(path);
expect(result).not.toBeNull();
expect(result!.moveInterval).toBeUndefined();
expect(result!.verbose).toBeUndefined();
});
test("throws CliError when explicit path does not exist", () => {
expect(() => loadConfigFile(join(TMP, "missing.json"))).toThrow(CliError);
});
test("throws on malformed JSON, mentioning the file path", () => {
const path = writeFixture("bad-json.json", "this is not json");
expect(() => loadConfigFile(path)).toThrow(/is not valid JSON/);
expect(() => loadConfigFile(path)).toThrow(new RegExp(path.replace(/[.]/g, "\\.")));
});
test("throws when root is not an object (e.g. array)", () => {
const path = writeFixture("array.json", "[1, 2, 3]");
expect(() => loadConfigFile(path)).toThrow(/JSON object at the root/);
});
test("throws when root is not an object (e.g. string)", () => {
const path = writeFixture("string.json", "\"hello\"");
expect(() => loadConfigFile(path)).toThrow(/JSON object at the root/);
});
test("throws on an unknown key, naming the typo and the allowed set", () => {
const path = writeFixture("typo.json", JSON.stringify({ movInterval: 60 }));
expect(() => loadConfigFile(path)).toThrow(/unknown key 'movInterval'/);
expect(() => loadConfigFile(path)).toThrow(/moveInterval/);
});
test("throws on non-positive numeric values", () => {
const negative = writeFixture("neg.json", JSON.stringify({ moveInterval: -1 }));
expect(() => loadConfigFile(negative)).toThrow(/'moveInterval'.*positive number/);
const zero = writeFixture("zero.json", JSON.stringify({ stepDelay: 0 }));
expect(() => loadConfigFile(zero)).toThrow(/'stepDelay'.*positive number/);
});
test("throws when a numeric field has the wrong type", () => {
const path = writeFixture("type.json", JSON.stringify({ moveInterval: "60" }));
expect(() => loadConfigFile(path)).toThrow(/'moveInterval'.*positive number/);
});
test("throws when verbose is the wrong type", () => {
const path = writeFixture("verbose.json", JSON.stringify({ verbose: "yes" }));
expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/);
});
test("accepts a boolean loop", () => {
const path = writeFixture("loop.json", JSON.stringify({ loop: true }));
const result = loadConfigFile(path);
expect(result!.loop).toBe(true);
});
test("throws when loop is the wrong type", () => {
const path = writeFixture("loop-bad.json", JSON.stringify({ loop: "yes" }));
expect(() => loadConfigFile(path)).toThrow(/'loop'.*boolean/);
});
test("accepts a known pattern", () => {
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" }));
const result = loadConfigFile(path);
expect(result!.pattern).toBe("arc");
});
test("normalizes a loosely-spelled pattern to its canonical name", () => {
const path = writeFixture("loosepattern.json", JSON.stringify({ pattern: "figure-eight" }));
const result = loadConfigFile(path);
expect(result!.pattern).toBe("figureEight");
});
test("throws on an unknown pattern, listing the valid names", () => {
const path = writeFixture("badpattern.json", JSON.stringify({ pattern: "zigzag" }));
expect(() => loadConfigFile(path)).toThrow(/'pattern'.*valid:/);
expect(() => loadConfigFile(path)).toThrow(/line/);
});
test("tolerates obsolete stepCount/stepSize keys, ignoring their values", () => {
// Seeded by pre-1.3.0 installs; must not hard-fail on upgrade. They're
// accepted but not surfaced as overrides (and even an invalid value,
// like a negative, is ignored rather than rejected).
const path = writeFixture(
"obsolete.json",
JSON.stringify({ moveInterval: 60, stepCount: -1, stepSize: 3 }),
);
const result = loadConfigFile(path);
expect(result).not.toBeNull();
expect(result!.moveInterval).toBe(60);
expect(result as unknown as Record<string, unknown>).not.toHaveProperty("stepCount");
});
test("still rejects a genuinely unknown key", () => {
const path = writeFixture("unknown.json", JSON.stringify({ movInterval: 60 }));
expect(() => loadConfigFile(path)).toThrow(/unknown key 'movInterval'/);
});
});
describe("loadConfigFile (default path)", () => {
let savedXdg: string | undefined;
beforeAll(() => {
savedXdg = process.env.XDG_CONFIG_HOME;
// Point the default path under the test tmpdir so a missing file is
// guaranteed (we never create $TMP/move/config.json).
process.env.XDG_CONFIG_HOME = TMP;
});
afterAll(() => {
if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = savedXdg;
});
test("returns null when no file exists at the default path", () => {
expect(loadConfigFile(undefined)).toBeNull();
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* editor.test.ts
* --------------
* Unit tests for the `--edit` helper. The spawn path is not exercised
* (would actually launch $EDITOR); instead we test:
* - the pure argv-construction helper, and
* - the two refusal paths ($EDITOR unset, file missing).
*
* Run via `bun test`.
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { editConfig, editorCommand } from "../src/editor.ts";
import { CliError } from "../src/errors.ts";
describe("editorCommand", () => {
test("builds 'sh -c <editor> \"$@\"' argv with -- placeholder and path", () => {
const argv = editorCommand("vim", "/tmp/x.json");
expect(argv).toEqual(["sh", "-c", 'vim "$@"', "--", "/tmp/x.json"]);
});
test("interpolates the editor verbatim so shell word-splits multi-word values", () => {
const argv = editorCommand("code --wait", "/path with space.json");
expect(argv).toEqual([
"sh",
"-c",
'code --wait "$@"',
"--",
"/path with space.json",
]);
});
});
describe("editConfig", () => {
let TMP: string;
let savedEditor: string | undefined;
beforeAll(() => {
TMP = mkdtempSync(join(tmpdir(), "move-edit-test-"));
});
afterAll(() => {
rmSync(TMP, { recursive: true, force: true });
});
beforeEach(() => {
savedEditor = process.env.EDITOR;
});
afterEach(() => {
if (savedEditor === undefined) delete process.env.EDITOR;
else process.env.EDITOR = savedEditor;
});
test("throws CliError when $EDITOR is unset", () => {
delete process.env.EDITOR;
expect(() => editConfig(join(TMP, "any.json"))).toThrow(CliError);
});
test("throws CliError when $EDITOR is empty", () => {
process.env.EDITOR = "";
expect(() => editConfig(join(TMP, "any.json"))).toThrow(CliError);
});
test("throws CliError when the config file does not exist", () => {
// Use a benign editor command that we never actually reach (the
// existence check fires first).
process.env.EDITOR = "true";
const missing = join(TMP, "no-such-file.json");
expect(() => editConfig(missing)).toThrow(/no config file at/);
});
test("error message names the missing path", () => {
process.env.EDITOR = "true";
const missing = join(TMP, "missing.json");
expect(() => editConfig(missing)).toThrow(new RegExp(missing.replace(/[.]/g, "\\.")));
});
test("error message mentions 'reinstall' as a recovery hint", () => {
process.env.EDITOR = "true";
expect(() => editConfig(join(TMP, "x.json"))).toThrow(/reinstall/);
});
test("$EDITOR unset error explicitly mentions setting it", () => {
delete process.env.EDITOR;
expect(() => editConfig(join(TMP, "x.json"))).toThrow(/export EDITOR/);
});
// Success path: $EDITOR set, file exists. The editor IS spawned and we
// then call process.exit() — which kills the test process. So we don't
// exercise this code path in unit tests; the manual smoke test in
// dev-setup verifies end-to-end behavior instead.
test("placeholder: success path is verified via manual `EDITOR=true move -e` run", () => {
// Intentionally empty assertion. See comment above.
expect(true).toBe(true);
// Ensure the fixture path is referenced so this test isn't seen
// as truly empty if the fixture system ever needs assertion.
writeFileSync(join(TMP, "exists.json"), "{}");
});
});
+260
View File
@@ -0,0 +1,260 @@
/**
* executor.test.ts
* ----------------
* Unit tests for the execution driver against a fake `Device`. Covers the
* two sweep outcomes, on-screen reflection, the rounding/interrupt contract,
* step pacing, and the loop/restore options — none of which was testable
* before the device seam existed.
*/
import { describe, expect, test } from "bun:test";
import { DEFAULT_CONFIG } from "../src/config.ts";
import type { Config } from "../src/config.ts";
import type { Device, Point } from "../src/device.ts";
import { executePath, type Logger } from "../src/executor.ts";
import type { MoveContext, MovementStrategy } from "../src/strategies.ts";
const noopLog: Logger = { info: (): void => {}, event: (): void => {} };
/**
* A scriptable `Device`. `getPosition` echoes the last commanded point
* (simulating "the cursor stayed where we put it") unless `overrides` maps
* the current getPosition call index to a substitute — used to inject a
* mid-sweep user interruption.
*/
class FakeDevice implements Device {
commanded: Point[] = [];
sleeps: number[] = [];
getCalls = 0;
overrides = new Map<number, Point>();
constructor(public w = 1920, public h = 1080, public initial: Point = { x: 0, y: 0 }) {}
async getPosition(): Promise<Point> {
this.getCalls++;
const o = this.overrides.get(this.getCalls);
if (o) return o;
return this.commanded.at(-1) ?? this.initial;
}
async setPosition(p: Point): Promise<void> {
this.commanded.push(p);
}
async width(): Promise<number> {
return this.w;
}
async height(): Promise<number> {
return this.h;
}
async sleep(ms: number): Promise<void> {
this.sleeps.push(ms);
}
}
/** A strategy that emits a fixed list of points. */
function fixed(points: Point[]): MovementStrategy {
return {
name: "fixed",
*path(): Generator<Point> {
yield* points;
},
};
}
function ctxOf(start: Point, width: number, height: number): MoveContext {
return { start, width, height, rng: Math.random };
}
/** A full `Config` for the executor's pacing; only `stepDelay` matters here. */
function cfgOf(config?: Partial<Config>): Config {
return { ...DEFAULT_CONFIG, ...config };
}
describe("executePath — outcomes", () => {
test("clean sweep commands every point, restores to start, returns 'completed'", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [
{ x: 501, y: 500 },
{ x: 502, y: 500 },
{ x: 503, y: 500 },
];
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
// 3 steps + 1 restore.
expect(dev.commanded).toEqual([...pts, start]);
});
test("interruption mid-sweep returns 'interrupted' and does NOT restore", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [
{ x: 501, y: 500 },
{ x: 502, y: 500 },
{ x: 503, y: 500 },
];
// 2nd getPosition call reports the user elsewhere.
dev.overrides.set(2, { x: 9, y: 9 });
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("interrupted");
// Commanded points 1 and 2 only; never restored to start.
expect(dev.commanded).toEqual([pts[0]!, pts[1]!]);
expect(dev.commanded.at(-1)).not.toEqual(start);
});
});
describe("executePath — on-screen reflection", () => {
test("mirrors an out-of-range coordinate back inside the inset range", async () => {
const dev = new FakeDevice(100, 100);
// Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74.
const pts = [{ x: 120, y: 50 }];
await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
});
test("negative and far-past-edge coordinates both fold inside", async () => {
const dev = new FakeDevice(100, 100);
// Inset [2, 97]. x=-5 -> reflects to 9; x=99 -> 95 (period 190).
const pts = [
{ x: -5, y: 50 },
{ x: 99, y: 50 },
];
await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
for (const p of dev.commanded.slice(0, 2)) {
expect(p.x).toBeGreaterThanOrEqual(2);
expect(p.x).toBeLessThanOrEqual(97);
}
});
test("a monotonic ramp past an edge keeps moving — never two identical points in a row", async () => {
// This is the guarantee that motivated removing `clamp`: a clamp would
// pin every over-the-edge point to the same edge pixel, stalling the
// cursor. Reflection folds the ramp into a triangle wave, so the cursor
// both rises and falls and never repeats a pixel step to step.
const dev = new FakeDevice(40, 40);
// Ramp x well past the right edge and back's worth of travel.
const pts = Array.from({ length: 60 }, (_, i) => ({ x: 10 + i, y: 20 }));
await executePath(fixed(pts), ctxOf({ x: 10, y: 20 }, 40, 40), dev, noopLog, cfgOf({ stepDelay: 0 }));
const xs = dev.commanded.slice(0, 60).map((p) => p.x);
// No stall: consecutive commanded points always differ.
for (let i = 1; i < xs.length; i++) {
expect(xs[i]).not.toBe(xs[i - 1]);
}
// It bounced: the ramp both increased and decreased at some point.
const rose = xs.some((x, i) => i > 0 && x > xs[i - 1]!);
const fell = xs.some((x, i) => i > 0 && x < xs[i - 1]!);
expect(rose && fell).toBe(true);
});
});
describe("executePath — options", () => {
test("restore:false leaves the cursor at the last step, no snap-back", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [
{ x: 501, y: 500 },
{ x: 502, y: 500 },
];
const outcome = await executePath(
fixed(pts),
ctxOf(start, dev.w, dev.h),
dev,
noopLog,
cfgOf(),
{ restore: false },
);
expect(outcome).toBe("completed");
// No trailing restore-to-start command.
expect(dev.commanded).toEqual(pts);
});
test("the default (no options) still restores to start", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [{ x: 501, y: 500 }];
await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(dev.commanded).toEqual([...pts, start]);
});
test("loop:true runs loopPath when present, path otherwise", async () => {
const dev = new FakeDevice();
// A strategy whose loopPath differs from its path, both finite here.
const strat: MovementStrategy = {
name: "dual",
*path(): Generator<Point> {
yield { x: 1, y: 1 };
},
*loopPath(): Generator<Point> {
yield { x: 10, y: 10 };
yield { x: 20, y: 20 };
},
};
await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), {
loop: true,
restore: false,
});
expect(dev.commanded).toEqual([{ x: 10, y: 10 }, { x: 20, y: 20 }]);
});
test("loop:true falls back to path when the strategy has no loopPath", async () => {
const dev = new FakeDevice();
const strat = fixed([{ x: 3, y: 3 }]);
await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), {
loop: true,
restore: false,
});
expect(dev.commanded).toEqual([{ x: 3, y: 3 }]);
});
});
describe("executePath — readback tolerance", () => {
test("a readback within tolerance is not treated as interruption", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [
{ x: 510, y: 500 },
{ x: 520, y: 500 },
];
// Each in-sweep readback lands 2px off the commanded point (OS jitter,
// not the user). 2px is within READBACK_TOLERANCE, so the sweep runs on.
dev.overrides.set(1, { x: 512, y: 501 });
dev.overrides.set(2, { x: 518, y: 499 });
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
expect(dev.commanded).toEqual([...pts, start]);
});
test("a readback beyond tolerance is treated as interruption", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [
{ x: 510, y: 500 },
{ x: 520, y: 500 },
];
// First readback is 3px off -> exceeds the 2px tolerance -> real user.
dev.overrides.set(1, { x: 513, y: 500 });
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("interrupted");
expect(dev.commanded).toEqual([pts[0]!]);
});
});
describe("executePath — rounding & pacing", () => {
test("fractional targets are rounded and do not read as interruption", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [{ x: 10.4, y: 20.6 }]; // -> (10, 21)
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
expect(dev.commanded[0]).toEqual({ x: 10, y: 21 });
});
test("sleeps once per step with the configured stepDelay", async () => {
const dev = new FakeDevice();
const pts = [
{ x: 501, y: 500 },
{ x: 502, y: 500 },
];
await executePath(fixed(pts), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 }));
expect(dev.sleeps).toEqual([7, 7]);
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* keeper.test.ts
* --------------
* Loop-level tests for `runKeeper` driven by a fake `Device`. The loop runs
* forever in production, so the fake stops it by throwing a sentinel from
* `sleep` once a call budget is exhausted; the test then inspects the
* commands that were issued.
*
* These assert the two behaviors that matter: an idle cursor triggers a
* synthetic sweep, and a moving cursor never does.
*/
import { describe, expect, test } from "bun:test";
import { DEFAULT_CONFIG } from "../src/config.ts";
import type { Config } from "../src/config.ts";
import type { Device, Point } from "../src/device.ts";
import { runKeeper } from "../src/keeper.ts";
class StopError extends Error {}
/**
* Fake device that echoes the last commanded point (so a synthetic sweep
* completes cleanly) and aborts the loop after `budget` sleeps.
*
* `positions`, when provided, is consumed one entry per `getPosition` call
* to simulate real user movement; otherwise the cursor is reported as
* stationary at `initial`/the last commanded point (idle).
*/
class LoopDevice implements Device {
commanded: Point[] = [];
sleepCount = 0;
constructor(
public budget: number,
public initial: Point = { x: 100, y: 100 },
private positions: Point[] | null = null,
) {}
async getPosition(): Promise<Point> {
if (this.positions) return this.positions.shift() ?? this.initial;
return this.commanded.at(-1) ?? this.initial;
}
async setPosition(p: Point): Promise<void> {
this.commanded.push(p);
}
async width(): Promise<number> {
return 1920;
}
async height(): Promise<number> {
return 1080;
}
async sleep(): Promise<void> {
if (++this.sleepCount > this.budget) throw new StopError();
}
}
const quietConfig = (overrides: Partial<Config>): Config => ({
...DEFAULT_CONFIG,
verbose: false,
...overrides,
});
async function runUntilStop(config: Config, device: Device): Promise<void> {
try {
await runKeeper(config, device);
} catch (err) {
if (!(err instanceof StopError)) throw err;
}
}
describe("runKeeper", () => {
test("fires a synthetic sweep once the cursor has been idle long enough", async () => {
// moveInterval 0 => any elapsed time counts as "idle long enough",
// so the first idle check triggers a sweep deterministically.
const dev = new LoopDevice(50);
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
// A sweep issued setPosition commands (the sweep is interrupted by the
// sleep budget before it finishes, but many steps land); an idle loop
// with no sweep would have issued none.
expect(dev.commanded.length).toBeGreaterThanOrEqual(3);
});
test("does not fire while the cursor keeps moving", async () => {
// Every poll reports a new position => always "real activity", so the
// idleness clock keeps resetting and no sweep ever fires.
const moving: Point[] = Array.from({ length: 40 }, (_, i) => ({ x: i, y: i }));
const dev = new LoopDevice(20, { x: 0, y: 0 }, moving);
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
expect(dev.commanded.length).toBe(0);
});
});
describe("runKeeper — loop mode", () => {
const maxX = (pts: Point[]): number => pts.reduce((m, p) => Math.max(m, p.x), -Infinity);
test("loop mode ramps far from the start via the infinite loopPath", async () => {
// `line`'s loopPath ramps x by 4px/step from the start and never
// restores, reflecting off the screen edge. From x=100 it climbs well
// past a single finite sweep's reach before the budget stops it.
const dev = new LoopDevice(400, { x: 100, y: 100 });
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: true }), dev);
expect(maxX(dev.commanded)).toBeGreaterThan(1000);
});
test("single-sweep mode restores each sweep, so x never ramps away", async () => {
// Same setup without loop: `line` runs 250 one-pixel steps then snaps
// back to the start, so x is bounded by start + 250 no matter how many
// sweeps fire within the budget.
const dev = new LoopDevice(400, { x: 100, y: 100 });
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: false }), dev);
expect(maxX(dev.commanded)).toBeLessThanOrEqual(350);
});
test("loop mode chains a finite pattern across multiple cycles per trigger", async () => {
// `figureEight` has no loopPath, so loop mode chains its 90-step path.
// A single trigger keeps chaining cycles until the budget stops it,
// yielding far more than the 90 commands one cycle would.
const dev = new LoopDevice(400, { x: 800, y: 500 });
await runUntilStop(
quietConfig({ moveInterval: 0, pattern: "figureEight", loop: true }),
dev,
);
expect(dev.commanded.length).toBeGreaterThan(180);
});
});
+189
View File
@@ -0,0 +1,189 @@
/**
* strategies.test.ts
* ------------------
* Unit tests for the pure movement-pattern generators. No nut.js, no
* device: each strategy is exercised by feeding a `MoveContext` (with a
* deterministic `rng` where randomness matters) and asserting on the
* emitted points.
*/
import { describe, expect, test } from "bun:test";
import type { Point } from "../src/device.ts";
import {
arc,
diagonal,
figureEight,
isPatternName,
jitter,
line,
PATTERN_NAMES,
resolvePatternName,
STRATEGIES,
walk,
type MoveContext,
} from "../src/strategies.ts";
/** Deterministic PRNG so stochastic strategies are reproducible under test. */
function mulberry32(seed: number): () => number {
let a = seed;
return (): number => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Pull the first `n` points from a (possibly infinite) point iterable. */
function take(iter: Iterable<Point>, n: number): Point[] {
const out: Point[] = [];
for (const p of iter) {
out.push(p);
if (out.length >= n) break;
}
return out;
}
function ctxOf(overrides: {
start?: Point;
width?: number;
height?: number;
rng?: () => number;
}): MoveContext {
return {
start: overrides.start ?? { x: 500, y: 500 },
width: overrides.width ?? 1920,
height: overrides.height ?? 1080,
rng: overrides.rng ?? Math.random,
};
}
describe("line", () => {
test("emits its full 250-step, 250px sweep along +x with no vertical drift (preserved default)", () => {
const pts = [...line.path(ctxOf({ start: { x: 500, y: 500 } }))];
expect(pts.length).toBe(250);
expect(pts.every((p) => p.y === 500)).toBe(true);
// 1px per step: 501..750.
expect(pts[0]!.x).toBe(501);
expect(pts.at(-1)!.x).toBe(750);
});
test("reverses direction when there is no room to the right", () => {
const pts = [...line.path(ctxOf({ start: { x: 90, y: 10 }, width: 100 }))];
expect(pts[0]!.x).toBe(89);
// Heads left: each step decreases x by 1.
expect(pts[1]!.x).toBe(88);
expect(pts.at(-1)!.x).toBe(90 - 250);
});
test("loopPath ramps x forever at a fixed step, y held constant", () => {
const start = { x: 500, y: 300 };
const pts = take(line.loopPath!(ctxOf({ start })), 5);
// Monotonic +4 per step (LINE_LOOP_STEP), no vertical drift.
expect(pts.map((p) => p.x)).toEqual([504, 508, 512, 516, 520]);
expect(pts.every((p) => p.y === 300)).toBe(true);
});
});
describe("diagonal", () => {
test("moves 1px on both axes toward the roomy corner for 250 steps", () => {
const pts = [...diagonal.path(ctxOf({ start: { x: 500, y: 500 } }))];
expect(pts.length).toBe(250);
expect(pts[0]!).toEqual({ x: 501, y: 501 });
expect(pts.at(-1)!).toEqual({ x: 750, y: 750 });
});
test("loopPath ramps both axes forever at a fixed step", () => {
const pts = take(diagonal.loopPath!(ctxOf({ start: { x: 100, y: 200 } })), 3);
// Both axes advance by DIAGONAL_LOOP_STEP (4) each step.
expect(pts).toEqual([
{ x: 104, y: 204 },
{ x: 108, y: 208 },
{ x: 112, y: 212 },
]);
});
});
describe("jitter", () => {
test("stays within its fixed radius of start across its fixed step count", () => {
const radius = 30; // JITTER_RADIUS
const start = { x: 500, y: 500 };
const pts = [...jitter.path(ctxOf({ start, rng: mulberry32(1) }))];
expect(pts.length).toBe(80); // JITTER_STEPS
for (const p of pts) {
expect(Math.hypot(p.x - start.x, p.y - start.y)).toBeLessThanOrEqual(radius + 1e-9);
}
});
});
describe("walk", () => {
test("is a cumulative walk; a 0.5-constant rng yields zero net drift", () => {
const start = { x: 400, y: 300 };
const pts = [...walk.path(ctxOf({ start, rng: () => 0.5 }))];
expect(pts.length).toBe(200); // WALK_STEPS
// (0.5*2 - 1) === 0, so every step delta is zero.
expect(pts.every((p) => p.x === start.x && p.y === start.y)).toBe(true);
});
test("accumulates finite deltas step over step", () => {
const pts = [...walk.path(ctxOf({ rng: mulberry32(42) }))];
expect(pts.length).toBe(200);
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
});
});
describe("arc", () => {
test("emits its fixed step count of finite points, deterministic under a fixed seed", () => {
const pts = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
expect(pts.length).toBe(120); // ARC_STEPS
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
// Same seed -> same endpoint (t = 1 at the final step is a stable point).
const again = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
expect(pts.at(-1)).toEqual(again.at(-1)!);
});
});
describe("figureEight", () => {
test("returns to the start point after one full period", () => {
const start = { x: 600, y: 400 };
const pts = [...figureEight.path(ctxOf({ start }))];
expect(pts.length).toBe(90); // FIG8_STEPS
expect(pts.at(-1)!.x).toBeCloseTo(start.x, 6);
expect(pts.at(-1)!.y).toBeCloseTo(start.y, 6);
});
});
describe("registry", () => {
test("PATTERN_NAMES matches the registry keys and includes the default", () => {
expect(new Set(PATTERN_NAMES)).toEqual(new Set(Object.keys(STRATEGIES)));
expect(PATTERN_NAMES).toContain("line");
});
test("isPatternName accepts registered names and rejects others", () => {
for (const name of PATTERN_NAMES) expect(isPatternName(name)).toBe(true);
expect(isPatternName("zigzag")).toBe(false);
expect(isPatternName("")).toBe(false);
// Must not be fooled by inherited Object.prototype members.
expect(isPatternName("toString")).toBe(false);
});
test("resolvePatternName maps every canonical name to itself", () => {
for (const name of PATTERN_NAMES) expect(resolvePatternName(name)).toBe(name);
});
test("resolvePatternName normalizes case and separators", () => {
expect(resolvePatternName("figure-eight")).toBe("figureEight");
expect(resolvePatternName("figure_eight")).toBe("figureEight");
expect(resolvePatternName("FIGUREEIGHT")).toBe("figureEight");
expect(resolvePatternName(" Figure Eight ")).toBe("figureEight");
expect(resolvePatternName("LINE")).toBe("line");
});
test("resolvePatternName returns null for unknown or prototype names", () => {
expect(resolvePatternName("zigzag")).toBeNull();
expect(resolvePatternName("")).toBeNull();
expect(resolvePatternName("toString")).toBeNull();
});
});
+5 -2
View File
@@ -8,7 +8,10 @@
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"noUncheckedIndexedAccess": true,
"types": ["bun"]
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "tests/**/*.ts"]
}