20 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
nokeo08 b43d5aad2d Add curl-pipe installer, uninstaller, and contributor bootstrap
End-user installation collapses to a single command:

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

Changes:

- install.sh rewritten as a POSIX sh, curl-pipeable end-user installer.
  Honors XDG_DATA_HOME and XDG_BIN_HOME (de facto). Idempotent via a
  version-marker file at $INSTALL_DIR/.installed-version; MOVE_FORCE=1
  overrides. Hard-fails if Bun is missing (no auto-install). Includes
  a safety guard refusing rm -rf on too-broad install dirs.
- uninstall.sh added; same curl-pipe pattern, shared XDG path
  resolution, leaves Bun alone.
- dev-setup.sh added (= the previous install.sh content, retitled for
  contributors and pointing at bun run start / bun link / install.sh).
- README updated: curl one-liner Install section, XDG behavior tables,
  new Uninstall section, new 'For contributors' section, Files table
  rows for all three scripts.
2026-06-17 11:55:03 -05:00
nokeo08 081f94088d Remove distribution plan (resolved)
The plan is being implemented in the following commit. The working-notes
document has served its purpose and is removed to keep master focused
on shipping artifacts.
2026-06-17 11:48:36 -05:00
18 changed files with 1426 additions and 384 deletions
-222
View File
@@ -1,222 +0,0 @@
# Distribution Plan for `move`
**Status:** Tabled 2026-06-15. Revisit after the initial Gitea push lands
and the repo is publicly reachable at <https://gitea.cahlen.com/nokeo08/Move>.
This document captures the analysis and decisions for distributing the
`move` CLI as an end-user-friendly tool, so we can resume without redoing
the discussion.
---
## Decisions locked in
| Decision | Choice |
| -------- | ------ |
| Primary hosting | Self-hosted Gitea (`https://gitea.cahlen.com/nokeo08/Move`) |
| Default branch | `master` |
| Audience | Anyone who finds the repo (broader public-ish) |
| Effort budget for now | Smallest viable curl-able `install.sh` |
| Bun handling | Fail with a clear message when missing (no auto-install) |
| Reinstall behavior | Idempotent via a version-marker file; `MOVE_FORCE=1` overrides |
| Install location | `~/.local/bin` + `~/.local/share/move` (XDG, no sudo), env-overridable |
| `move` shim | Bash wrapper script, not symlink |
| PATH rc files | Never modified; print instructions if PATH isn't right |
| Uninstaller | In scope; same `curl ... \| sh` pattern |
| npm publishing | Out of scope |
| Standalone binaries (`bun build --compile`) | Future work; depends on nut.js native-bundling testing |
| Homebrew tap | Future work; cheap once releases exist |
---
## Distribution-option survey (from the discussion)
Four options were weighed:
1. **Compiled standalone binary** (`bun build --compile`). Best UX (single
binary, no Bun needed) but needs per-platform builds, a release
pipeline, code signing on macOS, and depends on nut.js's prebuilt native
`.node` files bundling correctly under `--compile`**unverified**.
2. **Curl-piped installer****chosen**. Single command, no clone,
source-and-Bun model. Conventions used by bun, deno, rustup, nvm.
Slight cold-start cost vs. compiled binary; tradeoff accepted.
3. **Package-manager distribution** (`bun add -g`, brew). `move` is taken
on npm; brew is macOS-centric; both add publishing overhead. Out of
scope for first cut.
4. **Status quo** (clone + `./install.sh` + `bun link`). Developer flow
only; will be renamed to `dev-setup.sh`.
---
## Target end-user UX
```sh
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/install.sh | sh
move --help
```
One command. No clone. No `bun link`. No `chmod`. After this the user has
a global `move` on their PATH.
---
## `install.sh` design (end-user, curl-able)
- **Shebang:** `#!/usr/bin/env sh` (POSIX).
- **Strict mode:** `set -eu`. Trap on errors with a clear
"install failed at <step>" message.
### Top-to-bottom behavior
1. **Platform detection:**
- `uname -s``Darwin` / `Linux` / else → "unsupported OS" + exit 1.
- `uname -m``arm64` / `x86_64` (mapped to `x64`) / else →
"unsupported arch" + exit 1.
- nut.js supports darwin-arm64/x64, linux-x64, win32-x64. WSL users
fall under Linux.
2. **Bun check:**
- `command -v bun` present → continue.
- Absent → print
`Error: bun is not installed. Install from https://bun.sh (e.g. 'curl -fsSL https://bun.sh/install | bash'), then re-run.`
→ exit 1.
3. **Resolve paths:**
- `INSTALL_DIR=${MOVE_PREFIX:-$HOME/.local/share/move}`
- `BIN_DIR=${MOVE_BIN_DIR:-$HOME/.local/bin}`
- `mkdir -p` both.
4. **Resolve version:**
- `VERSION=${MOVE_VERSION:-master}`.
5. **Idempotence check:**
- If `$INSTALL_DIR/.installed-version` exists AND its content equals
`$VERSION` AND `${MOVE_FORCE:-0}` is not `1`:
- Print `move <version> already installed at $BIN_DIR/move. Set MOVE_FORCE=1 to reinstall, or pass a different MOVE_VERSION.`
- Exit 0.
6. **Clean install dir:** `rm -rf "$INSTALL_DIR"/*` (preserving the dir
itself so user-set permissions on it are kept).
7. **Download source:**
- `TARBALL="https://gitea.cahlen.com/nokeo08/Move/archive/${VERSION}.tar.gz"`
- `curl -fsSL "$TARBALL" -o "$TMP"` (mktemp) → on failure, fail
loudly with the offending URL.
- `tar -xzf "$TMP" -C "$INSTALL_DIR" --strip-components=1`
- `rm -f "$TMP"`
8. **Install deps:** `cd "$INSTALL_DIR" && bun install --production`.
`--production` skips devDependencies (TypeScript, @types/bun); saves
tens of MB and seconds per install.
9. **Drop the wrapper:**
```sh
cat > "$BIN_DIR/move" <<EOF
#!/usr/bin/env sh
exec bun "$INSTALL_DIR/src/move.ts" "\$@"
EOF
chmod +x "$BIN_DIR/move"
```
Wrapper, not a symlink, so the path is absolute and stable regardless
of how the user's shell resolves things.
10. **Write version marker:** `printf '%s\n' "$VERSION" > "$INSTALL_DIR/.installed-version"`.
11. **PATH sanity check:**
- If `$BIN_DIR` is not on `$PATH`, print:
`Warning: $BIN_DIR is not on your PATH. Add this to your shell rc: export PATH="$BIN_DIR:$PATH"`
- Do **not** mutate the user's rc files — that breaks the "clean
uninstall" contract.
12. **macOS Accessibility hint** (carried over from current `install.sh`).
13. **Final message:** `Installed move $VERSION at $BIN_DIR/move. Run 'move --help' to get started.`
### Env vars
| Var | Default | Purpose |
| --- | ------- | ------- |
| `MOVE_VERSION` | `master` | Branch or tag to fetch from Gitea. |
| `MOVE_PREFIX` | `$HOME/.local/share/move` | Source install location. |
| `MOVE_BIN_DIR` | `$HOME/.local/bin` | Wrapper install location. |
| `MOVE_FORCE` | unset | Force reinstall even if version marker matches. |
---
## `uninstall.sh` design
```sh
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/uninstall.sh | sh
```
**Behavior:**
1. `set -eu`.
2. Respect `MOVE_PREFIX` and `MOVE_BIN_DIR` env vars (same defaults as
install).
3. `rm -f "$BIN_DIR/move"`.
4. `rm -rf "$INSTALL_DIR"`.
5. Print `Uninstalled. (Bun was not touched.)`.
Does **not** uninstall Bun — that's the user's runtime, not ours.
---
## `dev-setup.sh` design (renamed from current `install.sh`)
The current `install.sh` is appropriate for contributors but inappropriate
as the end-user installer. Rename to `dev-setup.sh`. Content stays nearly
identical:
- Verify Bun is installed (hard-error if missing).
- `bun install`.
- macOS Accessibility hint.
- Trailing message updated to:
`Done. Dev workflow: 'bun run start' to run, or 'bun link' to install the global 'move' command. End-user installer is install.sh.`
---
## README changes (when we resume)
- `## Install` section becomes the curl one-liner targeting `master`, plus
a short note about env-var overrides and a pointer to uninstall.
- `## Run` section collapses to `move` / `move --help` for the global
install path.
- New `## Uninstall` section with the curl-pipe uninstall command.
- New `## For contributors` section absorbing the existing
`bun run start` / `bun run src/move.ts` / `bun link` examples; points
at `dev-setup.sh`.
- `## Files` table updates: add `install.sh` (end-user, curl-able),
`uninstall.sh` (end-user, curl-able), `dev-setup.sh` (contributor
bootstrap).
---
## Verification plan (when we resume)
1. `sh -n install.sh` and `sh -n uninstall.sh` — POSIX syntax check.
2. Local end-to-end with overridden paths:
```sh
MOVE_PREFIX=/tmp/movetest MOVE_BIN_DIR=/tmp/movetest/bin ./install.sh
/tmp/movetest/bin/move --help
./install.sh # second run: already-installed message
MOVE_FORCE=1 ./install.sh # forced reinstall
```
3. Uninstall against the test prefix; confirm nothing left under it.
4. Re-run existing acceptance tests (`tsc`, `--help`, `--version`,
`--bogus`) — should be unaffected; no source changes in this milestone.
5. After committing/pushing, end-to-end against the actual Gitea URL.
---
## Caveat: Gitea archive URL shape
The plan assumes
`https://gitea.cahlen.com/nokeo08/Move/archive/<ref>.tar.gz` returns a
tarball whose top-level directory contains the project files (peeled by
`--strip-components=1`). This is the standard Gitea archive format, but
if your Gitea version produces a different layout the installer will need
a small tweak. The installer should error loudly with the offending URL
if extraction fails.
---
## Open future work (out of scope for the resume session)
- Switch `MOVE_VERSION` default from `master` to "latest tag" via the
Gitea API (`/api/v1/repos/nokeo08/Move/tags`) once tagged releases are
routine.
- Investigate `bun build --compile` + nut.js native-binary bundling for
true zero-Bun installs. If it works, layer prebuilt binaries onto the
curl installer so users without Bun also get one-command installs.
- Homebrew tap for macOS users (cheap once a release pipeline exists).
- Possible: PowerShell installer (`install.ps1`) for native Windows users
without WSL.
+188 -42
View File
@@ -11,7 +11,7 @@ cursor leaves the position the script just commanded.
## Requirements ## Requirements
- [Bun](https://bun.sh) >= 1.0.0 - [Bun](https://bun.sh) >= 1.0.0
- macOS, Linux, or Windows (relies on - macOS or Linux (relies on
[`@nut-tree-fork/nut-js`](https://github.com/nut-tree-fork/nut.js) for [`@nut-tree-fork/nut-js`](https://github.com/nut-tree-fork/nut.js) for
cross-platform mouse + screen control) cross-platform mouse + screen control)
- On macOS: Accessibility permission for the terminal running Bun - On macOS: Accessibility permission for the terminal running Bun
@@ -20,38 +20,62 @@ cursor leaves the position the script just commanded.
## Install ## Install
```sh ```sh
./install.sh curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
``` ```
The installer verifies `bun` is on `PATH` and runs `bun install`. It does This fetches the latest `master` from Gitea, runs `bun install --production`
not auto-install Bun; if Bun is missing it prints a hard error pointing at under the install dir, and drops a `move` wrapper on your bin dir.
<https://bun.sh>.
The installer respects the XDG Base Directory Specification:
- Source lives at `$XDG_DATA_HOME/move` (default `~/.local/share/move`).
- Wrapper goes to `$XDG_BIN_HOME/move` (default `~/.local/bin/move`).
`XDG_BIN_HOME` is the widely-recognized de facto convention; XDG itself
doesn't standardize a user bin dir.
Env vars (all optional):
| Var | Default | Purpose |
| --- | ------- | ------- |
| `MOVE_VERSION` | `master` | Branch or tag to install. Pin with e.g. `v1.0.0`. |
| `MOVE_FORCE` | unset | Set to `1` to reinstall when the same version is already present. |
| `XDG_DATA_HOME` | `$HOME/.local/share` | Where the source tree is installed (under `move/`). |
| `XDG_BIN_HOME` | `$HOME/.local/bin` | Where the `move` wrapper is placed. |
Bun must already be installed; the installer fails with a clear pointer
to <https://bun.sh> if it isn't.
If your bin dir isn't on `PATH`, the installer prints the line to add to
your shell rc. It will not modify rc files for you.
## Run ## Run
```sh ```sh
bun run start
```
Or directly:
```sh
bun run src/move.ts
```
Or install it as a global `move` command via Bun's bin-linking:
```sh
bun link
move move
move --help
``` ```
`bun link` registers the `bin.move` entry from `package.json` (`./src/move.ts`,
which carries `#!/usr/bin/env bun`) as an executable on your `PATH`.
Stop with `Ctrl+C`. If `SIGINT` arrives mid-sweep, the cursor stays at Stop with `Ctrl+C`. If `SIGINT` arrives mid-sweep, the cursor stays at
whichever step was last commanded — by design. whichever step was last commanded — by design.
## Uninstall
```sh
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/uninstall.sh | sh
```
Removes the wrapper at `$XDG_BIN_HOME/move` and the install tree at
`$XDG_DATA_HOME/move`. Bun stays — it's your runtime, not ours.
Your config file at `$XDG_CONFIG_HOME/move/config.json` is **intentionally
left behind**, whether you customized it or never touched the seeded
defaults. The uninstaller prints a one-line notice pointing at the path
so you can remove it manually if you want:
```sh
rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/move"
```
## Usage ## Usage
```text ```text
@@ -60,17 +84,21 @@ 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.
``` ```
Run `bun run src/move.ts --help` for the full text. Run `move --help` for the resolved default config-file path on your
system. Numeric overrides are layered onto the defaults via
All flags are wired. Numeric overrides are layered onto the defaults via
`resolveConfig` in `src/config.ts`; time-valued inputs (`-m`, `-c`) are `resolveConfig` in `src/config.ts`; time-valued inputs (`-m`, `-c`) are
expressed in seconds at the CLI boundary and converted to milliseconds expressed in seconds at the CLI boundary and converted to milliseconds
internally. internally.
@@ -83,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`:
@@ -140,21 +253,54 @@ sweep. The script controls cadence itself via `config.stepDelay`, so the
implicit delay is disabled at module load (a side effect of importing implicit delay is disabled at module load (a side effect of importing
`keeper.ts`). `keeper.ts`).
## For contributors
Clone the repo and bootstrap a dev environment:
```sh
git clone https://gitea.cahlen.com/nokeo08/Move.git
cd Move
./scripts/dev-setup.sh
```
`scripts/dev-setup.sh` verifies Bun is installed and runs `bun install`
(with devDependencies, unlike the end-user `scripts/install.sh`). It
operates at the repo root regardless of the CWD you invoke it from.
Run from the source tree:
```sh
bun run start # via the package.json script
bun run src/move.ts # direct
bun run src/move.ts --help
```
Or install a global `move` pointed at your checkout:
```sh
bun link
move --help
```
## Files ## Files
| File | Purpose | | File | Purpose |
| ------------------- | ----------------------------------------------------------------------- | | ------------------- | ----------------------------------------------------------------------------- |
| `src/move.ts` | CLI entry point: parses args, dispatches help/version, starts the loop. | | `scripts/install.sh` | End-user installer; curl-pipeable from Gitea. |
| `src/cli.ts` | Argument parsing, validation, and help/version output. | | `scripts/uninstall.sh` | End-user uninstaller; curl-pipeable from Gitea. |
| `src/config.ts` | `Config` type, `DEFAULT_CONFIG`, and `resolveConfig` overlay. | | `scripts/dev-setup.sh` | Contributor bootstrap (verify Bun + `bun install`). |
| `src/keeper.ts` | Synthetic-activity sweep and idle-watch loop. | | `scripts/config.default.json`| Single source of truth for default values: imported by `src/config.ts` and copied to `$XDG_CONFIG_HOME/move/config.json` on a fresh install. |
| `install.sh` | Bootstrap: verify Bun, `bun install`, print macOS permission hint. | | `src/move.ts` | CLI entry point: parses args, dispatches help/version, starts the loop. |
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. | | `src/cli.ts` | Argument parsing, validation, and help/version output. |
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). | | `src/config.ts` | `Config` type (carries every tunable, including `verbose`), `DEFAULT_CONFIG` (derived from `scripts/config.default.json`), `defaultConfigPath`, and the layered `resolveConfig` overlay. |
| `bun.lock` | Bun's lockfile. Commit this. | | `src/configFile.ts` | Optional JSON config-file loader with strict schema validation. |
| `LICENSE` | GPLv3 license text. | | `src/editor.ts` | `move --edit`: opens the active config file in `$EDITOR`. |
| `CHANGES.md` | History of the post-port review fixes. | | `src/errors.ts` | Shared error types (`CliError`). |
| `MouseMover.java` | Original Java source, kept for reference. | | `src/keeper.ts` | 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). |
| `bun.lock` | Bun's lockfile. Commit this. |
| `LICENSE` | GPLv3 license text. |
## License ## License
+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
}
+21 -9
View File
@@ -1,6 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env sh
# #
# install.sh - bootstrap the Teams Status Keeper project. # dev-setup.sh - contributor bootstrap for the `move` repo.
#
# Use this when you have cloned the repo and want a working development
# environment. End users should use `install.sh` (curl-pipeable) instead.
# #
# Verifies Bun is installed, then runs `bun install` to fetch dependencies # Verifies Bun is installed, then runs `bun install` to fetch dependencies
# (notably `@nut-tree-fork/nut-js`, which ships prebuilt native binaries # (notably `@nut-tree-fork/nut-js`, which ships prebuilt native binaries
@@ -8,13 +11,18 @@
# #
# This script is idempotent: re-running it just re-resolves the dependency # This script is idempotent: re-running it just re-resolves the dependency
# tree against the existing `bun.lock`. # tree against the existing `bun.lock`.
#
# POSIX sh; no bashisms. Matches the style of install.sh / uninstall.sh.
# Fail fast on any error, unset variable, or failed pipe stage. # Fail fast on any error or unset variable. (`pipefail` is bash-only and
set -euo pipefail # not strictly needed here; this script doesn't pipe in failure-prone
# ways.)
set -eu
# Always operate relative to the script's own directory so the install works # Operate at the repo root regardless of the caller's CWD. This script
# regardless of the caller's CWD. # lives under scripts/, so pop up one level to land at the project root
cd "$(dirname "$0")" # before running bun install.
cd "$(dirname "$0")/.."
echo "==> Checking for Bun..." echo "==> Checking for Bun..."
if ! command -v bun >/dev/null 2>&1; then if ! command -v bun >/dev/null 2>&1; then
@@ -30,12 +38,16 @@ echo "==> Installing dependencies..."
bun install bun install
echo echo
echo "Done. Run with: bun run start" echo "Done. Dev workflow:"
echo " - 'bun run start' to run from the source tree."
echo " - 'bun run test' to run the test suite."
echo " - 'bun link' to install a global 'move' command pointed at this checkout."
echo "End-user installer is install.sh (curl-pipeable from Gitea)."
# macOS gates synthetic mouse events behind the Accessibility permission. # macOS gates synthetic mouse events behind the Accessibility permission.
# Without this hint, the first run silently fails to move the cursor and # Without this hint, the first run silently fails to move the cursor and
# the user has no obvious next step. # the user has no obvious next step.
if [[ "$(uname)" == "Darwin" ]]; then if [ "$(uname)" = "Darwin" ]; then
echo "Note: macOS will prompt for Accessibility permission on first mouse move." echo "Note: macOS will prompt for Accessibility permission on first mouse move."
echo " Grant it under System Settings > Privacy & Security > Accessibility." echo " Grant it under System Settings > Privacy & Security > Accessibility."
fi fi
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env sh
#
# install.sh - End-user installer for the `move` CLI.
#
# Curl-pipe ready:
#
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/install.sh | sh
#
# What it does:
# 1. Detect platform; bail on anything @nut-tree-fork/nut-js doesn't ship.
# 2. Require Bun; fail with a clear hint if missing (no auto-install).
# 3. Resolve XDG-compliant install paths.
# 4. Idempotence check via a version marker file.
# 5. Download the source tarball from Gitea, extract under the install dir.
# 6. `bun install --production` (skips devDependencies).
# 7. Drop a small wrapper script as `move` on the user's bin dir.
# 8. Seed the user's config file with defaults, only if one doesn't already
# exist at $XDG_CONFIG_HOME/move/config.json.
# 9. Verify PATH, surface macOS Accessibility hint, print final status.
#
# Env vars (all optional):
# MOVE_VERSION Branch or tag to install. Default: master.
# MOVE_FORCE Set to 1 to reinstall even if the version marker matches.
# XDG_DATA_HOME Source install root (default $HOME/.local/share).
# Final source location is $XDG_DATA_HOME/move.
# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin).
# Final binary location is $XDG_BIN_HOME/move.
# XDG_CONFIG_HOME User config root (default $HOME/.config).
# Default config file path is $XDG_CONFIG_HOME/move/config.json.
#
# POSIX sh; no bashisms.
set -eu
REPO_OWNER="nokeo08"
REPO_NAME="Move"
GITEA_HOST="gitea.cahlen.com"
MOVE_VERSION="${MOVE_VERSION:-master}"
MOVE_FORCE="${MOVE_FORCE:-0}"
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move"
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/move"
CONFIG_FILE="$CONFIG_DIR/config.json"
die() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
# Refuse to operate on a directory that points somewhere catastrophic.
# The XDG_* env vars are broadly-scoped and the user could set them to
# anything; our paths always culminate in `.../move`, but a malformed
# XDG var could still resolve to something like '/move' which we don't
# want to `rm -rf` or otherwise mass-write into.
assert_safe_dir() {
case "$1" in
'' | '/' | "$HOME" | "$HOME/" | '/move')
die "refusing to operate on '$1' (too broad)"
;;
esac
}
# --- Prerequisite tools ------------------------------------------------------
for tool in curl tar mktemp; do
if ! command -v "$tool" >/dev/null 2>&1; then
die "$tool is required (not found in PATH)"
fi
done
# --- Platform detection ------------------------------------------------------
OS=$(uname -s)
ARCH=$(uname -m)
case "$OS" in
Darwin|Linux) ;;
*) die "unsupported OS '$OS' (move supports macOS and Linux)" ;;
esac
case "$ARCH" in
arm64|aarch64|x86_64|amd64) ;;
*) die "unsupported architecture '$ARCH'" ;;
esac
# --- Bun check (hard fail; no auto-install) ----------------------------------
if ! command -v bun >/dev/null 2>&1; then
cat >&2 <<EOF
Error: bun is not installed.
Install it from https://bun.sh
(e.g. 'curl -fsSL https://bun.sh/install | bash')
then re-run this installer.
EOF
exit 1
fi
BUN_VERSION=$(bun --version)
printf '==> Using bun %s\n' "$BUN_VERSION"
# --- Idempotence check -------------------------------------------------------
assert_safe_dir "$INSTALL_DIR"
if [ "$MOVE_FORCE" != "1" ] && [ -f "$INSTALL_DIR/.installed-version" ]; then
CURRENT=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '')
if [ "$CURRENT" = "$MOVE_VERSION" ]; then
printf 'move %s is already installed at %s/move.\n' "$MOVE_VERSION" "$BIN_DIR"
printf 'Set MOVE_FORCE=1 to reinstall, or set MOVE_VERSION to a different ref.\n'
exit 0
fi
fi
# --- Clean install dir -------------------------------------------------------
mkdir -p "$BIN_DIR"
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
# --- Download source ---------------------------------------------------------
TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz"
TARBALL_TMP=$(mktemp) || die "could not create temp file"
cleanup() {
rm -f "$TARBALL_TMP"
}
trap cleanup EXIT INT TERM
printf '==> Downloading %s\n' "$TARBALL_URL"
if ! curl -fsSL "$TARBALL_URL" -o "$TARBALL_TMP"; then
die "could not download $TARBALL_URL (check MOVE_VERSION='$MOVE_VERSION' and network)"
fi
printf '==> Extracting source to %s\n' "$INSTALL_DIR"
if ! tar -xzf "$TARBALL_TMP" -C "$INSTALL_DIR" --strip-components=1; then
die "could not extract tarball from $TARBALL_URL"
fi
# --- Install runtime deps ----------------------------------------------------
printf '==> Installing runtime dependencies (bun install --production)\n'
(cd "$INSTALL_DIR" && bun install --production)
# --- Drop the wrapper --------------------------------------------------------
WRAPPER="$BIN_DIR/move"
printf '==> Writing wrapper to %s\n' "$WRAPPER"
cat > "$WRAPPER" <<EOF
#!/usr/bin/env sh
exec bun "$INSTALL_DIR/src/move.ts" "\$@"
EOF
chmod +x "$WRAPPER"
# --- Write version marker ----------------------------------------------------
printf '%s\n' "$MOVE_VERSION" > "$INSTALL_DIR/.installed-version"
# --- Seed user config file (only if absent) ----------------------------------
#
# The defaults file shipped with the source tree (scripts/config.default.json)
# is also the single source of truth for the runtime defaults loaded by
# src/config.ts, so seeding a fresh user file from the same place keeps the
# CLI behavior and the user-visible config in sync.
#
# Strict policy: never overwrite an existing user config. The uninstaller
# follows the matching policy of never removing it; together that
# preserves user customizations unconditionally across (re)installs and
# uninstalls.
assert_safe_dir "$CONFIG_DIR"
SEED_SRC="$INSTALL_DIR/scripts/config.default.json"
if [ ! -f "$SEED_SRC" ]; then
die "default config seed missing from install tree: $SEED_SRC"
fi
mkdir -p "$CONFIG_DIR"
if [ ! -e "$CONFIG_FILE" ]; then
cp "$SEED_SRC" "$CONFIG_FILE"
printf '==> Wrote default config to %s\n' "$CONFIG_FILE"
else
printf '==> Config already exists at %s; leaving it alone\n' "$CONFIG_FILE"
fi
# --- PATH sanity check -------------------------------------------------------
case ":$PATH:" in
*":$BIN_DIR:"*) BIN_ON_PATH=1 ;;
*) BIN_ON_PATH=0 ;;
esac
if [ "$BIN_ON_PATH" = "0" ]; then
printf '\nNote: %s is not on your PATH. Add this to your shell rc:\n' "$BIN_DIR"
printf ' export PATH="%s:$PATH"\n' "$BIN_DIR"
fi
# --- macOS Accessibility hint ------------------------------------------------
if [ "$OS" = "Darwin" ]; then
printf '\nNote: macOS will prompt for Accessibility permission on first mouse move.\n'
printf ' Grant it under System Settings > Privacy & Security > Accessibility.\n'
fi
# --- Final message -----------------------------------------------------------
printf '\nInstalled move %s at %s.\n' "$MOVE_VERSION" "$WRAPPER"
printf "Run 'move --help' to get started.\n"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env sh
#
# uninstall.sh - End-user uninstaller for the `move` CLI.
#
# Curl-pipe ready:
#
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/uninstall.sh | sh
#
# Removes the `move` wrapper from $XDG_BIN_HOME and the install tree from
# $XDG_DATA_HOME/move. Does NOT remove Bun — that's your runtime, not ours.
# Also leaves the user config file at $XDG_CONFIG_HOME/move/config.json
# intact (Unix convention; user customizations are not ours to delete).
# A notice is printed pointing at the file so you can remove it yourself
# if desired.
#
# Env vars (must match what install.sh used):
# XDG_DATA_HOME Source install root (default $HOME/.local/share).
# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin).
# XDG_CONFIG_HOME User config root (default $HOME/.config).
#
# POSIX sh; no bashisms.
set -eu
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move"
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/move"
CONFIG_FILE="$CONFIG_DIR/config.json"
WRAPPER="$BIN_DIR/move"
die() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
# Same safety guard as install.sh: refuse to wipe a directory that points
# somewhere catastrophic.
case "$INSTALL_DIR" in
'' | '/' | "$HOME" | "$HOME/" | '/move')
die "refusing to operate on INSTALL_DIR='$INSTALL_DIR' (too broad)"
;;
esac
REMOVED_SOMETHING=0
if [ -e "$WRAPPER" ] || [ -L "$WRAPPER" ]; then
rm -f "$WRAPPER"
printf 'Removed %s\n' "$WRAPPER"
REMOVED_SOMETHING=1
fi
if [ -d "$INSTALL_DIR" ]; then
rm -rf "$INSTALL_DIR"
printf 'Removed %s\n' "$INSTALL_DIR"
REMOVED_SOMETHING=1
fi
if [ "$REMOVED_SOMETHING" = "0" ]; then
printf 'Nothing to remove. Checked %s and %s.\n' "$WRAPPER" "$INSTALL_DIR"
if [ -e "$CONFIG_FILE" ]; then
printf 'Note: config file at %s was left in place.\n' "$CONFIG_FILE"
printf ' Remove it manually with: rm -rf %s\n' "$CONFIG_DIR"
fi
exit 0
fi
if [ -e "$CONFIG_FILE" ]; then
printf '\nNote: your config file at %s was left in place.\n' "$CONFIG_FILE"
printf ' Remove it manually with: rm -rf %s\n' "$CONFIG_DIR"
fi
printf '\nUninstalled. (Bun was not touched.)\n'
+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"]
} }