Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
819cc5a5fb | ||
|
|
ec33648e74 | ||
|
|
db3310c247 | ||
|
|
7777b16540 | ||
|
|
cff1c482a3 | ||
|
|
10dcc1791a | ||
|
|
cc7a487aca | ||
|
|
10172f11b0 | ||
|
|
add4a2d60c | ||
|
|
fec35a2333 | ||
|
|
9617758d8b | ||
|
|
94d963d8ef | ||
|
|
1a857de5ed | ||
|
|
5af253283e | ||
|
|
7120c06bbe | ||
|
|
d5656efe5b | ||
|
|
8eac51cd45 | ||
|
|
7714a6ad1a | ||
|
|
940113019d | ||
|
|
ccc136f727 | ||
|
|
df9305c6ac | ||
|
|
c9645b374c | ||
|
|
b43d5aad2d | ||
|
|
081f94088d |
+121
@@ -0,0 +1,121 @@
|
|||||||
|
# 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.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.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
|
||||||
@@ -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.
|
|
||||||
@@ -11,7 +11,7 @@ cursor leaves the position the script just commanded.
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- [Bun](https://bun.sh) >= 1.0.0
|
- [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
|
[`@nut-tree-fork/nut-js`](https://github.com/nut-tree-fork/nut.js) for
|
||||||
cross-platform mouse + screen control)
|
cross-platform mouse + screen control)
|
||||||
- On macOS: Accessibility permission for the terminal running Bun
|
- On macOS: Accessibility permission for the terminal running Bun
|
||||||
@@ -20,38 +20,62 @@ cursor leaves the position the script just commanded.
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```sh
|
```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
|
This fetches the latest `master` from Gitea, runs `bun install --production`
|
||||||
not auto-install Bun; if Bun is missing it prints a hard error pointing at
|
under the install dir, and drops a `move` wrapper on your bin dir.
|
||||||
<https://bun.sh>.
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Env vars (all optional):
|
||||||
|
|
||||||
|
| Var | Default | Purpose |
|
||||||
|
| --- | ------- | ------- |
|
||||||
|
| `MOVE_VERSION` | `master` | Branch or tag to install. Pin with e.g. `v1.0.0`. |
|
||||||
|
| `MOVE_FORCE` | unset | Set to `1` to reinstall when the same version is already present. |
|
||||||
|
| `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. |
|
||||||
|
|
||||||
|
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
|
## Run
|
||||||
|
|
||||||
```sh
|
```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
|
||||||
|
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
|
Stop with `Ctrl+C`. If `SIGINT` arrives mid-sweep, the cursor stays at
|
||||||
whichever step was last commanded — by design.
|
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
|
## Usage
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -60,17 +84,24 @@ Usage: move [options]
|
|||||||
Options:
|
Options:
|
||||||
-h, --help Show this help and exit.
|
-h, --help Show this help and exit.
|
||||||
-v, --version Print version 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.
|
-m, --move-interval <seconds> Idle time before a sweep fires. Default: 240.
|
||||||
-c, --check-interval <seconds> Cursor poll cadence. Default: 10.
|
-c, --check-interval <seconds> Cursor poll cadence. Default: 10.
|
||||||
-d, --step-delay <ms> Pause between synthetic steps. Default: 50.
|
-d, --step-delay <ms> Pause between synthetic steps. Default: 50.
|
||||||
-n, --step-count <pixels> Steps per sweep. Default: 250.
|
-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, interrupt, and bounds event
|
-V, --verbose Log every sweep, interrupt, and bounds event
|
||||||
(default prints only the startup banner).
|
(default prints only the startup banner).
|
||||||
|
|
||||||
|
Precedence (highest wins): CLI flags > config file > built-in defaults.
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `bun run src/move.ts --help` for the full text.
|
Run `move --help` for the resolved default config-file path on your
|
||||||
|
system. Numeric overrides are layered onto the defaults via
|
||||||
All flags are wired. Numeric overrides are layered onto the defaults via
|
|
||||||
`resolveConfig` in `src/config.ts`; time-valued inputs (`-m`, `-c`) are
|
`resolveConfig` in `src/config.ts`; time-valued inputs (`-m`, `-c`) are
|
||||||
expressed in seconds at the CLI boundary and converted to milliseconds
|
expressed in seconds at the CLI boundary and converted to milliseconds
|
||||||
internally.
|
internally.
|
||||||
@@ -83,18 +114,126 @@ out-of-bounds events.
|
|||||||
Invalid input (unknown flag, missing value, non-positive number) prints an
|
Invalid input (unknown flag, missing value, non-positive number) prints an
|
||||||
error to `stderr` and exits with code `2`.
|
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**. Existing configs — yours
|
||||||
|
or from a previous install — are never overwritten. 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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` is a boolean.
|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
Any validation failure prints a message naming the file and the offending
|
||||||
|
key to `stderr` and exits `2`.
|
||||||
|
|
||||||
|
### Known limitation: `verbose` can be turned on but not off from the CLI
|
||||||
|
|
||||||
|
`--verbose` is a presence-only flag (there is no `--no-verbose`). If the
|
||||||
|
config file sets `"verbose": true`, the CLI cannot force quiet mode in
|
||||||
|
that invocation. Workarounds: edit the file, or point at a different
|
||||||
|
file with `--config`.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
The source lives under `src/`, split into an entry point plus three logic
|
The source lives under `src/`, split into an entry point plus logic
|
||||||
modules:
|
modules:
|
||||||
|
|
||||||
- `src/move.ts` is a thin entry point: parses args, dispatches `--help` /
|
- `src/move.ts` is a thin entry point: parses args, dispatches `--help` /
|
||||||
`--version`, resolves the runtime config, and calls
|
`--version`, loads the config file, resolves the layered runtime
|
||||||
`runKeeper(config, verbose)`.
|
config, and calls `runKeeper(config)`.
|
||||||
- `src/cli.ts` owns argument parsing, validation, and help/version output.
|
- `src/cli.ts` owns argument parsing, validation, and help/version output.
|
||||||
- `src/config.ts` exports the `Config` type, `DEFAULT_CONFIG`, and the
|
- `src/configFile.ts` owns optional JSON config-file loading + strict
|
||||||
`resolveConfig` overlay function.
|
schema validation.
|
||||||
- `src/keeper.ts` owns the synthetic-activity sweep and the idle-watch loop.
|
- `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,
|
||||||
|
applies the strategy's bounds policy, paces steps, detects real-user
|
||||||
|
interruption, and restores the cursor on a clean sweep.
|
||||||
|
|
||||||
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
|
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
|
||||||
|
|
||||||
@@ -103,7 +242,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. |
|
| `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. |
|
| `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. |
|
| `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
|
`-m` and `-c` are accepted in seconds at the CLI; `resolveConfig` converts
|
||||||
to milliseconds before handing the resolved `Config` to `runKeeper`.
|
to milliseconds before handing the resolved `Config` to `runKeeper`.
|
||||||
@@ -118,43 +257,111 @@ to milliseconds before handing the resolved `Config` to `runKeeper`.
|
|||||||
- Otherwise, if `now - lastActivity >= config.moveInterval`, call
|
- Otherwise, if `now - lastActivity >= config.moveInterval`, call
|
||||||
`simulateActivity` and reset the idleness clock.
|
`simulateActivity` and reset the idleness clock.
|
||||||
|
|
||||||
### Synthetic sweep (`simulateActivity`)
|
### Synthetic sweep (`simulateActivity` + `executePath`)
|
||||||
|
|
||||||
1. Read the starting position and current screen dimensions.
|
1. `simulateActivity` snapshots the starting position and current screen
|
||||||
2. Pick a horizontal direction (`dx = +1` if there's room to the right,
|
dimensions (re-read every sweep so monitor changes are handled), looks
|
||||||
else `-1`) so the sweep stays on-screen. Vertical is `dy = 0` for now.
|
up `config.pattern` in the strategy registry, and builds a `MoveContext`.
|
||||||
3. For each of `config.stepCount` steps:
|
2. It hands the strategy and context to `executePath`, which drives the
|
||||||
- Compute and bounds-check the next target.
|
sweep. For each target the strategy yields:
|
||||||
|
- Round to whole pixels and apply the strategy's bounds policy
|
||||||
|
(`abort` / `clamp` / `reflect`) to keep it on-screen.
|
||||||
- Move the cursor there, sleep `config.stepDelay`.
|
- Move the cursor there, sleep `config.stepDelay`.
|
||||||
- Re-read the cursor. If it isn't where we put it, the user moved it —
|
- Re-read the cursor. If it isn't at the point we *just commanded*, the
|
||||||
log (when `--verbose`) and return early without snapping back.
|
user moved it — log (when `--verbose`) and return early without
|
||||||
4. On a clean full sweep, restore the cursor to its starting position so
|
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
|
the next idle-check sees "no movement" and doesn't misread the synthetic
|
||||||
activity as real user input.
|
activity as real user input.
|
||||||
|
|
||||||
|
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 `clamp`/`reflect`
|
||||||
|
patterns stay 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. `line` uses the `abort` policy and is unaffected.
|
||||||
|
|
||||||
|
### Movement strategies
|
||||||
|
|
||||||
|
`config.pattern` selects one of the generators in `src/strategies.ts`:
|
||||||
|
|
||||||
|
| Name | Motion | Steps | Size | Bounds |
|
||||||
|
| ------------- | ------------------------------------------------------------- | ----- | -------- | --------- |
|
||||||
|
| `line` | Straight horizontal sweep (the original behavior). | 250 | 250px | `abort` |
|
||||||
|
| `diagonal` | Straight line on both axes toward the roomiest corner. | 250 | 250px/axis | `clamp` |
|
||||||
|
| `jitter` | Small random hops within a tight radius of the start. | 80 | 30px radius | `clamp` |
|
||||||
|
| `walk` | Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step | `reflect` |
|
||||||
|
| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px | `clamp` |
|
||||||
|
| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide | `clamp` |
|
||||||
|
|
||||||
|
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 bounds, pacing, interrupt,
|
||||||
|
and restore for free.
|
||||||
|
|
||||||
### Why `mouse.config.autoDelayMs = 0`
|
### Why `mouse.config.autoDelayMs = 0`
|
||||||
|
|
||||||
nut.js inserts a 100ms delay after every action by default. With two mouse
|
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
|
calls per step that would silently more-than-double the duration of a
|
||||||
sweep. The script controls cadence itself via `config.stepDelay`, so the
|
sweep. The code controls cadence itself via `config.stepDelay`, so the
|
||||||
implicit delay is disabled at module load (a side effect of importing
|
implicit delay is disabled in `createNutDevice` — the single place nut.js
|
||||||
`keeper.ts`).
|
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
|
## Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
| ------------------- | ----------------------------------------------------------------------- |
|
| ------------------- | ----------------------------------------------------------------------------- |
|
||||||
| `src/move.ts` | CLI entry point: parses args, dispatches help/version, starts the loop. |
|
| `scripts/install.sh` | End-user installer; curl-pipeable from Gitea. |
|
||||||
| `src/cli.ts` | Argument parsing, validation, and help/version output. |
|
| `scripts/uninstall.sh` | End-user uninstaller; curl-pipeable from Gitea. |
|
||||||
| `src/config.ts` | `Config` type, `DEFAULT_CONFIG`, and `resolveConfig` overlay. |
|
| `scripts/dev-setup.sh` | Contributor bootstrap (verify Bun + `bun install`). |
|
||||||
| `src/keeper.ts` | Synthetic-activity sweep and idle-watch loop. |
|
| `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. |
|
||||||
| `install.sh` | Bootstrap: verify Bun, `bun install`, print macOS permission hint. |
|
| `src/move.ts` | CLI entry point: parses args, dispatches help/version, starts the loop. |
|
||||||
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
|
| `src/cli.ts` | Argument parsing, validation, and help/version output. |
|
||||||
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
|
| `src/config.ts` | `Config` type (carries every tunable, including `verbose`), `DEFAULT_CONFIG` (derived from `scripts/config.default.json`), `defaultConfigPath`, and the layered `resolveConfig` overlay. |
|
||||||
| `bun.lock` | Bun's lockfile. Commit this. |
|
| `src/configFile.ts` | Optional JSON config-file loader with strict schema validation. |
|
||||||
| `LICENSE` | GPLv3 license text. |
|
| `src/editor.ts` | `move --edit`: opens the active config file in `$EDITOR`. |
|
||||||
| `CHANGES.md` | History of the post-port review fixes. |
|
| `src/errors.ts` | Shared error types (`CliError`). |
|
||||||
| `MouseMover.java` | Original Java source, kept for reference. |
|
| `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: bounds policy, pacing, interrupt detection, restore. |
|
||||||
|
| `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
|
## License
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "move",
|
"name": "move",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
"bun": ">=1.0.0"
|
"bun": ">=1.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run src/move.ts"
|
"start": "bun run src/move.ts",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nut-tree-fork/nut-js": "^4.2.2"
|
"@nut-tree-fork/nut-js": "^4.2.2"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"moveInterval": 240,
|
||||||
|
"checkInterval": 10,
|
||||||
|
"stepDelay": 50,
|
||||||
|
"pattern": "line",
|
||||||
|
"verbose": false
|
||||||
|
}
|
||||||
@@ -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
|
# Verifies Bun is installed, then runs `bun install` to fetch dependencies
|
||||||
# (notably `@nut-tree-fork/nut-js`, which ships prebuilt native binaries
|
# (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
|
# This script is idempotent: re-running it just re-resolves the dependency
|
||||||
# tree against the existing `bun.lock`.
|
# 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.
|
# Fail fast on any error or unset variable. (`pipefail` is bash-only and
|
||||||
set -euo pipefail
|
# 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
|
# Operate at the repo root regardless of the caller's CWD. This script
|
||||||
# regardless of the caller's CWD.
|
# lives under scripts/, so pop up one level to land at the project root
|
||||||
cd "$(dirname "$0")"
|
# before running bun install.
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
echo "==> Checking for Bun..."
|
echo "==> Checking for Bun..."
|
||||||
if ! command -v bun >/dev/null 2>&1; then
|
if ! command -v bun >/dev/null 2>&1; then
|
||||||
@@ -30,12 +38,16 @@ echo "==> Installing dependencies..."
|
|||||||
bun install
|
bun install
|
||||||
|
|
||||||
echo
|
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.
|
# macOS gates synthetic mouse events behind the Accessibility permission.
|
||||||
# Without this hint, the first run silently fails to move the cursor and
|
# Without this hint, the first run silently fails to move the cursor and
|
||||||
# the user has no obvious next step.
|
# 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 "Note: macOS will prompt for Accessibility permission on first mouse move."
|
||||||
echo " Grant it under System Settings > Privacy & Security > Accessibility."
|
echo " Grant it under System Settings > Privacy & Security > Accessibility."
|
||||||
fi
|
fi
|
||||||
Executable
+210
@@ -0,0 +1,210 @@
|
|||||||
|
#!/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/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. Idempotence check via a version marker file.
|
||||||
|
# 5. Download the source tarball from Gitea, extract under the install dir.
|
||||||
|
# 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, only if one doesn't already
|
||||||
|
# exist at $XDG_CONFIG_HOME/move/config.json.
|
||||||
|
# 9. Verify PATH, surface macOS Accessibility hint, print final status.
|
||||||
|
#
|
||||||
|
# Env vars (all optional):
|
||||||
|
# MOVE_VERSION Branch or tag to install. Default: master.
|
||||||
|
# MOVE_FORCE Set to 1 to reinstall even if the version marker matches.
|
||||||
|
# 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 path is $XDG_CONFIG_HOME/move/config.json.
|
||||||
|
#
|
||||||
|
# POSIX sh; no bashisms.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
REPO_OWNER="nokeo08"
|
||||||
|
REPO_NAME="Move"
|
||||||
|
GITEA_HOST="gitea.cahlen.com"
|
||||||
|
|
||||||
|
MOVE_VERSION="${MOVE_VERSION:-master}"
|
||||||
|
MOVE_FORCE="${MOVE_FORCE:-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"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 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"
|
||||||
|
|
||||||
|
# --- Idempotence check -------------------------------------------------------
|
||||||
|
|
||||||
|
assert_safe_dir "$INSTALL_DIR"
|
||||||
|
|
||||||
|
if [ "$MOVE_FORCE" != "1" ] && [ -f "$INSTALL_DIR/.installed-version" ]; then
|
||||||
|
CURRENT=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '')
|
||||||
|
if [ "$CURRENT" = "$MOVE_VERSION" ]; then
|
||||||
|
printf 'move %s is already installed at %s/move.\n' "$MOVE_VERSION" "$BIN_DIR"
|
||||||
|
printf 'Set MOVE_FORCE=1 to reinstall, or set MOVE_VERSION to a different ref.\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Clean install dir -------------------------------------------------------
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
rm -rf "$INSTALL_DIR"
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# --- Download source ---------------------------------------------------------
|
||||||
|
|
||||||
|
TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz"
|
||||||
|
TARBALL_TMP=$(mktemp) || die "could not create temp file"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$TARBALL_TMP"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
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 to %s\n' "$INSTALL_DIR"
|
||||||
|
if ! tar -xzf "$TARBALL_TMP" -C "$INSTALL_DIR" --strip-components=1; then
|
||||||
|
die "could not extract tarball from $TARBALL_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Install runtime deps ----------------------------------------------------
|
||||||
|
|
||||||
|
printf '==> Installing runtime dependencies (bun install --production)\n'
|
||||||
|
(cd "$INSTALL_DIR" && bun install --production)
|
||||||
|
|
||||||
|
# --- Drop the wrapper --------------------------------------------------------
|
||||||
|
|
||||||
|
WRAPPER="$BIN_DIR/move"
|
||||||
|
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"
|
||||||
|
|
||||||
|
# --- Write version marker ----------------------------------------------------
|
||||||
|
|
||||||
|
printf '%s\n' "$MOVE_VERSION" > "$INSTALL_DIR/.installed-version"
|
||||||
|
|
||||||
|
# --- Seed user config file (only if absent) ----------------------------------
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# Strict policy: never overwrite an existing user config. The uninstaller
|
||||||
|
# follows the matching policy of never removing it; together that
|
||||||
|
# preserves user customizations unconditionally across (re)installs and
|
||||||
|
# uninstalls.
|
||||||
|
|
||||||
|
assert_safe_dir "$CONFIG_DIR"
|
||||||
|
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"
|
||||||
|
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"
|
||||||
Executable
+72
@@ -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/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'
|
||||||
+65
-32
@@ -9,14 +9,18 @@
|
|||||||
*
|
*
|
||||||
* -h, --help Prints `printHelp()` to stdout; entry exits 0.
|
* -h, --help Prints `printHelp()` to stdout; entry exits 0.
|
||||||
* -v, --version Prints `move <VERSION>` 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.
|
* -m, --move-interval Idle time (seconds) before a sweep fires.
|
||||||
* -c, --check-interval Cursor poll cadence (seconds).
|
* -c, --check-interval Cursor poll cadence (seconds).
|
||||||
* -d, --step-delay Pause between synthetic steps (ms).
|
* -d, --step-delay Pause between synthetic steps (ms).
|
||||||
* -n, --step-count Steps per sweep (pixels).
|
* -p, --pattern Movement strategy name (see strategies.ts).
|
||||||
* -V, --verbose Enable per-sweep / interrupt / bounds logging.
|
* -V, --verbose Enable per-sweep / interrupt / bounds logging.
|
||||||
* (`-V` capital because `-v` is `--version`.)
|
* (`-V` capital because `-v` is `--version`.)
|
||||||
*
|
*
|
||||||
* 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.
|
* `resolveConfig` in `config.ts`; this module only parses and validates.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -25,35 +29,38 @@ import { dirname, join } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { parseArgs } from "node:util";
|
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).
|
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user
|
||||||
* Distinct from runtime errors so the top-level entry can exit with code 2
|
* did not supply the flag; this lets `resolveConfig` cleanly distinguish
|
||||||
* (user error) instead of code 1 (runtime failure).
|
* "use the layer below" from "explicit override".
|
||||||
*/
|
|
||||||
export class CliError extends Error {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user did
|
|
||||||
* not supply the flag; this lets `resolveConfig` cleanly distinguish "use the
|
|
||||||
* default" from "explicit override".
|
|
||||||
*/
|
*/
|
||||||
export interface ParsedCliArgs {
|
export interface ParsedCliArgs {
|
||||||
help: boolean;
|
help: boolean;
|
||||||
version: boolean;
|
version: boolean;
|
||||||
|
edit: boolean;
|
||||||
|
config: string | undefined;
|
||||||
moveInterval: number | undefined; // seconds
|
moveInterval: number | undefined; // seconds
|
||||||
checkInterval: number | undefined; // seconds
|
checkInterval: number | undefined; // seconds
|
||||||
stepDelay: number | undefined; // milliseconds
|
stepDelay: number | undefined; // milliseconds
|
||||||
stepCount: number | undefined; // pixels
|
/** Movement strategy name, validated against the registry. */
|
||||||
verbose: boolean;
|
pattern: string | undefined;
|
||||||
|
/**
|
||||||
|
* `true` when `-V`/`--verbose` was passed; `undefined` when it was not.
|
||||||
|
* `undefined` (not `false`) lets the layered resolver distinguish "user
|
||||||
|
* did not specify" from a hypothetical "user explicitly turned off",
|
||||||
|
* even though the CLI has no off-switch today.
|
||||||
|
*/
|
||||||
|
verbose: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a CLI-supplied numeric value. Returns `undefined` if the user did
|
* Validate a CLI-supplied numeric value. Returns `undefined` if the user
|
||||||
* not supply the flag at all; throws `CliError` on anything that isn't a
|
* did not supply the flag at all; throws `CliError` on anything that isn't
|
||||||
* positive finite number. Zero is rejected: every numeric tunable here is a
|
* a positive finite number.
|
||||||
* duration or count where zero is meaningless or actively broken.
|
|
||||||
*/
|
*/
|
||||||
function parsePositiveNumber(name: string, raw: string | undefined): number | undefined {
|
function parsePositiveNumber(name: string, raw: string | undefined): number | undefined {
|
||||||
if (raw === undefined) return undefined;
|
if (raw === undefined) return undefined;
|
||||||
@@ -64,13 +71,24 @@ function parsePositiveNumber(name: string, raw: string | undefined): number | un
|
|||||||
return n;
|
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
|
* Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
|
||||||
* `parseArgs` in strict mode so unknown flags and missing values surface as
|
* `parseArgs` in strict mode so unknown flags and missing values surface
|
||||||
* `CliError`s that the entry point can turn into exit code 2.
|
* as `CliError`s that the entry point can turn into exit code 2.
|
||||||
*
|
|
||||||
* Numeric flags are stored as `string` by `parseArgs` and then validated by
|
|
||||||
* `parsePositiveNumber`.
|
|
||||||
*/
|
*/
|
||||||
export function parseCliArgs(): ParsedCliArgs {
|
export function parseCliArgs(): ParsedCliArgs {
|
||||||
let values: Record<string, string | boolean | undefined>;
|
let values: Record<string, string | boolean | undefined>;
|
||||||
@@ -80,10 +98,12 @@ export function parseCliArgs(): ParsedCliArgs {
|
|||||||
options: {
|
options: {
|
||||||
help: { type: "boolean", short: "h" },
|
help: { type: "boolean", short: "h" },
|
||||||
version: { type: "boolean", short: "v" },
|
version: { type: "boolean", short: "v" },
|
||||||
|
edit: { type: "boolean", short: "e" },
|
||||||
|
config: { type: "string", short: "C" },
|
||||||
"move-interval": { type: "string", short: "m" },
|
"move-interval": { type: "string", short: "m" },
|
||||||
"check-interval": { type: "string", short: "c" },
|
"check-interval": { type: "string", short: "c" },
|
||||||
"step-delay": { type: "string", short: "d" },
|
"step-delay": { type: "string", short: "d" },
|
||||||
"step-count": { type: "string", short: "n" },
|
pattern: { type: "string", short: "p" },
|
||||||
verbose: { type: "boolean", short: "V" },
|
verbose: { type: "boolean", short: "V" },
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
@@ -100,19 +120,21 @@ export function parseCliArgs(): ParsedCliArgs {
|
|||||||
return {
|
return {
|
||||||
help: Boolean(values.help),
|
help: Boolean(values.help),
|
||||||
version: Boolean(values.version),
|
version: Boolean(values.version),
|
||||||
|
edit: Boolean(values.edit),
|
||||||
|
config: values.config as string | undefined,
|
||||||
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
|
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
|
||||||
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
|
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
|
||||||
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
|
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
|
||||||
stepCount: parsePositiveNumber("step-count", values["step-count"] as string | undefined),
|
pattern: parsePatternName(values.pattern as string | undefined),
|
||||||
verbose: Boolean(values.verbose),
|
verbose: values.verbose === true ? true : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the package version from `package.json` at runtime so help/version
|
* Read the package version from `package.json` at runtime so help/version
|
||||||
* output stays in sync with the manifest without a build step. Resolved
|
* 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
|
* relative to this module's own location so the lookup works regardless
|
||||||
* the caller's CWD.
|
* of the caller's CWD.
|
||||||
*/
|
*/
|
||||||
export const VERSION: string = (() => {
|
export const VERSION: string = (() => {
|
||||||
// This module lives in `src/`, so `package.json` is one directory up.
|
// This module lives in `src/`, so `package.json` is one directory up.
|
||||||
@@ -123,12 +145,14 @@ export const VERSION: string = (() => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Write the usage block to stdout. Default values are pulled from
|
* Write the usage block to stdout. Default values are pulled from
|
||||||
* `DEFAULT_CONFIG` (converted to the units the CLI exposes) so the help
|
* `DEFAULT_CONFIG` (converted to the units the CLI exposes); the default
|
||||||
* text never drifts from the actual defaults.
|
* config-file path is computed by `defaultConfigPath`. Both make the help
|
||||||
|
* text self-updating when their sources change.
|
||||||
*/
|
*/
|
||||||
export function printHelp(): void {
|
export function printHelp(): void {
|
||||||
const moveDefaultSec: number = DEFAULT_CONFIG.moveInterval / 1000;
|
const moveDefaultSec: number = DEFAULT_CONFIG.moveInterval / 1000;
|
||||||
const checkDefaultSec: number = DEFAULT_CONFIG.checkInterval / 1000;
|
const checkDefaultSec: number = DEFAULT_CONFIG.checkInterval / 1000;
|
||||||
|
const cfgPath: string = defaultConfigPath();
|
||||||
|
|
||||||
process.stdout.write(`Usage: move [options]
|
process.stdout.write(`Usage: move [options]
|
||||||
|
|
||||||
@@ -138,16 +162,25 @@ nudging the mouse cursor after a configurable idle period.
|
|||||||
Options:
|
Options:
|
||||||
-h, --help Show this help and exit.
|
-h, --help Show this help and exit.
|
||||||
-v, --version Print version 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}.
|
-m, --move-interval <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}.
|
||||||
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
|
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
|
||||||
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
|
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
|
||||||
-n, --step-count <pixels> Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}.
|
-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, interrupt, and bounds event
|
-V, --verbose Log every sweep, interrupt, and bounds event
|
||||||
(default prints only the startup banner).
|
(default prints only the startup banner).
|
||||||
|
|
||||||
|
Precedence (highest wins): CLI flags > config file > built-in defaults.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
move
|
move
|
||||||
move --move-interval 180 --check-interval 5
|
move --move-interval 180 --check-interval 5
|
||||||
move -m 300 -V
|
move -m 300 -V
|
||||||
|
move --pattern arc
|
||||||
|
move --config ~/myprofile.json
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
+162
-35
@@ -1,20 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* config.ts
|
* 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
|
* The keeper is driven by a single `Config` object that carries every
|
||||||
* tunables it cares about. Defaults live in `DEFAULT_CONFIG`; CLI overrides
|
* tunable it cares about, including the `verbose` flag. Defaults live in
|
||||||
* are layered on top by `resolveConfig` rather than mutating the defaults,
|
* `DEFAULT_CONFIG`; CLI and config-file overrides are layered on top by
|
||||||
* so the defaults stay genuinely constant and the resolved config stays
|
* `resolveConfig` rather than mutating the defaults, so the defaults stay
|
||||||
* structurally typed.
|
* genuinely constant and the resolved config stays structurally typed.
|
||||||
*
|
*
|
||||||
* All fields are in their internal units (ms, pixels). The CLI exposes the
|
* All numeric `Config` fields are in their internal units (ms, pixels).
|
||||||
* time-valued fields in seconds for ergonomics; `resolveConfig` performs
|
* The CLI and config file expose the time-valued fields in seconds for
|
||||||
* the seconds->ms conversion at the boundary so downstream code never has
|
* ergonomics; `resolveConfig` performs the seconds->ms conversion at the
|
||||||
* to think about it.
|
* 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
|
* The shape of a resolved runtime configuration. `readonly` to make
|
||||||
* accidental mutation a type error.
|
* accidental mutation a type error.
|
||||||
@@ -26,51 +42,162 @@
|
|||||||
* - `stepDelay` — pause between individual synthetic mouse steps inside
|
* - `stepDelay` — pause between individual synthetic mouse steps inside
|
||||||
* a sweep. Also the window in which the user can
|
* a sweep. Also the window in which the user can
|
||||||
* "interrupt" by moving the cursor. Milliseconds.
|
* "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 / bounds events are
|
||||||
|
* logged. The startup banner is always printed.
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
export interface Config {
|
||||||
readonly moveInterval: number;
|
readonly moveInterval: number;
|
||||||
readonly checkInterval: number;
|
readonly checkInterval: number;
|
||||||
readonly stepDelay: number;
|
readonly stepDelay: number;
|
||||||
readonly stepCount: number;
|
readonly pattern: PatternName;
|
||||||
|
readonly verbose: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in defaults used when the user did not supply an explicit CLI
|
* Shape of `scripts/config.default.json` after parsing. The cast below
|
||||||
* override for the corresponding flag.
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
export const DEFAULT_CONFIG: Config = {
|
||||||
moveInterval: 4 * 60 * 1000, // 4 minutes in milliseconds
|
moveInterval: seed.moveInterval * 1000,
|
||||||
checkInterval: 10 * 1000, // Check every 10 seconds
|
checkInterval: seed.checkInterval * 1000,
|
||||||
stepDelay: 50, // ms between mouse steps
|
stepDelay: seed.stepDelay,
|
||||||
stepCount: 250, // pixels to move per sweep
|
pattern: seed.pattern,
|
||||||
|
verbose: seed.verbose,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subset of `ParsedCliArgs` that `resolveConfig` actually consumes. Declared
|
* Common shape for override layers (CLI args and config-file content).
|
||||||
* locally instead of importing from `cli.ts` to keep the dependency arrow
|
*
|
||||||
* pointing one way (cli -> config), which lets `config.ts` stay a leaf
|
* `undefined` means "this layer doesn't supply a value"; the next layer
|
||||||
* module with no internal imports.
|
* 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).
|
||||||
*/
|
*/
|
||||||
export interface ConfigOverrides {
|
export interface ConfigOverrides {
|
||||||
readonly moveInterval: number | undefined; // seconds (CLI units)
|
readonly moveInterval: number | undefined;
|
||||||
readonly checkInterval: number | undefined; // seconds (CLI units)
|
readonly checkInterval: number | undefined;
|
||||||
readonly stepDelay: number | undefined; // milliseconds
|
readonly stepDelay: number | undefined;
|
||||||
readonly stepCount: number | undefined; // pixels
|
readonly pattern: string | undefined;
|
||||||
|
readonly verbose: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overlay user-supplied CLI values on top of `DEFAULT_CONFIG` and return a
|
* Resolve the default config-file path per the XDG Base Directory Spec.
|
||||||
* resolved `Config`. Time-valued CLI inputs (move/check interval) are in
|
* Honors `$XDG_CONFIG_HOME` if set; otherwise falls back to
|
||||||
* seconds; this is where they're converted to milliseconds for internal use.
|
* `$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 {
|
return {
|
||||||
moveInterval: overrides.moveInterval !== undefined ? overrides.moveInterval * 1000 : DEFAULT_CONFIG.moveInterval,
|
moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval),
|
||||||
checkInterval: overrides.checkInterval !== undefined ? overrides.checkInterval * 1000 : DEFAULT_CONFIG.checkInterval,
|
checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval),
|
||||||
stepDelay: overrides.stepDelay ?? DEFAULT_CONFIG.stepDelay,
|
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
|
||||||
stepCount: overrides.stepCount ?? DEFAULT_CONFIG.stepCount,
|
pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
|
||||||
|
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*
|
||||||
|
* 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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 per the strategy's `BoundsPolicy`,
|
||||||
|
* - 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,
|
||||||
|
* bounds, 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 { BoundsPolicy, 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; aborted without
|
||||||
|
* snapping back.
|
||||||
|
* - `aborted` — an `abort`-policy target went out of bounds.
|
||||||
|
*/
|
||||||
|
export type SweepOutcome = "completed" | "interrupted" | "aborted";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `clamp` / `reflect` 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). `abort` (used by `line`) is deliberately left on the
|
||||||
|
* full `[0, max - 1]` range, so its behavior is unchanged.
|
||||||
|
*/
|
||||||
|
const EDGE_MARGIN: number = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inclusive `[lo, hi]` integer range an axis of length `max` may travel
|
||||||
|
* under the `clamp` / `reflect` policies: `[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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round to whole pixels and clamp into the inset travel range for `max`. */
|
||||||
|
function clampInt(v: number, max: number): number {
|
||||||
|
const { lo, hi } = travelRange(max);
|
||||||
|
const r: number = Math.round(v);
|
||||||
|
if (r < lo) return lo;
|
||||||
|
if (r > hi) return hi;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror `v` into the inset travel range for `max` as a triangle wave, so
|
||||||
|
* values past an edge bounce back inside instead of clamping flat against it.
|
||||||
|
*/
|
||||||
|
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 target to an on-screen integer pixel under the
|
||||||
|
* given policy. Returns `null` when policy is `abort` and the (rounded)
|
||||||
|
* target lies outside the screen — the signal to stop the sweep.
|
||||||
|
*/
|
||||||
|
function resolveTarget(
|
||||||
|
policy: BoundsPolicy,
|
||||||
|
p: Point,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): Point | null {
|
||||||
|
if (policy === "reflect") {
|
||||||
|
return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) };
|
||||||
|
}
|
||||||
|
if (policy === "clamp") {
|
||||||
|
return { x: clampInt(p.x, width), y: clampInt(p.y, height) };
|
||||||
|
}
|
||||||
|
// abort: round, then reject anything off-screen.
|
||||||
|
const x: number = Math.round(p.x);
|
||||||
|
const y: number = Math.round(p.y);
|
||||||
|
if (x < 0 || x >= width || y < 0 || y >= height) return null;
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (bounds policy).
|
||||||
|
* An `abort`-policy out-of-bounds target ends the sweep (`aborted`).
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* `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,
|
||||||
|
): Promise<SweepOutcome> {
|
||||||
|
const { start, width, height } = ctx;
|
||||||
|
|
||||||
|
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
|
||||||
|
|
||||||
|
for (const target of strategy.path(ctx)) {
|
||||||
|
const point: Point | null = resolveTarget(strategy.bounds, target, width, height);
|
||||||
|
if (point === null) {
|
||||||
|
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
|
||||||
|
return "aborted";
|
||||||
|
}
|
||||||
|
|
||||||
|
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. Abort
|
||||||
|
// 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 the edge-seeking patterns (clamp/reflect/arc)
|
||||||
|
// 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()}; aborting simulation.`);
|
||||||
|
return "interrupted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
|
||||||
|
log.event("Mouse moved.");
|
||||||
|
return "completed";
|
||||||
|
}
|
||||||
+61
-108
@@ -1,59 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* keeper.ts
|
* keeper.ts
|
||||||
* ---------
|
* ---------
|
||||||
* The actual "Teams Status Keeper" behavior: synthetic mouse activity with
|
* The "Teams Status Keeper" behavior: the idle-watch loop plus the
|
||||||
* real-user-wins semantics, plus the idle-watch loop that drives it.
|
* per-sweep glue that ties a movement strategy to the execution driver.
|
||||||
*
|
*
|
||||||
* Runtime: Bun (uses `@nut-tree-fork/nut-js` for cross-platform mouse +
|
* The mechanics are split across three seams so this file stays small and
|
||||||
* screen). Importing this module sets `mouse.config.autoDelayMs = 0` as a
|
* the interesting parts stay testable:
|
||||||
* side effect — see below.
|
* - `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 (bounds, 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:
|
* Logging policy:
|
||||||
* - The startup banner in `runKeeper` is unconditional so the user always
|
* - The startup banner in `runKeeper` is unconditional so the user always
|
||||||
* sees confirmation that the process is alive.
|
* sees the process is alive.
|
||||||
* - Every per-sweep / interrupt / bounds log is gated by `verbose` so the
|
* - Per-sweep / interrupt / bounds lines are gated by `config.verbose`
|
||||||
* default is quiet. Errors stay on `console.error` (unconditional, raised
|
* (see `makeLogger`). Errors stay on `console.error`, raised by the
|
||||||
* by the entry point on unhandled rejection).
|
* 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 } from "./executor.ts";
|
||||||
|
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
|
||||||
|
|
||||||
import type { Config } from "./config.ts";
|
import type { Config } from "./config.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* nut.js inserts a configurable delay after every action (default 100ms).
|
* Build a verbose-gated `Logger`. `info` is unconditional; `event` only
|
||||||
* That default would silently more-than-double the duration of every
|
* fires when the caller asked for verbose output. Returning a small object
|
||||||
* `setPosition` and `getPosition` call. We drive cadence ourselves via
|
* keeps call sites free of `if (verbose)` noise at every log line.
|
||||||
* `Config.stepDelay`, so disable nut.js's implicit delay entirely.
|
|
||||||
*/
|
*/
|
||||||
mouse.config.autoDelayMs = 0;
|
function makeLogger(verbose: boolean): Logger {
|
||||||
|
|
||||||
/**
|
|
||||||
* Promise-based `setTimeout` wrapper. Allows `await sleep(ms)` ergonomics.
|
|
||||||
*
|
|
||||||
* @param ms - Duration to wait, in milliseconds.
|
|
||||||
*/
|
|
||||||
const sleep = (ms: number): Promise<void> =>
|
|
||||||
new Promise<void>((resolve: () => void): void => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the current local time as `HH:MM:SS` (24-hour, zero-padded).
|
|
||||||
* Used for human-readable log lines. Date is intentionally omitted.
|
|
||||||
*/
|
|
||||||
const timestamp = (): string => {
|
|
||||||
const d: Date = new Date();
|
|
||||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a verbose-gated logger. `info` is unconditional; `event` only fires
|
|
||||||
* when the caller asked for verbose output. Returning a small object keeps
|
|
||||||
* `simulateActivity` free of `if (verbose)` noise at every log site.
|
|
||||||
*/
|
|
||||||
function makeLogger(verbose: boolean): { info(msg: string): void; event(msg: string): void } {
|
|
||||||
return {
|
return {
|
||||||
info: (msg: string): void => {
|
info: (msg: string): void => {
|
||||||
console.log(msg);
|
console.log(msg);
|
||||||
@@ -67,61 +48,22 @@ function makeLogger(verbose: boolean): { info(msg: string): void; event(msg: str
|
|||||||
/**
|
/**
|
||||||
* Perform a single synthetic mouse-activity sweep.
|
* Perform a single synthetic mouse-activity sweep.
|
||||||
*
|
*
|
||||||
* Behavior:
|
* Snapshots the cursor and screen (re-read every call so monitor changes
|
||||||
* 1. Snapshot the starting cursor position.
|
* are handled), selects the configured strategy from the registry, and
|
||||||
* 2. Read current screen dimensions (re-read every call so monitor changes
|
* hands the resulting path to `executePath`, which owns bounds, pacing,
|
||||||
* are handled correctly).
|
* interrupt detection, and restore-on-clean. An unknown `config.pattern`
|
||||||
* 3. Pick a horizontal direction (`dx`) that keeps the sweep on-screen:
|
* falls back to the default strategy defensively; validation at the CLI /
|
||||||
* move right if there's room, otherwise move left. Vertical movement is
|
* config-file boundary should prevent that from ever happening.
|
||||||
* currently disabled (`dy = 0`) but the framework is in place for
|
|
||||||
* richer patterns later.
|
|
||||||
* 4. For each of `config.stepCount` steps:
|
|
||||||
* - Compute the next target position.
|
|
||||||
* - Defensive bounds check (belt-and-braces given the `dx` choice).
|
|
||||||
* - Command nut.js to move the cursor there.
|
|
||||||
* - Sleep `config.stepDelay` — also the user's interrupt window.
|
|
||||||
* - Re-read the cursor. If it isn't where we put it, the user
|
|
||||||
* touched the mouse: log (verbose) and return early, leaving the
|
|
||||||
* cursor wherever the user moved it.
|
|
||||||
* 5. On a clean full sweep, restore the cursor to the starting position
|
|
||||||
* so the next idle-check sees "no movement" and doesn't misread the
|
|
||||||
* synthetic activity as the user returning.
|
|
||||||
*/
|
*/
|
||||||
async function simulateActivity(config: Config, log: ReturnType<typeof makeLogger>): Promise<void> {
|
async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> {
|
||||||
const start: Point = await mouse.getPosition();
|
const start: Point = await device.getPosition();
|
||||||
const screenWidth: number = await screen.width();
|
const width: number = await device.width();
|
||||||
const screenHeight: number = await screen.height();
|
const height: number = await device.height();
|
||||||
const dx: number = start.x + config.stepCount < screenWidth ? 1 : -1;
|
|
||||||
const dy: number = 0;
|
|
||||||
|
|
||||||
log.event(`Simulating activity at ${timestamp()}...`);
|
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
|
||||||
|
const ctx: MoveContext = { start, width, height, rng: Math.random };
|
||||||
|
|
||||||
for (let i: number = 1; i <= config.stepCount; i++) {
|
await executePath(strategy, ctx, device, log, config);
|
||||||
const expected: Point = new Point(start.x + i * dx, start.y + i * dy);
|
|
||||||
|
|
||||||
if (expected.x < 0 || expected.x >= screenWidth || expected.y < 0 || expected.y >= screenHeight) {
|
|
||||||
// Safety net for future non-linear movement patterns. With the
|
|
||||||
// current straight-line sweep + `dx` selection above, this branch
|
|
||||||
// should never fire.
|
|
||||||
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await mouse.setPosition(expected);
|
|
||||||
await sleep(config.stepDelay);
|
|
||||||
|
|
||||||
const current: Point = await mouse.getPosition();
|
|
||||||
if (current.x !== expected.x || current.y !== expected.y) {
|
|
||||||
// Cursor isn't where we put it -> real user activity. Abort
|
|
||||||
// without snapping back, so we don't yank the cursor out from
|
|
||||||
// under the user.
|
|
||||||
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await mouse.setPosition(start);
|
|
||||||
log.event("Mouse moved.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,21 +81,26 @@ async function simulateActivity(config: Config, log: ReturnType<typeof makeLogge
|
|||||||
* idleness clock so we wait another full `moveInterval` before
|
* idleness clock so we wait another full `moveInterval` before
|
||||||
* firing again.
|
* firing again.
|
||||||
*
|
*
|
||||||
* `simulateActivity` is designed so that its own synthetic movement never
|
* `simulateActivity` (via `executePath`) is designed so its own synthetic
|
||||||
* counts as real activity: on a clean sweep it restores the cursor (so the
|
* movement never counts as real activity: on a clean sweep it restores the
|
||||||
* next position check matches), and on a user-interrupted sweep the next
|
* cursor, and on a user-interrupted sweep the next iteration sees the
|
||||||
* iteration sees the user's new position and correctly resets the clock.
|
* 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> {
|
export async function runKeeper(config: Config, device?: Device): Promise<void> {
|
||||||
const log = makeLogger(verbose);
|
const dev: Device = device ?? (await createNutDevice());
|
||||||
|
|
||||||
|
const log = makeLogger(config.verbose);
|
||||||
log.info("Teams Status Keeper started. Press Ctrl+C to stop.");
|
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();
|
let lastActivity: number = Date.now();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
await sleep(config.checkInterval);
|
await dev.sleep(config.checkInterval);
|
||||||
const pos: Point = await mouse.getPosition();
|
const pos: Point = await dev.getPosition();
|
||||||
const now: number = Date.now();
|
const now: number = Date.now();
|
||||||
|
|
||||||
if (pos.x !== lastPos.x || pos.y !== lastPos.y) {
|
if (pos.x !== lastPos.x || pos.y !== lastPos.y) {
|
||||||
@@ -164,12 +111,18 @@ export async function runKeeper(config: Config, verbose: boolean): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (now - lastActivity >= config.moveInterval) {
|
if (now - lastActivity >= config.moveInterval) {
|
||||||
await simulateActivity(config, log);
|
await simulateActivity(config, log, dev);
|
||||||
// `simulateActivity` either returns the cursor to its start
|
// The sweep either restored the cursor to its start (clean) or
|
||||||
// (clean sweep) or leaves it where the user moved it (interrupt).
|
// left it where the user moved it (interrupt). Either way, reset
|
||||||
// Either way we reset the clock and require another full
|
// the clock and require another full moveInterval of inactivity
|
||||||
// moveInterval of inactivity before firing again.
|
// before firing again.
|
||||||
lastActivity = Date.now();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-27
@@ -4,18 +4,25 @@
|
|||||||
* -------
|
* -------
|
||||||
* Entry point for the `move` CLI.
|
* Entry point for the `move` CLI.
|
||||||
*
|
*
|
||||||
* Thin shim that ties the three logic modules together:
|
* Thin shim that ties the four logic modules together:
|
||||||
* - `cli.ts` parses and validates `process.argv`.
|
* - `cli.ts` parses and validates `process.argv`.
|
||||||
* - `config.ts` holds the default tunables and the `resolveConfig` overlay.
|
* - `configFile.ts` loads and validates the JSON config file.
|
||||||
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
|
* - `config.ts` holds defaults and the layered `resolveConfig` overlay.
|
||||||
|
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
|
||||||
*
|
*
|
||||||
* Order of operations:
|
* Order of operations:
|
||||||
* 1. Parse CLI args. Bad input -> stderr message + short usage hint, exit 2.
|
* 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
|
||||||
* 2. `--help` / `--version` short-circuit before any mouse work happens.
|
* 2. `--help` / `--version` short-circuit before any I/O, config load, or
|
||||||
* 3. Resolve CLI overrides on top of `DEFAULT_CONFIG` and hand the result
|
* mouse work. `keeper.ts` is also lazy-imported (see below) so these
|
||||||
* (plus the verbose flag) to `runKeeper`.
|
* flags don't pay the cost of loading the nut.js native binary.
|
||||||
* 4. Any unhandled rejection from `runKeeper` is logged and exits with
|
* 3. Load + validate the config file (default XDG path, or `--config
|
||||||
* code 1 so it's catchable by shells / supervisors.
|
* <path>` if supplied). Validation failures share the exit-2 path.
|
||||||
|
* 4. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose
|
||||||
|
* lives inside `Config` and is layered with the same precedence as
|
||||||
|
* the numeric fields.
|
||||||
|
* 5. 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
|
* 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`.
|
* above lets this file run as a real CLI once linked via `bun link`.
|
||||||
@@ -23,20 +30,61 @@
|
|||||||
|
|
||||||
import { parseCliArgs, printHelp, VERSION } from "./cli.ts";
|
import { parseCliArgs, printHelp, VERSION } from "./cli.ts";
|
||||||
import type { ParsedCliArgs } from "./cli.ts";
|
import type { ParsedCliArgs } from "./cli.ts";
|
||||||
import { resolveConfig } from "./config.ts";
|
import { defaultConfigPath, resolveConfig } from "./config.ts";
|
||||||
import { runKeeper } from "./keeper.ts";
|
import type { ConfigOverrides } from "./config.ts";
|
||||||
|
import { loadConfigFile } from "./configFile.ts";
|
||||||
|
import { editConfig } from "./editor.ts";
|
||||||
|
|
||||||
let cliArgs: ParsedCliArgs;
|
// `keeper.ts` is intentionally NOT statically imported here. It transitively
|
||||||
try {
|
// pulls in `@nut-tree-fork/nut-js`, which in turn dlopens a sizeable native
|
||||||
cliArgs = parseCliArgs();
|
// `.node` binary. On a cold first run that load dominates startup (~1 s on
|
||||||
} catch (err: unknown) {
|
// 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);
|
const msg: string = err instanceof Error ? err.message : String(err);
|
||||||
process.stderr.write(`move: ${msg}\nTry 'move --help' for more information.\n`);
|
process.stderr.write(`move: ${msg}\nTry 'move --help' for more information.\n`);
|
||||||
process.exit(2);
|
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) {
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if (cliArgs.version) {
|
if (cliArgs.version) {
|
||||||
@@ -44,14 +92,43 @@ if (cliArgs.version) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = resolveConfig({
|
if (cliArgs.edit) {
|
||||||
moveInterval: cliArgs.moveInterval,
|
// Edit is a fully terminal action: open the config file in $EDITOR and
|
||||||
checkInterval: cliArgs.checkInterval,
|
// hand the user's terminal over. Path resolution mirrors loadConfigFile's:
|
||||||
stepDelay: cliArgs.stepDelay,
|
// honor `--config <path>` if set, else use the XDG default.
|
||||||
stepCount: cliArgs.stepCount,
|
try {
|
||||||
});
|
const path: string = cliArgs.config ?? defaultConfigPath();
|
||||||
|
editConfig(path);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
failUser(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
runKeeper(config, cliArgs.verbose).catch((err: unknown): void => {
|
let fileOverrides: ConfigOverrides | null;
|
||||||
console.error("Error:", err);
|
try {
|
||||||
process.exit(1);
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* 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; the executor rounds to whole
|
||||||
|
* pixels before commanding the cursor and applies the strategy's declared
|
||||||
|
* `BoundsPolicy` to keep everything on-screen.
|
||||||
|
*
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the executor keeps a strategy's targets on-screen:
|
||||||
|
*
|
||||||
|
* - `abort` — stop the sweep the moment a target falls out of bounds.
|
||||||
|
* Used by `line`, whose direction is chosen so this never
|
||||||
|
* actually fires; preserves the original straight-line
|
||||||
|
* semantics exactly.
|
||||||
|
* - `clamp` — pin each out-of-bounds coordinate to the nearest edge.
|
||||||
|
* - `reflect` — mirror out-of-bounds coordinates back inside, so a roaming
|
||||||
|
* pattern bounces off the screen edges instead of sticking.
|
||||||
|
*/
|
||||||
|
export type BoundsPolicy = "abort" | "clamp" | "reflect";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* - `bounds` — how the executor confines this pattern to the screen.
|
||||||
|
* - `path` — pure generator of ideal (possibly fractional) targets,
|
||||||
|
* emitted in visiting order. Should not re-emit `start`.
|
||||||
|
*/
|
||||||
|
export interface MovementStrategy {
|
||||||
|
readonly name: string;
|
||||||
|
readonly bounds: BoundsPolicy;
|
||||||
|
path(ctx: MoveContext): Iterable<Point>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */
|
||||||
|
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, which is why its bounds
|
||||||
|
* policy is `abort` (the direction choice guarantees it never triggers).
|
||||||
|
*/
|
||||||
|
const LINE_STEPS = 250;
|
||||||
|
|
||||||
|
export const line: MovementStrategy = {
|
||||||
|
name: "line",
|
||||||
|
bounds: "abort",
|
||||||
|
*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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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.
|
||||||
|
*/
|
||||||
|
const DIAGONAL_STEPS = 250;
|
||||||
|
|
||||||
|
export const diagonal: MovementStrategy = {
|
||||||
|
name: "diagonal",
|
||||||
|
bounds: "clamp",
|
||||||
|
*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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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",
|
||||||
|
bounds: "clamp",
|
||||||
|
*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's `reflect` policy 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",
|
||||||
|
bounds: "reflect",
|
||||||
|
*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",
|
||||||
|
bounds: "clamp",
|
||||||
|
*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",
|
||||||
|
bounds: "clamp",
|
||||||
|
*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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* 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 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"), "{}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* executor.test.ts
|
||||||
|
* ----------------
|
||||||
|
* Unit tests for the execution driver against a fake `Device`. Covers the
|
||||||
|
* three sweep outcomes, all three bounds policies, the rounding/interrupt
|
||||||
|
* contract, and step pacing — 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 { BoundsPolicy, 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 under a chosen bounds policy. */
|
||||||
|
function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy {
|
||||||
|
return {
|
||||||
|
name: "fixed",
|
||||||
|
bounds,
|
||||||
|
*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, "clamp"), 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, "clamp"), 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("abort policy stops before commanding an out-of-bounds point", async () => {
|
||||||
|
const dev = new FakeDevice(100, 100);
|
||||||
|
const pts = [{ x: 150, y: 10 }]; // x >= width
|
||||||
|
const outcome = await executePath(fixed(pts, "abort"), ctxOf({ x: 10, y: 10 }, 100, 100), dev, noopLog, cfgOf());
|
||||||
|
expect(outcome).toBe("aborted");
|
||||||
|
expect(dev.commanded).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("executePath — bounds policies", () => {
|
||||||
|
test("clamp pins out-of-bounds coordinates to the inset edges", async () => {
|
||||||
|
const dev = new FakeDevice(100, 100);
|
||||||
|
const pts = [
|
||||||
|
{ x: -5, y: 50 },
|
||||||
|
{ x: 9999, y: 50 },
|
||||||
|
];
|
||||||
|
// travelRange(100) is inset by EDGE_MARGIN (2) to [2, 97].
|
||||||
|
await executePath(fixed(pts, "clamp"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
|
||||||
|
expect(dev.commanded[0]).toEqual({ x: 2, y: 50 });
|
||||||
|
expect(dev.commanded[1]).toEqual({ x: 97, y: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reflect mirrors out-of-bounds coordinates 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, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
|
||||||
|
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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, "clamp"), 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, "clamp"), 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, "clamp"), 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, "clamp"), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 }));
|
||||||
|
expect(dev.sleeps).toEqual([7, 7]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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
@@ -8,7 +8,10 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true
|
"verbatimModuleSyntax": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"types": ["bun"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user