Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9617758d8b | |||
| 94d963d8ef | |||
| 1a857de5ed | |||
| 5af253283e | |||
| 7120c06bbe | |||
| d5656efe5b | |||
| 8eac51cd45 | |||
| 7714a6ad1a | |||
| 940113019d | |||
| ccc136f727 | |||
| df9305c6ac | |||
| c9645b374c |
@@ -20,7 +20,7 @@ cursor leaves the position the script just commanded.
|
||||
## Install
|
||||
|
||||
```sh
|
||||
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/install.sh | sh
|
||||
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
This fetches the latest `master` from Gitea, runs `bun install --production`
|
||||
@@ -61,12 +61,21 @@ whichever step was last commanded — by design.
|
||||
## Uninstall
|
||||
|
||||
```sh
|
||||
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/uninstall.sh | sh
|
||||
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/uninstall.sh | sh
|
||||
```
|
||||
|
||||
Removes the wrapper at `$XDG_BIN_HOME/move` and the install tree at
|
||||
`$XDG_DATA_HOME/move`. Bun stays — it's your runtime, not ours.
|
||||
|
||||
Your config file at `$XDG_CONFIG_HOME/move/config.json` is **intentionally
|
||||
left behind**, whether you customized it or never touched the seeded
|
||||
defaults. The uninstaller prints a one-line notice pointing at the path
|
||||
so you can remove it manually if you want:
|
||||
|
||||
```sh
|
||||
rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/move"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```text
|
||||
@@ -75,17 +84,23 @@ Usage: move [options]
|
||||
Options:
|
||||
-h, --help Show this help and exit.
|
||||
-v, --version Print version and exit.
|
||||
-C, --config <path> Load defaults from a JSON config file.
|
||||
Default path: see the Configuration section.
|
||||
-m, --move-interval <seconds> Idle time before a sweep fires. Default: 240.
|
||||
-c, --check-interval <seconds> Cursor poll cadence. Default: 10.
|
||||
-d, --step-delay <ms> Pause between synthetic steps. Default: 50.
|
||||
-n, --step-count <pixels> Steps per sweep. Default: 250.
|
||||
-V, --verbose Log every sweep, interrupt, and bounds event
|
||||
(default prints only the startup banner).
|
||||
|
||||
Precedence (highest wins): CLI flags > config file > built-in defaults.
|
||||
```
|
||||
|
||||
Numeric overrides are layered onto the defaults via `resolveConfig` in
|
||||
`src/config.ts`; time-valued inputs (`-m`, `-c`) are expressed in seconds
|
||||
at the CLI boundary and converted to milliseconds internally.
|
||||
Run `move --help` for the resolved default config-file path on your
|
||||
system. Numeric overrides are layered onto the defaults via
|
||||
`resolveConfig` in `src/config.ts`; time-valued inputs (`-m`, `-c`) are
|
||||
expressed in seconds at the CLI boundary and converted to milliseconds
|
||||
internally.
|
||||
|
||||
Logging is **quiet by default**: only the startup banner ("Teams Status
|
||||
Keeper started…") and any error from an unhandled rejection print on a
|
||||
@@ -95,17 +110,84 @@ out-of-bounds events.
|
||||
Invalid input (unknown flag, missing value, non-positive number) prints an
|
||||
error to `stderr` and exits with code `2`.
|
||||
|
||||
## Configuration
|
||||
|
||||
`move` reads an optional JSON config file at:
|
||||
|
||||
```
|
||||
${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json
|
||||
```
|
||||
|
||||
The installer seeds this file with the default values on a fresh install,
|
||||
**only if no file already exists at that path**. 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,
|
||||
"stepCount": 250,
|
||||
"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, `stepCount` is
|
||||
pixels, `verbose` is a boolean.
|
||||
|
||||
### 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.
|
||||
- `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
|
||||
|
||||
The source lives under `src/`, split into an entry point plus three logic
|
||||
The source lives under `src/`, split into an entry point plus four logic
|
||||
modules:
|
||||
|
||||
- `src/move.ts` is a thin entry point: parses args, dispatches `--help` /
|
||||
`--version`, resolves the runtime config, and calls
|
||||
`runKeeper(config, verbose)`.
|
||||
`--version`, loads the config file, resolves the layered runtime
|
||||
config, and calls `runKeeper(config)`.
|
||||
- `src/cli.ts` owns argument parsing, validation, and help/version output.
|
||||
- `src/config.ts` exports the `Config` type, `DEFAULT_CONFIG`, and the
|
||||
`resolveConfig` overlay function.
|
||||
- `src/configFile.ts` owns optional JSON config-file loading + strict
|
||||
schema validation.
|
||||
- `src/config.ts` exports the `Config` type (which carries every tunable
|
||||
including `verbose`), `DEFAULT_CONFIG`, `defaultConfigPath`, and the
|
||||
layered `resolveConfig` overlay function.
|
||||
- `src/keeper.ts` owns the synthetic-activity sweep and the idle-watch loop.
|
||||
|
||||
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
|
||||
@@ -159,11 +241,12 @@ Clone the repo and bootstrap a dev environment:
|
||||
```sh
|
||||
git clone https://gitea.cahlen.com/nokeo08/Move.git
|
||||
cd Move
|
||||
./dev-setup.sh
|
||||
./scripts/dev-setup.sh
|
||||
```
|
||||
|
||||
`dev-setup.sh` verifies Bun is installed and runs `bun install` (with
|
||||
devDependencies, unlike the end-user `install.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:
|
||||
|
||||
@@ -184,12 +267,14 @@ move --help
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------- | ----------------------------------------------------------------------------- |
|
||||
| `install.sh` | End-user installer; curl-pipeable from Gitea. |
|
||||
| `uninstall.sh` | End-user uninstaller; curl-pipeable from Gitea. |
|
||||
| `dev-setup.sh` | Contributor bootstrap (verify Bun + `bun install`). |
|
||||
| `scripts/install.sh` | End-user installer; curl-pipeable from Gitea. |
|
||||
| `scripts/uninstall.sh` | End-user uninstaller; curl-pipeable from Gitea. |
|
||||
| `scripts/dev-setup.sh` | Contributor bootstrap (verify Bun + `bun install`). |
|
||||
| `scripts/config.default.json`| Single source of truth for default values: imported by `src/config.ts` and copied to `$XDG_CONFIG_HOME/move/config.json` on a fresh install. |
|
||||
| `src/move.ts` | CLI entry point: parses args, dispatches help/version, starts the loop. |
|
||||
| `src/cli.ts` | Argument parsing, validation, and help/version output. |
|
||||
| `src/config.ts` | `Config` type, `DEFAULT_CONFIG`, and `resolveConfig` overlay. |
|
||||
| `src/config.ts` | `Config` type (carries every tunable, including `verbose`), `DEFAULT_CONFIG` (derived from `scripts/config.default.json`), `defaultConfigPath`, and the layered `resolveConfig` overlay. |
|
||||
| `src/configFile.ts` | Optional JSON config-file loader with strict schema validation. |
|
||||
| `src/keeper.ts` | Synthetic-activity sweep and idle-watch loop. |
|
||||
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
|
||||
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@
|
||||
"bun": ">=1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun run src/move.ts"
|
||||
"start": "bun run src/move.ts",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nut-tree-fork/nut-js": "^4.2.2"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"moveInterval": 240,
|
||||
"checkInterval": 10,
|
||||
"stepDelay": 50,
|
||||
"stepCount": 250,
|
||||
"verbose": false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# dev-setup.sh - contributor bootstrap for the `move` repo.
|
||||
#
|
||||
@@ -11,13 +11,18 @@
|
||||
#
|
||||
# This script is idempotent: re-running it just re-resolves the dependency
|
||||
# tree against the existing `bun.lock`.
|
||||
#
|
||||
# POSIX sh; no bashisms. Matches the style of install.sh / uninstall.sh.
|
||||
|
||||
# Fail fast on any error, unset variable, or failed pipe stage.
|
||||
set -euo pipefail
|
||||
# Fail fast on any error or unset variable. (`pipefail` is bash-only and
|
||||
# not strictly needed here; this script doesn't pipe in failure-prone
|
||||
# ways.)
|
||||
set -eu
|
||||
|
||||
# Always operate relative to the script's own directory so the install works
|
||||
# regardless of the caller's CWD.
|
||||
cd "$(dirname "$0")"
|
||||
# Operate at the repo root regardless of the caller's CWD. This script
|
||||
# lives under scripts/, so pop up one level to land at the project root
|
||||
# before running bun install.
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> Checking for Bun..."
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
@@ -35,13 +40,14 @@ bun install
|
||||
echo
|
||||
echo "Done. Dev workflow:"
|
||||
echo " - 'bun run start' to run from the source tree."
|
||||
echo " - 'bun run test' to run the test suite."
|
||||
echo " - 'bun link' to install a global 'move' command pointed at this checkout."
|
||||
echo "End-user installer is install.sh (curl-pipeable from Gitea)."
|
||||
|
||||
# macOS gates synthetic mouse events behind the Accessibility permission.
|
||||
# Without this hint, the first run silently fails to move the cursor and
|
||||
# the user has no obvious next step.
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
echo "Note: macOS will prompt for Accessibility permission on first mouse move."
|
||||
echo " Grant it under System Settings > Privacy & Security > Accessibility."
|
||||
fi
|
||||
@@ -14,15 +14,19 @@
|
||||
# 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. Verify PATH, surface macOS Accessibility hint, print final status.
|
||||
# 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.
|
||||
# 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.
|
||||
|
||||
@@ -37,6 +41,8 @@ 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
|
||||
@@ -44,14 +50,14 @@ die() {
|
||||
}
|
||||
|
||||
# Refuse to operate on a directory that points somewhere catastrophic.
|
||||
# `INSTALL_DIR` derives from XDG_DATA_HOME, a broadly-scoped env var the
|
||||
# user could conceivably set to anything; the install path always
|
||||
# culminates in `.../move`, but a malformed XDG_DATA_HOME could still
|
||||
# resolve to something like '/move' which we don't want to `rm -rf`.
|
||||
assert_safe_install_dir() {
|
||||
case "$INSTALL_DIR" in
|
||||
# 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 INSTALL_DIR='$INSTALL_DIR' (too broad)"
|
||||
die "refusing to operate on '$1' (too broad)"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -96,7 +102,7 @@ printf '==> Using bun %s\n' "$BUN_VERSION"
|
||||
|
||||
# --- Idempotence check -------------------------------------------------------
|
||||
|
||||
assert_safe_install_dir
|
||||
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 '')
|
||||
@@ -152,6 +158,33 @@ chmod +x "$WRAPPER"
|
||||
|
||||
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
|
||||
@@ -8,10 +8,15 @@
|
||||
#
|
||||
# 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_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.
|
||||
|
||||
@@ -19,6 +24,8 @@ 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() {
|
||||
@@ -50,7 +57,16 @@ 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'
|
||||
+34
-27
@@ -9,6 +9,7 @@
|
||||
*
|
||||
* -h, --help Prints `printHelp()` to stdout; entry exits 0.
|
||||
* -v, --version Prints `move <VERSION>` to stdout; entry exits 0.
|
||||
* -C, --config <path> Override the default config-file path.
|
||||
* -m, --move-interval Idle time (seconds) before a sweep fires.
|
||||
* -c, --check-interval Cursor poll cadence (seconds).
|
||||
* -d, --step-delay Pause between synthetic steps (ms).
|
||||
@@ -16,7 +17,7 @@
|
||||
* -V, --verbose Enable per-sweep / interrupt / bounds logging.
|
||||
* (`-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.
|
||||
*/
|
||||
|
||||
@@ -25,35 +26,35 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { DEFAULT_CONFIG } from "./config.ts";
|
||||
import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts";
|
||||
import { CliError } from "./errors.ts";
|
||||
|
||||
/**
|
||||
* Thrown when CLI input is invalid (unknown option, missing value, bad number).
|
||||
* Distinct from runtime errors so the top-level entry can exit with code 2
|
||||
* (user error) instead of code 1 (runtime failure).
|
||||
*/
|
||||
export class CliError extends Error {}
|
||||
|
||||
/**
|
||||
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user did
|
||||
* not supply the flag; this lets `resolveConfig` cleanly distinguish "use the
|
||||
* default" from "explicit override".
|
||||
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user
|
||||
* did not supply the flag; this lets `resolveConfig` cleanly distinguish
|
||||
* "use the layer below" from "explicit override".
|
||||
*/
|
||||
export interface ParsedCliArgs {
|
||||
help: boolean;
|
||||
version: boolean;
|
||||
config: string | undefined;
|
||||
moveInterval: number | undefined; // seconds
|
||||
checkInterval: number | undefined; // seconds
|
||||
stepDelay: number | undefined; // milliseconds
|
||||
stepCount: number | undefined; // pixels
|
||||
verbose: boolean;
|
||||
/**
|
||||
* `true` when `-V`/`--verbose` was passed; `undefined` when it was not.
|
||||
* `undefined` (not `false`) lets the layered resolver distinguish "user
|
||||
* did not specify" from a hypothetical "user explicitly turned off",
|
||||
* even though the CLI has no off-switch today.
|
||||
*/
|
||||
verbose: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a CLI-supplied numeric value. Returns `undefined` if the user did
|
||||
* not supply the flag at all; throws `CliError` on anything that isn't a
|
||||
* positive finite number. Zero is rejected: every numeric tunable here is a
|
||||
* duration or count where zero is meaningless or actively broken.
|
||||
* Validate a CLI-supplied numeric value. Returns `undefined` if the user
|
||||
* did not supply the flag at all; throws `CliError` on anything that isn't
|
||||
* a positive finite number.
|
||||
*/
|
||||
function parsePositiveNumber(name: string, raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
@@ -66,11 +67,8 @@ function parsePositiveNumber(name: string, raw: string | undefined): number | un
|
||||
|
||||
/**
|
||||
* Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
|
||||
* `parseArgs` in strict mode so unknown flags and missing values surface as
|
||||
* `CliError`s that the entry point can turn into exit code 2.
|
||||
*
|
||||
* Numeric flags are stored as `string` by `parseArgs` and then validated by
|
||||
* `parsePositiveNumber`.
|
||||
* `parseArgs` in strict mode so unknown flags and missing values surface
|
||||
* as `CliError`s that the entry point can turn into exit code 2.
|
||||
*/
|
||||
export function parseCliArgs(): ParsedCliArgs {
|
||||
let values: Record<string, string | boolean | undefined>;
|
||||
@@ -80,6 +78,7 @@ export function parseCliArgs(): ParsedCliArgs {
|
||||
options: {
|
||||
help: { type: "boolean", short: "h" },
|
||||
version: { type: "boolean", short: "v" },
|
||||
config: { type: "string", short: "C" },
|
||||
"move-interval": { type: "string", short: "m" },
|
||||
"check-interval": { type: "string", short: "c" },
|
||||
"step-delay": { type: "string", short: "d" },
|
||||
@@ -100,19 +99,20 @@ export function parseCliArgs(): ParsedCliArgs {
|
||||
return {
|
||||
help: Boolean(values.help),
|
||||
version: Boolean(values.version),
|
||||
config: values.config as string | undefined,
|
||||
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
|
||||
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
|
||||
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
|
||||
stepCount: parsePositiveNumber("step-count", values["step-count"] as string | undefined),
|
||||
verbose: Boolean(values.verbose),
|
||||
verbose: values.verbose === true ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the package version from `package.json` at runtime so help/version
|
||||
* output stays in sync with the manifest without a build step. Resolved
|
||||
* relative to this module's own location so the lookup works regardless of
|
||||
* the caller's CWD.
|
||||
* relative to this module's own location so the lookup works regardless
|
||||
* of the caller's CWD.
|
||||
*/
|
||||
export const VERSION: string = (() => {
|
||||
// This module lives in `src/`, so `package.json` is one directory up.
|
||||
@@ -123,12 +123,14 @@ export const VERSION: string = (() => {
|
||||
|
||||
/**
|
||||
* Write the usage block to stdout. Default values are pulled from
|
||||
* `DEFAULT_CONFIG` (converted to the units the CLI exposes) so the help
|
||||
* text never drifts from the actual defaults.
|
||||
* `DEFAULT_CONFIG` (converted to the units the CLI exposes); the default
|
||||
* config-file path is computed by `defaultConfigPath`. Both make the help
|
||||
* text self-updating when their sources change.
|
||||
*/
|
||||
export function printHelp(): void {
|
||||
const moveDefaultSec: number = DEFAULT_CONFIG.moveInterval / 1000;
|
||||
const checkDefaultSec: number = DEFAULT_CONFIG.checkInterval / 1000;
|
||||
const cfgPath: string = defaultConfigPath();
|
||||
|
||||
process.stdout.write(`Usage: move [options]
|
||||
|
||||
@@ -138,6 +140,8 @@ nudging the mouse cursor after a configurable idle period.
|
||||
Options:
|
||||
-h, --help Show this help and exit.
|
||||
-v, --version Print version and exit.
|
||||
-C, --config <path> Load defaults from a JSON config file.
|
||||
Default path: ${cfgPath}
|
||||
-m, --move-interval <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}.
|
||||
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
|
||||
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
|
||||
@@ -145,9 +149,12 @@ Options:
|
||||
-V, --verbose Log every sweep, interrupt, and bounds event
|
||||
(default prints only the startup banner).
|
||||
|
||||
Precedence (highest wins): CLI flags > config file > built-in defaults.
|
||||
|
||||
Examples:
|
||||
move
|
||||
move --move-interval 180 --check-interval 5
|
||||
move -m 300 -V
|
||||
move --config ~/myprofile.json
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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 "./config.ts";
|
||||
import type { ConfigOverrides } from "./config.ts";
|
||||
import { CliError } from "./errors.ts";
|
||||
|
||||
const NONE: ConfigOverrides = {
|
||||
moveInterval: undefined,
|
||||
checkInterval: undefined,
|
||||
stepDelay: undefined,
|
||||
stepCount: 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 and stepCount pass through untouched (no unit conversion)", () => {
|
||||
const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100 };
|
||||
const cfg = resolveConfig(null, cli);
|
||||
expect(cfg.stepDelay).toBe(75);
|
||||
expect(cfg.stepCount).toBe(100);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
+154
-33
@@ -1,20 +1,35 @@
|
||||
/**
|
||||
* config.ts
|
||||
* ---------
|
||||
* Runtime configuration types, defaults, and the CLI->config resolver.
|
||||
* Runtime configuration types, defaults, the layered resolver, and the
|
||||
* default config-file path.
|
||||
*
|
||||
* The keeper is driven by a single `Config` object that carries the four
|
||||
* tunables it cares about. Defaults live in `DEFAULT_CONFIG`; CLI overrides
|
||||
* are layered on top by `resolveConfig` rather than mutating the defaults,
|
||||
* so the defaults stay genuinely constant and the resolved config stays
|
||||
* structurally typed.
|
||||
* The keeper is driven by a single `Config` object that carries every
|
||||
* tunable it cares about, including the `verbose` flag. Defaults live in
|
||||
* `DEFAULT_CONFIG`; CLI and config-file overrides are layered on top by
|
||||
* `resolveConfig` rather than mutating the defaults, so the defaults stay
|
||||
* genuinely constant and the resolved config stays structurally typed.
|
||||
*
|
||||
* All fields are in their internal units (ms, pixels). The CLI exposes the
|
||||
* time-valued fields in seconds for ergonomics; `resolveConfig` performs
|
||||
* the seconds->ms conversion at the boundary so downstream code never has
|
||||
* to think about it.
|
||||
* All numeric `Config` fields are in their internal units (ms, pixels).
|
||||
* The CLI and config file expose the time-valued fields in seconds for
|
||||
* ergonomics; `resolveConfig` performs the seconds->ms conversion at the
|
||||
* boundary so downstream code never has to think about it.
|
||||
*
|
||||
* Layering precedence (highest wins):
|
||||
* CLI overrides > file overrides > DEFAULT_CONFIG
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
|
||||
import { CliError } from "./errors.ts";
|
||||
|
||||
// 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, pixels for stepCount);
|
||||
// the seconds->ms conversion happens below where DEFAULT_CONFIG is built.
|
||||
import seedRaw from "../scripts/config.default.json" with { type: "json" };
|
||||
|
||||
/**
|
||||
* The shape of a resolved runtime configuration. `readonly` to make
|
||||
* accidental mutation a type error.
|
||||
@@ -27,50 +42,156 @@
|
||||
* a sweep. Also the window in which the user can
|
||||
* "interrupt" by moving the cursor. Milliseconds.
|
||||
* - `stepCount` — number of pixel-steps in a single sweep. Pixels.
|
||||
* - `verbose` — whether per-sweep / interrupt / bounds events are
|
||||
* logged. The startup banner is always printed.
|
||||
*/
|
||||
export interface Config {
|
||||
readonly moveInterval: number;
|
||||
readonly checkInterval: number;
|
||||
readonly stepDelay: number;
|
||||
readonly stepCount: number;
|
||||
readonly verbose: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in defaults used when the user did not supply an explicit CLI
|
||||
* override for the corresponding flag.
|
||||
* Shape of `scripts/config.default.json` after parsing. The cast below
|
||||
* trusts the file's structure; `assertSeedShape` performs a small runtime
|
||||
* sanity check at import time so a corrupted seed file fails loudly
|
||||
* instead of silently producing `NaN` or `undefined` defaults.
|
||||
*/
|
||||
interface SeedShape {
|
||||
moveInterval: number; // seconds
|
||||
checkInterval: number; // seconds
|
||||
stepDelay: number; // milliseconds
|
||||
stepCount: number; // pixels
|
||||
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", "stepCount"] 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.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 = {
|
||||
moveInterval: 4 * 60 * 1000, // 4 minutes in milliseconds
|
||||
checkInterval: 10 * 1000, // Check every 10 seconds
|
||||
stepDelay: 50, // ms between mouse steps
|
||||
stepCount: 250, // pixels to move per sweep
|
||||
moveInterval: seed.moveInterval * 1000,
|
||||
checkInterval: seed.checkInterval * 1000,
|
||||
stepDelay: seed.stepDelay,
|
||||
stepCount: seed.stepCount,
|
||||
verbose: seed.verbose,
|
||||
};
|
||||
|
||||
/**
|
||||
* Subset of `ParsedCliArgs` that `resolveConfig` actually consumes. Declared
|
||||
* locally instead of importing from `cli.ts` to keep the dependency arrow
|
||||
* pointing one way (cli -> config), which lets `config.ts` stay a leaf
|
||||
* module with no internal imports.
|
||||
* Common shape for override layers (CLI args and config-file content).
|
||||
*
|
||||
* `undefined` means "this layer doesn't supply a value"; the next layer
|
||||
* down (file overrides, then DEFAULT_CONFIG) is consulted in that case.
|
||||
*
|
||||
* Numeric fields are in CLI / config-file units:
|
||||
* moveInterval, checkInterval — seconds
|
||||
* stepDelay — milliseconds
|
||||
* stepCount — pixels
|
||||
*
|
||||
* `verbose` is `boolean | undefined` like the numeric fields, so all five
|
||||
* fields share 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 {
|
||||
readonly moveInterval: number | undefined; // seconds (CLI units)
|
||||
readonly checkInterval: number | undefined; // seconds (CLI units)
|
||||
readonly stepDelay: number | undefined; // milliseconds
|
||||
readonly stepCount: number | undefined; // pixels
|
||||
readonly moveInterval: number | undefined;
|
||||
readonly checkInterval: number | undefined;
|
||||
readonly stepDelay: number | undefined;
|
||||
readonly stepCount: number | undefined;
|
||||
readonly verbose: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay user-supplied CLI values on top of `DEFAULT_CONFIG` and return a
|
||||
* resolved `Config`. Time-valued CLI inputs (move/check interval) are in
|
||||
* seconds; this is where they're converted to milliseconds for internal use.
|
||||
* Resolve the default config-file path per the XDG Base Directory Spec.
|
||||
* Honors `$XDG_CONFIG_HOME` if set; otherwise falls back to
|
||||
* `$HOME/.config`. The file itself is always `move/config.json` under
|
||||
* that base.
|
||||
*
|
||||
* Any field left `undefined` in the overrides falls back to the default.
|
||||
* Computed at call time (not at module load) so tests can override
|
||||
* `XDG_CONFIG_HOME` after import.
|
||||
*/
|
||||
export function resolveConfig(overrides: ConfigOverrides): Config {
|
||||
export function defaultConfigPath(): string {
|
||||
const xdg = process.env.XDG_CONFIG_HOME;
|
||||
if (xdg && xdg.length > 0) {
|
||||
return join(xdg, "move", "config.json");
|
||||
}
|
||||
const home = process.env.HOME;
|
||||
if (!home || home.length === 0) {
|
||||
// Neither var is set; we don't have a sensible fallback. Throwing
|
||||
// CliError lets the entry-point's normal handler surface this as
|
||||
// 'move: cannot resolve default config path: ...' + exit 2 instead
|
||||
// of silently producing '/.config/move/config.json' and bewildering
|
||||
// the user with a downstream 'file not found' message.
|
||||
throw new CliError(
|
||||
"cannot resolve default config path: neither $XDG_CONFIG_HOME nor $HOME is set",
|
||||
);
|
||||
}
|
||||
return join(home, ".config", "move", "config.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay file overrides (lowest priority) and CLI overrides (highest)
|
||||
* on top of `DEFAULT_CONFIG` and return a resolved `Config`. Time-valued
|
||||
* numeric inputs are in seconds; this is where they're converted to
|
||||
* milliseconds for internal use.
|
||||
*
|
||||
* For each field, the first layer that supplies a defined value wins:
|
||||
* CLI -> file -> DEFAULT_CONFIG
|
||||
*/
|
||||
export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides): Config {
|
||||
const pickSeconds = (
|
||||
cliVal: number | undefined,
|
||||
fileVal: number | undefined,
|
||||
fallbackMs: number,
|
||||
): number => {
|
||||
if (cliVal !== undefined) return cliVal * 1000;
|
||||
if (fileVal !== undefined) return fileVal * 1000;
|
||||
return fallbackMs;
|
||||
};
|
||||
|
||||
const pickRaw = <T>(
|
||||
cliVal: T | undefined,
|
||||
fileVal: T | undefined,
|
||||
fallback: T,
|
||||
): T => {
|
||||
if (cliVal !== undefined) return cliVal;
|
||||
if (fileVal !== undefined) return fileVal;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
moveInterval: overrides.moveInterval !== undefined ? overrides.moveInterval * 1000 : DEFAULT_CONFIG.moveInterval,
|
||||
checkInterval: overrides.checkInterval !== undefined ? overrides.checkInterval * 1000 : DEFAULT_CONFIG.checkInterval,
|
||||
stepDelay: overrides.stepDelay ?? DEFAULT_CONFIG.stepDelay,
|
||||
stepCount: overrides.stepCount ?? DEFAULT_CONFIG.stepCount,
|
||||
moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval),
|
||||
checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval),
|
||||
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
|
||||
stepCount: pickRaw(cli.stepCount, file?.stepCount, DEFAULT_CONFIG.stepCount),
|
||||
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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 "./configFile.ts";
|
||||
import { CliError } from "./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!.stepCount).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({ stepCount: -1 }));
|
||||
expect(() => loadConfigFile(negative)).toThrow(/'stepCount'.*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/);
|
||||
});
|
||||
});
|
||||
|
||||
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,138 @@
|
||||
/**
|
||||
* 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
|
||||
* stepCount number pixels, positive
|
||||
* 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.
|
||||
*
|
||||
* 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";
|
||||
|
||||
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
|
||||
"moveInterval",
|
||||
"checkInterval",
|
||||
"stepDelay",
|
||||
"stepCount",
|
||||
"verbose",
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' that would otherwise sail through silently.
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (!ALLOWED_KEYS.has(key)) {
|
||||
const allowed: string = [...ALLOWED_KEYS].join(", ");
|
||||
throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
stepCount:
|
||||
"stepCount" in parsed
|
||||
? requirePositiveNumber("stepCount", parsed.stepCount, path)
|
||||
: undefined,
|
||||
verbose:
|
||||
"verbose" in parsed
|
||||
? requireBoolean("verbose", parsed.verbose, path)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -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 {}
|
||||
+37
-20
@@ -5,29 +5,21 @@
|
||||
* real-user-wins semantics, plus the idle-watch loop that drives it.
|
||||
*
|
||||
* Runtime: Bun (uses `@nut-tree-fork/nut-js` for cross-platform mouse +
|
||||
* screen). Importing this module sets `mouse.config.autoDelayMs = 0` as a
|
||||
* side effect — see below.
|
||||
* screen). The nut.js auto-delay is disabled inside `runKeeper`, not at
|
||||
* module load, so importing this module is side-effect-free.
|
||||
*
|
||||
* Logging policy:
|
||||
* - The startup banner in `runKeeper` is unconditional so the user always
|
||||
* sees confirmation that the process is alive.
|
||||
* - Every per-sweep / interrupt / bounds log is gated by `verbose` so the
|
||||
* default is quiet. Errors stay on `console.error` (unconditional, raised
|
||||
* by the entry point on unhandled rejection).
|
||||
* - Every per-sweep / interrupt / bounds log is gated by `config.verbose`
|
||||
* so the default is quiet. Errors stay on `console.error` (unconditional,
|
||||
* raised by the entry point on unhandled rejection).
|
||||
*/
|
||||
|
||||
import { mouse, Point, screen } from "@nut-tree-fork/nut-js";
|
||||
|
||||
import type { Config } from "./config.ts";
|
||||
|
||||
/**
|
||||
* nut.js inserts a configurable delay after every action (default 100ms).
|
||||
* That default would silently more-than-double the duration of every
|
||||
* `setPosition` and `getPosition` call. We drive cadence ourselves via
|
||||
* `Config.stepDelay`, so disable nut.js's implicit delay entirely.
|
||||
*/
|
||||
mouse.config.autoDelayMs = 0;
|
||||
|
||||
/**
|
||||
* Promise-based `setTimeout` wrapper. Allows `await sleep(ms)` ergonomics.
|
||||
*
|
||||
@@ -49,11 +41,25 @@ const timestamp = (): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Minimal log surface used by `simulateActivity` and `runKeeper`. Named so
|
||||
* it can appear directly in function signatures (clearer than
|
||||
* `ReturnType<typeof makeLogger>`) and so a test could substitute a fake
|
||||
* implementation if needed.
|
||||
*
|
||||
* - `info(msg)` prints unconditionally.
|
||||
* - `event(msg)` prints only when `--verbose` / `verbose: true` is set.
|
||||
*/
|
||||
function makeLogger(verbose: boolean): { info(msg: string): void; event(msg: string): void } {
|
||||
interface Logger {
|
||||
info(msg: string): void;
|
||||
event(msg: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): Logger {
|
||||
return {
|
||||
info: (msg: string): void => {
|
||||
console.log(msg);
|
||||
@@ -87,7 +93,7 @@ function makeLogger(verbose: boolean): { info(msg: string): void; event(msg: str
|
||||
* 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): Promise<void> {
|
||||
const start: Point = await mouse.getPosition();
|
||||
const screenWidth: number = await screen.width();
|
||||
const screenHeight: number = await screen.height();
|
||||
@@ -144,8 +150,19 @@ async function simulateActivity(config: Config, log: ReturnType<typeof makeLogge
|
||||
* next position check matches), and on a user-interrupted sweep the next
|
||||
* iteration sees the user's new position and correctly resets the clock.
|
||||
*/
|
||||
export async function runKeeper(config: Config, verbose: boolean): Promise<void> {
|
||||
const log = makeLogger(verbose);
|
||||
export async function runKeeper(config: Config): Promise<void> {
|
||||
// nut.js inserts a configurable delay after every action (default 100ms).
|
||||
// That default would silently more-than-double the duration of every
|
||||
// setPosition and getPosition call. We drive cadence ourselves via
|
||||
// config.stepDelay, so disable nut.js's implicit delay entirely.
|
||||
//
|
||||
// Setting this here (rather than at module load) keeps `keeper.ts` free
|
||||
// of import-time side effects on the shared nut.js singleton — useful
|
||||
// for tests and any future code path that imports this module without
|
||||
// actually running the loop.
|
||||
mouse.config.autoDelayMs = 0;
|
||||
|
||||
const log = makeLogger(config.verbose);
|
||||
log.info("Teams Status Keeper started. Press Ctrl+C to stop.");
|
||||
|
||||
let lastPos: Point = await mouse.getPosition();
|
||||
|
||||
+69
-25
@@ -4,18 +4,21 @@
|
||||
* -------
|
||||
* Entry point for the `move` CLI.
|
||||
*
|
||||
* Thin shim that ties the three logic modules together:
|
||||
* - `cli.ts` parses and validates `process.argv`.
|
||||
* - `config.ts` holds the default tunables and the `resolveConfig` overlay.
|
||||
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
|
||||
* Thin shim that ties the four logic modules together:
|
||||
* - `cli.ts` parses and validates `process.argv`.
|
||||
* - `configFile.ts` loads and validates the JSON config file.
|
||||
* - `config.ts` holds defaults and the layered `resolveConfig` overlay.
|
||||
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
|
||||
*
|
||||
* Order of operations:
|
||||
* 1. Parse CLI args. Bad input -> stderr message + short usage hint, exit 2.
|
||||
* 2. `--help` / `--version` short-circuit before any mouse work happens.
|
||||
* 3. Resolve CLI overrides on top of `DEFAULT_CONFIG` and hand the result
|
||||
* (plus the verbose flag) to `runKeeper`.
|
||||
* 4. Any unhandled rejection from `runKeeper` is logged and exits with
|
||||
* code 1 so it's catchable by shells / supervisors.
|
||||
* 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
|
||||
* 2. `--help` / `--version` short-circuit before any I/O or mouse work.
|
||||
* 3. Load + validate the config file (default XDG path, or `--config
|
||||
* <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. Run keeper. Any unhandled rejection exits 1.
|
||||
*
|
||||
* Runtime: Bun (uses `@nut-tree-fork/nut-js` via `keeper.ts`). The shebang
|
||||
* above lets this file run as a real CLI once linked via `bun link`.
|
||||
@@ -24,19 +27,53 @@
|
||||
import { parseCliArgs, printHelp, VERSION } from "./cli.ts";
|
||||
import type { ParsedCliArgs } from "./cli.ts";
|
||||
import { resolveConfig } from "./config.ts";
|
||||
import type { ConfigOverrides } from "./config.ts";
|
||||
import { loadConfigFile } from "./configFile.ts";
|
||||
import { runKeeper } from "./keeper.ts";
|
||||
|
||||
let cliArgs: ParsedCliArgs;
|
||||
try {
|
||||
cliArgs = parseCliArgs();
|
||||
} catch (err: unknown) {
|
||||
/**
|
||||
* Print a user-error message and exit 2. Used for anything that comes from
|
||||
* invalid input: unknown CLI flags, bad numbers, malformed or missing
|
||||
* config files, unresolvable default paths. The accompanying "Try 'move
|
||||
* --help'" pointer is appropriate for these cases.
|
||||
*
|
||||
* Returns `never` so callers can invoke it without TypeScript flagging
|
||||
* "variable might be undefined" downstream.
|
||||
*/
|
||||
function failUser(err: unknown): never {
|
||||
const msg: string = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`move: ${msg}\nTry 'move --help' for more information.\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a runtime-failure message and exit 1. Used for anything the user
|
||||
* couldn't have prevented from the command line: nut.js errors, missing
|
||||
* Accessibility permission on macOS, unexpected exceptions from the
|
||||
* keeper loop. Prefers the stack trace when available since these failures
|
||||
* usually need a developer to interpret.
|
||||
*/
|
||||
function failRuntime(err: unknown): never {
|
||||
const detail: string = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
||||
process.stderr.write(`move: runtime error: ${detail}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let cliArgs: ParsedCliArgs;
|
||||
try {
|
||||
cliArgs = parseCliArgs();
|
||||
} catch (err: unknown) {
|
||||
failUser(err);
|
||||
}
|
||||
|
||||
if (cliArgs.help) {
|
||||
printHelp();
|
||||
// printHelp() resolves defaultConfigPath(), which can throw CliError
|
||||
// when neither $XDG_CONFIG_HOME nor $HOME is set.
|
||||
try {
|
||||
printHelp();
|
||||
} catch (err: unknown) {
|
||||
failUser(err);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
if (cliArgs.version) {
|
||||
@@ -44,14 +81,21 @@ if (cliArgs.version) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const config = resolveConfig({
|
||||
moveInterval: cliArgs.moveInterval,
|
||||
checkInterval: cliArgs.checkInterval,
|
||||
stepDelay: cliArgs.stepDelay,
|
||||
stepCount: cliArgs.stepCount,
|
||||
});
|
||||
let fileOverrides: ConfigOverrides | null;
|
||||
try {
|
||||
fileOverrides = loadConfigFile(cliArgs.config);
|
||||
} catch (err: unknown) {
|
||||
failUser(err);
|
||||
}
|
||||
|
||||
runKeeper(config, cliArgs.verbose).catch((err: unknown): void => {
|
||||
console.error("Error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
const cliOverrides: ConfigOverrides = {
|
||||
moveInterval: cliArgs.moveInterval,
|
||||
checkInterval: cliArgs.checkInterval,
|
||||
stepDelay: cliArgs.stepDelay,
|
||||
stepCount: cliArgs.stepCount,
|
||||
verbose: cliArgs.verbose,
|
||||
};
|
||||
|
||||
const config = resolveConfig(fileOverrides, cliOverrides);
|
||||
|
||||
runKeeper(config).catch(failRuntime);
|
||||
|
||||
+3
-1
@@ -8,7 +8,9 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true
|
||||
"verbatimModuleSyntax": true,
|
||||
"resolveJsonModule": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user