18 Commits

Author SHA1 Message Date
nokeo08 cff1c482a3 v1.2.0
Notable changes since v1.1.1:
- New -e/--edit flag opens the active config file in $EDITOR.
- Lazy import of keeper.ts so --help and --version skip the nut.js
  native load on cold start.
- Various audit cleanups: failRuntime() mirror, named Logger interface,
  errors.ts module, autoDelayMs moved out of module-load side effects,
  defaultConfigPath guards against unset HOME, noUncheckedIndexedAccess
  enabled in tsconfig.
- Added Bun test suite (34 tests across resolveConfig, loadConfigFile,
  editConfig).
- scripts/dev-setup.sh made POSIX-portable.
2026-06-17 22:20:59 -05:00
nokeo08 10dcc1791a Add -e/--edit flag: open config file in $EDITOR
New module src/editor.ts handles the flag end-to-end:

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

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

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

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

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

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

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

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

  $ move --version
  move 1.1.1

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Changes:

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

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

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

CLI flags continue to win when both are set:

  CLI flags  >  config file  >  built-in defaults

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

Changes:

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

Behavior is unchanged. The curl one-liner URL changes from
.../master/install.sh to .../master/scripts/install.sh; users following
the pre-v1.0.2 README will get a 404 from the old URL.
2026-06-17 12:17:32 -05:00
17 changed files with 1137 additions and 152 deletions
+123 -17
View File
@@ -20,7 +20,7 @@ cursor leaves the position the script just commanded.
## Install ## Install
```sh ```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` This fetches the latest `master` from Gitea, runs `bun install --production`
@@ -61,12 +61,21 @@ whichever step was last commanded — by design.
## Uninstall ## Uninstall
```sh ```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 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. `$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
@@ -75,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. -n, --step-count <pixels> Steps per sweep. Default: 250.
-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.
``` ```
Numeric overrides are layered onto the defaults via `resolveConfig` in Run `move --help` for the resolved default config-file path on your
`src/config.ts`; time-valued inputs (`-m`, `-c`) are expressed in seconds system. Numeric overrides are layered onto the defaults via
at the CLI boundary and converted to milliseconds internally. `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 Logging is **quiet by default**: only the startup banner ("Teams Status
Keeper started…") and any error from an unhandled rejection print on a Keeper started…") and any error from an unhandled rejection print on a
@@ -95,17 +111,102 @@ 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,
"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.
### 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.
- `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 four 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/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. - `src/keeper.ts` owns the synthetic-activity sweep and the idle-watch loop.
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`: Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
@@ -159,11 +260,12 @@ Clone the repo and bootstrap a dev environment:
```sh ```sh
git clone https://gitea.cahlen.com/nokeo08/Move.git git clone https://gitea.cahlen.com/nokeo08/Move.git
cd Move cd Move
./dev-setup.sh ./scripts/dev-setup.sh
``` ```
`dev-setup.sh` verifies Bun is installed and runs `bun install` (with `scripts/dev-setup.sh` verifies Bun is installed and runs `bun install`
devDependencies, unlike the end-user `install.sh`). (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: Run from the source tree:
@@ -184,12 +286,16 @@ move --help
| File | Purpose | | File | Purpose |
| ------------------- | ----------------------------------------------------------------------------- | | ------------------- | ----------------------------------------------------------------------------- |
| `install.sh` | End-user installer; curl-pipeable from Gitea. | | `scripts/install.sh` | End-user installer; curl-pipeable from Gitea. |
| `uninstall.sh` | End-user uninstaller; curl-pipeable from Gitea. | | `scripts/uninstall.sh` | End-user uninstaller; curl-pipeable from Gitea. |
| `dev-setup.sh` | Contributor bootstrap (verify Bun + `bun install`). | | `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/move.ts` | CLI entry point: parses args, dispatches help/version, starts the loop. |
| `src/cli.ts` | Argument parsing, validation, and help/version output. | | `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/editor.ts` | `move --edit`: opens the active config file in `$EDITOR`. |
| `src/errors.ts` | Shared error types (`CliError`). |
| `src/keeper.ts` | Synthetic-activity sweep and idle-watch loop. | | `src/keeper.ts` | Synthetic-activity sweep and idle-watch loop. |
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. | | `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). | | `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "move", "name": "move",
"version": "1.0.0", "version": "1.2.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"
+7
View File
@@ -0,0 +1,7 @@
{
"moveInterval": 240,
"checkInterval": 10,
"stepDelay": 50,
"stepCount": 250,
"verbose": false
}
+13 -7
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env sh
# #
# dev-setup.sh - contributor bootstrap for the `move` repo. # 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 # 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
@@ -35,13 +40,14 @@ bun install
echo echo
echo "Done. Dev workflow:" echo "Done. Dev workflow:"
echo " - 'bun run start' to run from the source tree." 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 " - 'bun link' to install a global 'move' command pointed at this checkout."
echo "End-user installer is install.sh (curl-pipeable from Gitea)." 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
+48 -15
View File
@@ -14,15 +14,19 @@
# 5. Download the source tarball from Gitea, extract under the install dir. # 5. Download the source tarball from Gitea, extract under the install dir.
# 6. `bun install --production` (skips devDependencies). # 6. `bun install --production` (skips devDependencies).
# 7. Drop a small wrapper script as `move` on the user's bin dir. # 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): # Env vars (all optional):
# MOVE_VERSION Branch or tag to install. Default: master. # MOVE_VERSION Branch or tag to install. Default: master.
# MOVE_FORCE Set to 1 to reinstall even if the version marker matches. # MOVE_FORCE Set to 1 to reinstall even if the version marker matches.
# XDG_DATA_HOME Source install root (default $HOME/.local/share). # XDG_DATA_HOME Source install root (default $HOME/.local/share).
# Final source location is $XDG_DATA_HOME/move. # Final source location is $XDG_DATA_HOME/move.
# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin). # XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin).
# Final binary location is $XDG_BIN_HOME/move. # 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. # POSIX sh; no bashisms.
@@ -37,6 +41,8 @@ MOVE_FORCE="${MOVE_FORCE:-0}"
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move" INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move"
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}" BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/move"
CONFIG_FILE="$CONFIG_DIR/config.json"
die() { die() {
printf 'Error: %s\n' "$1" >&2 printf 'Error: %s\n' "$1" >&2
@@ -44,14 +50,14 @@ die() {
} }
# Refuse to operate on a directory that points somewhere catastrophic. # Refuse to operate on a directory that points somewhere catastrophic.
# `INSTALL_DIR` derives from XDG_DATA_HOME, a broadly-scoped env var the # The XDG_* env vars are broadly-scoped and the user could set them to
# user could conceivably set to anything; the install path always # anything; our paths always culminate in `.../move`, but a malformed
# culminates in `.../move`, but a malformed XDG_DATA_HOME could still # XDG var could still resolve to something like '/move' which we don't
# resolve to something like '/move' which we don't want to `rm -rf`. # want to `rm -rf` or otherwise mass-write into.
assert_safe_install_dir() { assert_safe_dir() {
case "$INSTALL_DIR" in case "$1" in
'' | '/' | "$HOME" | "$HOME/" | '/move') '' | '/' | "$HOME" | "$HOME/" | '/move')
die "refusing to operate on INSTALL_DIR='$INSTALL_DIR' (too broad)" die "refusing to operate on '$1' (too broad)"
;; ;;
esac esac
} }
@@ -96,7 +102,7 @@ printf '==> Using bun %s\n' "$BUN_VERSION"
# --- Idempotence check ------------------------------------------------------- # --- Idempotence check -------------------------------------------------------
assert_safe_install_dir assert_safe_dir "$INSTALL_DIR"
if [ "$MOVE_FORCE" != "1" ] && [ -f "$INSTALL_DIR/.installed-version" ]; then if [ "$MOVE_FORCE" != "1" ] && [ -f "$INSTALL_DIR/.installed-version" ]; then
CURRENT=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '') 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" 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 ------------------------------------------------------- # --- PATH sanity check -------------------------------------------------------
case ":$PATH:" in case ":$PATH:" in
+18 -2
View File
@@ -8,10 +8,15 @@
# #
# Removes the `move` wrapper from $XDG_BIN_HOME and the install tree from # 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. # $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): # Env vars (must match what install.sh used):
# XDG_DATA_HOME Source install root (default $HOME/.local/share). # XDG_DATA_HOME Source install root (default $HOME/.local/share).
# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin). # XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin).
# XDG_CONFIG_HOME User config root (default $HOME/.config).
# #
# POSIX sh; no bashisms. # POSIX sh; no bashisms.
@@ -19,6 +24,8 @@ set -eu
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move" INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move"
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}" 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" WRAPPER="$BIN_DIR/move"
die() { die() {
@@ -50,7 +57,16 @@ fi
if [ "$REMOVED_SOMETHING" = "0" ]; then if [ "$REMOVED_SOMETHING" = "0" ]; then
printf 'Nothing to remove. Checked %s and %s.\n' "$WRAPPER" "$INSTALL_DIR" 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 exit 0
fi 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' printf '\nUninstalled. (Bun was not touched.)\n'
+41 -27
View File
@@ -9,6 +9,10 @@
* *
* -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).
@@ -16,7 +20,7 @@
* -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,36 @@ 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";
/** /**
* 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 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 * 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;
@@ -66,11 +71,8 @@ function parsePositiveNumber(name: string, raw: string | undefined): number | un
/** /**
* 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,6 +82,8 @@ 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" },
@@ -100,19 +104,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), 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 * 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 +129,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,6 +146,9 @@ 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}.
@@ -145,9 +156,12 @@ Options:
-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 --config ~/myprofile.json
`); `);
} }
+154 -33
View File
@@ -1,20 +1,35 @@
/** /**
* 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";
// 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 * The shape of a resolved runtime configuration. `readonly` to make
* accidental mutation a type error. * accidental mutation a type error.
@@ -27,50 +42,156 @@
* 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. * - `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 { 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 stepCount: number;
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
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 = { 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 stepCount: seed.stepCount,
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
* 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 { 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 stepCount: number | 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, stepCount: pickRaw(cli.stepCount, file?.stepCount, DEFAULT_CONFIG.stepCount),
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
}; };
} }
+138
View File
@@ -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,
};
}
+104
View File
@@ -0,0 +1,104 @@
/**
* editor.test.ts
* --------------
* Unit tests for the `--edit` helper. The spawn path is not exercised
* (would actually launch $EDITOR); instead we test:
* - the pure argv-construction helper, and
* - the two refusal paths ($EDITOR unset, file missing).
*
* Run via `bun test`.
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { editConfig, editorCommand } from "./editor.ts";
import { CliError } from "./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"), "{}");
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* editor.ts
* ---------
* `move --edit` support: open the active config file in `$EDITOR`.
*
* Refuses (CliError -> exit 2) when:
* - `$EDITOR` is unset or empty.
* - The target config file does not exist.
*
* Otherwise spawns `$EDITOR <path>` with the terminal attached, waits for
* it to exit, and propagates its exit code.
*
* Editor command parsing: `$EDITOR` is often a single word (`vim`,
* `nano`) but can include flags (`code --wait`, `emacs -nw`). We delegate
* to the shell so the value's own quoting / word-splitting Just Works:
*
* sh -c '<editor> "$@"' -- <path>
*
* The editor string is interpolated into the script body, so the shell
* tokenizes it normally (splitting `code --wait` into argv elements).
* The `--` placeholder takes the `$0` slot so `"$@"` is just our path.
* Same approach git uses to invoke `GIT_EDITOR`.
*
* Caveat: because `$EDITOR` is interpolated, shell metacharacters in its
* value WILL be interpreted (this matches git/vipe/most tools). That is a
* non-issue under the standard threat model — the user sets `$EDITOR`
* themselves — and would be impossible to handle differently without
* writing our own POSIX tokenizer.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { CliError } from "./errors.ts";
/**
* Pure helper that builds the argv we hand to the shell. Extracted so
* `editor.test.ts` can verify the construction without actually launching
* an editor.
*/
export function editorCommand(editor: string, path: string): readonly string[] {
// Editor is interpolated into the script body so the shell tokenizes
// multi-word values like 'code --wait'. The '--' takes the $0 slot;
// path becomes $1 / "$@".
return ["sh", "-c", `${editor} "$@"`, "--", path];
}
/**
* Launch `$EDITOR` on the given config path. Never returns: exits with the
* editor's status code (or 1 if it was killed by a signal).
*/
export function editConfig(path: string): never {
const editor = process.env.EDITOR;
if (editor === undefined || editor.length === 0) {
throw new CliError(
"$EDITOR is not set. Set it (e.g., 'export EDITOR=vim') and re-run.",
);
}
if (!existsSync(path)) {
throw new CliError(
`no config file at ${path}. Run 'move' once to start using defaults, or reinstall to re-seed the file.`,
);
}
// We build the argv via the pure helper, then unpack to satisfy
// spawnSync's (command, args, options) signature.
const argv: readonly string[] = editorCommand(editor, path);
const [command, ...args] = argv;
if (command === undefined) {
// Defensive: editorCommand always returns a non-empty array.
throw new CliError("internal: editor command construction produced an empty argv");
}
const result = spawnSync(command, args, { stdio: "inherit" });
if (result.error !== undefined) {
throw new CliError(`failed to launch $EDITOR: ${result.error.message}`);
}
// status is `number | null` (null when signal-killed). Exit 1 in the
// null case so the caller sees a non-zero, machine-readable status.
process.exit(result.status ?? 1);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* errors.ts
* ---------
* Cross-cutting error types used by parsers, loaders, and config resolution.
* Kept in its own module so feature modules can import error types without
* pulling in unrelated implementation code (e.g., the JSON loader doesn't
* need to depend on the CLI parser just to throw a typed error).
*/
/**
* Thrown when user-supplied input is invalid: unknown CLI option, missing
* value, non-positive number, malformed config file, unresolvable default
* path, etc. Distinct from runtime errors so the top-level entry can exit
* with code 2 (user error) instead of code 1 (runtime failure).
*/
export class CliError extends Error {}
+37 -20
View File
@@ -5,29 +5,21 @@
* real-user-wins semantics, plus the idle-watch loop that drives it. * real-user-wins semantics, plus the idle-watch loop that drives it.
* *
* Runtime: Bun (uses `@nut-tree-fork/nut-js` for cross-platform mouse + * Runtime: Bun (uses `@nut-tree-fork/nut-js` for cross-platform mouse +
* screen). Importing this module sets `mouse.config.autoDelayMs = 0` as a * screen). The nut.js auto-delay is disabled inside `runKeeper`, not at
* side effect — see below. * module load, so importing this module is side-effect-free.
* *
* 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 confirmation that the process is alive.
* - Every per-sweep / interrupt / bounds log is gated by `verbose` so the * - Every per-sweep / interrupt / bounds log is gated by `config.verbose`
* default is quiet. Errors stay on `console.error` (unconditional, raised * so the default is quiet. Errors stay on `console.error` (unconditional,
* by the entry point on unhandled rejection). * raised by the entry point on unhandled rejection).
*/ */
import { mouse, Point, screen } from "@nut-tree-fork/nut-js"; import { mouse, Point, screen } from "@nut-tree-fork/nut-js";
import type { Config } from "./config.ts"; import type { Config } from "./config.ts";
/**
* nut.js inserts a configurable delay after every action (default 100ms).
* That default would silently more-than-double the duration of every
* `setPosition` and `getPosition` call. We drive cadence ourselves via
* `Config.stepDelay`, so disable nut.js's implicit delay entirely.
*/
mouse.config.autoDelayMs = 0;
/** /**
* Promise-based `setTimeout` wrapper. Allows `await sleep(ms)` ergonomics. * 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 * Minimal log surface used by `simulateActivity` and `runKeeper`. Named so
* when the caller asked for verbose output. Returning a small object keeps * it can appear directly in function signatures (clearer than
* `simulateActivity` free of `if (verbose)` noise at every log site. * `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 { return {
info: (msg: string): void => { info: (msg: string): void => {
console.log(msg); 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 * so the next idle-check sees "no movement" and doesn't misread the
* synthetic activity as the user returning. * 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 start: Point = await mouse.getPosition();
const screenWidth: number = await screen.width(); const screenWidth: number = await screen.width();
const screenHeight: number = await screen.height(); 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 * next position check matches), and on a user-interrupted sweep the next
* iteration sees the user's new position and correctly resets the clock. * iteration sees the user's new position and correctly resets the clock.
*/ */
export async function runKeeper(config: Config, verbose: boolean): Promise<void> { export async function runKeeper(config: Config): Promise<void> {
const log = makeLogger(verbose); // 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."); log.info("Teams Status Keeper started. Press Ctrl+C to stop.");
let lastPos: Point = await mouse.getPosition(); let lastPos: Point = await mouse.getPosition();
+104 -27
View File
@@ -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,
stepCount: cliArgs.stepCount,
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);
}
+123
View File
@@ -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 "../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,
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);
});
});
+120
View File
@@ -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 "../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!.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();
});
});
+5 -2
View File
@@ -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"]
} }