6 Commits
Author SHA1 Message Date
nokeo08 1ad724cd33 Release 1.3.3
install.sh now installs the newest published tag by default instead of
tracking the master branch, so 'curl ... | sh' installs a real release and
reports its version. The tag is resolved from the Gitea tags API, falling
back to master if the lookup fails. MOVE_VERSION still pins an explicit ref.
2026-08-17 10:58:26 -05:00
nokeo08 b9669269bc Release 1.3.2
Interactive installer: prompt before replacing an existing install or
overwriting an existing config, reading answers from /dev/tty so it works
under 'curl ... | sh'. Falls back to the prior non-interactive contract
when no terminal is available. MOVE_FORCE=1 skips all prompts; new
MOVE_RESEED_CONFIG=1 reseeds the config unattended (old file kept as .bak).

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

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

Comments-only (plus two script header lines); tsc clean, 64 tests pass.
2026-08-14 13:39:15 -05:00
nokeo08 41903ebaf1 docs: add execution happy-path sequence diagram 2026-08-14 13:28:43 -05:00
8 changed files with 440 additions and 63 deletions
+55
View File
@@ -5,6 +5,58 @@ All notable changes to `move` are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.3] - 2026-08-17
### Changed
- `install.sh` now installs the newest published tag by default instead of
tracking the `master` branch, so the plain `curl ... | sh` one-liner
installs a real release and reports its version (e.g. `v1.3.2`). The tag is
resolved from the Gitea tags API; if that lookup fails (offline, API
unreachable, or no tags yet) it falls back to `master`, preserving the old
behavior. Set `MOVE_VERSION` to pin an explicit branch or tag as before.
## [1.3.2] - 2026-08-17
### Added
- `install.sh` is now interactive. When it finds an existing install and a
controlling terminal is available, it reports what's there and asks before
replacing it, instead of leaving `MOVE_FORCE` as the only control. If a
config file already exists it asks separately whether to reseed it from the
shipped defaults. Both questions are asked before anything is downloaded or
deleted, so declining changes nothing.
- `MOVE_RESEED_CONFIG=1` overwrites the user config with the shipped defaults
without prompting, for unattended use. The previous file is kept as
`config.json.bak`; the same backup is written when reseeding is confirmed
at the prompt.
### Changed
- `MOVE_FORCE=1` now means "skip every prompt and reinstall unconditionally".
It deliberately does not touch the user config, so automation that
reinstalls the CLI can't take customizations down with it.
- Existing-install detection looks for the install tree and the wrapper, not
just the `.installed-version` marker, so a half-finished or hand-moved
install is caught rather than silently overwritten.
### Fixed
- Installing a *different* version over an existing one used to wipe and
replace it with no warning; only an exact version match was ever reported.
That case now prompts. With no terminal (CI, cron, container builds) the
previous non-interactive behavior is preserved exactly: an identical
version is a no-op, a different version is replaced.
## [1.3.1] - 2026-08-14
### Added
- `docs/execution-happy-path.md`: a sequence diagram (plus invariants) tracing
a clean idle-triggered sweep end to end, linked from the README.
### Fixed
- Stale comments corrected to match the current code: the `install.sh` /
`uninstall.sh` header curl URLs pointed at a nonexistent repo-root path
(they live under `scripts/`), so the documented command 404'd; `move.ts`'s
module list omitted `editor.ts` and the `--edit` step; and `config.ts` still
described a removed pixel unit.
## [1.3.0] - 2026-08-14
### Added
@@ -113,6 +165,9 @@ Initial release.
- Source split into `src/{move,cli,config,keeper}.ts`.
- `bin` entry + shebang so `bun link` registers `move` globally.
[1.3.3]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.2...v1.3.3
[1.3.2]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.1...v1.3.2
[1.3.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.0...v1.3.1
[1.3.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.2.0...v1.3.0
[1.2.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.1...v1.2.0
[1.1.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.0...v1.1.1
+43 -9
View File
@@ -23,8 +23,11 @@ cursor leaves the position the script just commanded.
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
```
This fetches the latest `master` from Gitea, runs `bun install --production`
under the install dir, and drops a `move` wrapper on your bin dir.
This installs the latest tagged release from Gitea (the installer resolves
it automatically), runs `bun install --production` under the install dir,
and drops a `move` wrapper on your bin dir. If the latest tag can't be
determined — offline, or the API is unreachable — it falls back to the
`master` branch. Pin an exact ref with `MOVE_VERSION` (see below).
The installer respects the XDG Base Directory Specification:
@@ -33,14 +36,38 @@ The installer respects the XDG Base Directory Specification:
`XDG_BIN_HOME` is the widely-recognized de facto convention; XDG itself
doesn't standardize a user bin dir.
### Reinstalling over an existing install
The installer never replaces an existing install silently. When it finds
one and it can reach a terminal, it tells you what's there and asks:
```
==> Found an existing move install (v1.2.0) at /home/you/.local/share/move
Replace it with v1.3.2? [Y/n]
```
If a config file already exists, it asks separately whether to overwrite
it with the shipped defaults (default: no). Both questions come *before*
anything is downloaded or deleted, so declining costs you nothing.
This works under `curl ... | sh` too: the prompts read from `/dev/tty`
rather than stdin, which the piped script itself occupies.
With no terminal available — CI, cron, a container build — there's nobody
to ask, so the installer falls back to its long-standing behavior: an
identical version is a no-op, a different version is replaced, and your
config is left alone. Use the env vars below to drive it explicitly.
Env vars (all optional):
| Var | Default | Purpose |
| --- | ------- | ------- |
| `MOVE_VERSION` | `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. |
| `MOVE_VERSION` | latest tag | Branch or tag to install; auto-resolves to the newest tag, falling back to `master`. Pin with e.g. `v1.0.0`. |
| `MOVE_FORCE` | unset | Set to `1` to skip every prompt and reinstall unconditionally. Never touches your config. |
| `MOVE_RESEED_CONFIG` | unset | Set to `1` to overwrite your config with the shipped defaults without asking. The old file is kept as `config.json.bak`. |
| `XDG_DATA_HOME` | `$HOME/.local/share` | Where the source tree is installed (under `move/`). |
| `XDG_BIN_HOME` | `$HOME/.local/bin` | Where the `move` wrapper is placed. |
| `XDG_CONFIG_HOME` | `$HOME/.config` | Where the config file lives (under `move/`). |
Bun must already be installed; the installer fails with a clear pointer
to <https://bun.sh> if it isn't.
@@ -123,11 +150,14 @@ ${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`).
**only if no file already exists at that path**. An existing config —
yours or from a previous install — is never overwritten silently: the
installer asks first, and replaces it only if you say yes (or if you set
`MOVE_RESEED_CONFIG=1`), keeping the old file as `config.json.bak` either
way. `MOVE_FORCE=1` reinstalls the software but leaves your config alone.
If you remove the file later, `move` still works: missing defaults fall
back to the values baked into the binary (which match what was seeded,
since both come from `scripts/config.default.json`).
Pass `-C` / `--config <path>` to point at a different file; in that mode
the file must exist.
@@ -206,6 +236,9 @@ file with `--config`.
## How it works
For a step-by-step trace of a clean sweep, see the
[execution happy-path sequence diagram](docs/execution-happy-path.md).
The source lives under `src/`, split into an entry point plus logic
modules:
@@ -358,6 +391,7 @@ move --help
| `src/device.ts` | `Device` I/O seam over nut.js (`Point`, `createNutDevice`); the only nut.js importer. |
| `src/strategies.ts` | Pure movement-pattern generators, the strategy registry, and name validation. |
| `src/executor.ts` | `executePath` driver: bounds policy, pacing, interrupt detection, restore. |
| `docs/execution-happy-path.md` | Sequence diagram + invariants for a clean sweep. |
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
| `bun.lock` | Bun's lockfile. Commit this. |
+88
View File
@@ -0,0 +1,88 @@
# Execution: the happy path
This traces one full idle-triggered sweep that completes cleanly — the
"happy path" where the machine is idle long enough to fire, the configured
pattern runs to exhaustion, and no real user activity interrupts it.
For the module breakdown and the three seams (`device` / `strategies` /
`executor`), see the "How it works" section of the [README](../README.md).
```mermaid
sequenceDiagram
autonumber
participant Entry as move.ts
participant Keeper as runKeeper
participant Sim as simulateActivity
participant Strat as Strategy<br/>(e.g. line)
participant Exec as executePath
participant Dev as Device<br/>(nut.js)
Note over Entry: startup (args → config)
Entry->>Entry: parseCliArgs()
Entry->>Entry: loadConfigFile()
Entry->>Entry: resolveConfig(file, cli)
Entry->>Keeper: runKeeper(config)
Keeper->>Dev: createNutDevice()
Note right of Dev: sets mouse.config.autoDelayMs = 0
Keeper->>Keeper: log.info(banner)
Keeper->>Dev: getPosition()
Dev-->>Keeper: lastPos
Note over Keeper: lastActivity = now
loop every checkInterval (until idle long enough)
Keeper->>Dev: sleep(checkInterval)
Keeper->>Dev: getPosition()
Dev-->>Keeper: pos
Note over Keeper: pos == lastPos (no user movement)<br/>now - lastActivity ≥ moveInterval → fire
end
Keeper->>Sim: simulateActivity(config, log, dev)
Sim->>Dev: getPosition()
Dev-->>Sim: start
Sim->>Dev: width()
Dev-->>Sim: width
Sim->>Dev: height()
Dev-->>Sim: height
Note over Sim: strategy = STRATEGIES[config.pattern]<br/>ctx = { start, width, height, rng }
Sim->>Exec: executePath(strategy, ctx, dev, log, config)
Exec->>Strat: path(ctx)
Strat-->>Exec: iterable of Points
loop for each target point (clean run)
Exec->>Exec: resolveTarget(bounds, target) → point
Exec->>Dev: setPosition(point)
Exec->>Dev: sleep(stepDelay)
Exec->>Dev: getPosition()
Dev-->>Exec: current
Note over Exec: |current - point| ≤ 2px → not the user, continue
end
Note over Exec: path exhausted, no interruption
Exec->>Dev: setPosition(round(start))
Note right of Exec: restore cursor to origin
Exec-->>Sim: "completed"
Sim-->>Keeper: (done)
Keeper->>Dev: getPosition()
Dev-->>Keeper: lastPos (equals start, re-synced)
Note over Keeper: lastActivity = now<br/>loop continues
```
## Invariants this path relies on
- **`createNutDevice()` is the only nut.js touchpoint.** It disables nut.js's
100ms auto-delay so `executePath` owns cadence via `stepDelay`.
- **The strategy is pure.** `path(ctx)` yields ideal points from geometry
alone (`start` / `width` / `height` / `rng`); it never touches the device,
which is what makes every pattern unit-testable without a screen.
- **Every step re-reads the cursor** and compares it against the *commanded*
point (not the strategy's ideal, possibly fractional target) within a 2px
tolerance. On the happy path each check passes, so the loop runs to
exhaustion. A mismatch beyond tolerance is real user activity and returns
`"interrupted"` without restoring — the branch this diagram omits.
- **Clean completion restores the cursor to `round(start)`.** That is why the
follow-up `getPosition()` in `runKeeper` re-syncs `lastPos` to the origin as
a no-op, and the next idle check sees no net movement (so the synthetic
sweep is never mistaken for the user returning).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "move",
"version": "1.3.0",
"version": "1.3.3",
"private": true,
"license": "GPL-3.0-only",
"type": "module",
+242 -46
View File
@@ -4,31 +4,49 @@
#
# Curl-pipe ready:
#
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/install.sh | sh
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
#
# 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.
# 4. Detect an existing install and, on a terminal, ask before replacing it.
# 5. Download and build in a temp staging dir; swap it over the install
# dir only once it's complete, so a failed run can't destroy a working
# install.
# 6. `bun install --production` (skips devDependencies).
# 7. Drop a small wrapper script as `move` on the user's bin dir.
# 8. Seed the user's config file with defaults, only if one doesn't already
# exist at $XDG_CONFIG_HOME/move/config.json.
# 8. Seed the user's config file with defaults if one doesn't already exist
# at $XDG_CONFIG_HOME/move/config.json; if one does, offer to reseed it.
# 9. Verify PATH, surface macOS Accessibility hint, print final status.
#
# 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.
# Interactivity:
# When a controlling terminal is available, an existing install is never
# replaced without asking, and an existing config is never overwritten
# without asking. Both questions are put up front, before anything is
# downloaded or deleted, so declining costs nothing. With no terminal
# (CI, cron, container build) the script falls back to its historical
# non-interactive contract: an identical version is a no-op, a different
# version is replaced, and the config is left alone.
#
# POSIX sh; no bashisms.
# Env vars (all optional):
# MOVE_VERSION Branch or tag to install. Default: the newest tag
# published to the repo, falling back to the master
# branch if that can't be determined.
# MOVE_FORCE Set to 1 to skip every prompt and reinstall
# unconditionally. Does not touch the user config.
# MOVE_RESEED_CONFIG Set to 1 to overwrite the user config with the
# shipped defaults without asking. The previous file
# is saved alongside it as config.json.bak.
# XDG_DATA_HOME Source install root (default $HOME/.local/share).
# Final source location is $XDG_DATA_HOME/move.
# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin).
# Final binary location is $XDG_BIN_HOME/move.
# XDG_CONFIG_HOME User config root (default $HOME/.config).
# Default config file is $XDG_CONFIG_HOME/move/config.json.
#
# POSIX sh; no bashisms. Note the absence of `local`: helper functions use
# `_`-prefixed globals, which POSIX sh leaves us with.
set -eu
@@ -36,13 +54,17 @@ REPO_OWNER="nokeo08"
REPO_NAME="Move"
GITEA_HOST="gitea.cahlen.com"
MOVE_VERSION="${MOVE_VERSION:-master}"
# Left empty when unset so it can be resolved to the latest tag once the
# prerequisite tools are confirmed present (see "Resolve version" below).
MOVE_VERSION="${MOVE_VERSION:-}"
MOVE_FORCE="${MOVE_FORCE:-0}"
MOVE_RESEED_CONFIG="${MOVE_RESEED_CONFIG:-0}"
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move"
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/move"
CONFIG_FILE="$CONFIG_DIR/config.json"
WRAPPER="$BIN_DIR/move"
die() {
printf 'Error: %s\n' "$1" >&2
@@ -62,6 +84,70 @@ assert_safe_dir() {
esac
}
# Print the newest tag name published to the repo (e.g. v1.3.2), or return
# non-zero if it can't be determined. The Gitea tags API lists newest first,
# so with ?limit=1 the sole entry is the latest tag; grep+sed pull its "name"
# without a jq dependency. Any failure -- offline, API error, no tags, a
# missing grep/sed -- collapses to a non-zero return and lets the caller fall
# back to the master branch.
resolve_latest_tag() {
_tags_url="https://$GITEA_HOST/api/v1/repos/$REPO_OWNER/$REPO_NAME/tags?limit=1"
_json=$(curl -fsSL --max-time 10 "$_tags_url" 2>/dev/null) || return 1
_tag=$(printf '%s' "$_json" | grep -o '"name":"[^"]*"' | head -n 1 | sed 's/.*:"//; s/"$//')
[ -n "$_tag" ] || return 1
printf '%s\n' "$_tag"
}
# --- Interactive prompt support ----------------------------------------------
#
# The documented entry point is `curl -fsSL ... | sh`, which means stdin is
# the *script source itself*. Reading a prompt answer from stdin would
# consume the rest of the program and truncate execution mid-run, so every
# prompt reads from /dev/tty directly.
#
# Detecting whether that's possible needs a real open(2) attempt. A `[ -r
# /dev/tty ]` test is not enough: the device node exists and is mode 0666
# even in contexts with no controlling terminal (cron, CI, container
# builds), where opening it fails with ENXIO. The probe runs in a subshell
# because a redirection failure on `exec` -- a special built-in -- exits a
# non-interactive shell outright under POSIX.
if (: >/dev/tty) 2>/dev/null; then
INTERACTIVE=1
else
INTERACTIVE=0
fi
# confirm PROMPT DEFAULT -> 0 for yes, 1 for no.
#
# DEFAULT is 'y' or 'n' and is taken on a bare Enter or on EOF (^D), so the
# loop can't spin forever against a closed terminal. Prompts are written to
# /dev/tty rather than stdout so they stay visible when the caller redirects
# our output.
confirm() {
_prompt="$1"
_default="$2"
case "$_default" in
y) _hint='[Y/n]' ;;
*) _hint='[y/N]' ;;
esac
while :; do
printf '%s %s ' "$_prompt" "$_hint" > /dev/tty
if ! IFS= read -r _reply < /dev/tty; then
printf '\n' > /dev/tty
_reply=''
fi
if [ -z "$_reply" ]; then
_reply="$_default"
fi
case "$_reply" in
[yY] | [yY][eE][sS]) return 0 ;;
[nN] | [nN][oO]) return 1 ;;
*) printf "Please answer 'y' or 'n'.\n" > /dev/tty ;;
esac
done
}
# --- Prerequisite tools ------------------------------------------------------
for tool in curl tar mktemp; do
@@ -100,53 +186,160 @@ fi
BUN_VERSION=$(bun --version)
printf '==> Using bun %s\n' "$BUN_VERSION"
# --- Idempotence check -------------------------------------------------------
# --- Resolve version to install ----------------------------------------------
#
# With no explicit MOVE_VERSION, default to the newest published tag so the
# plain one-liner installs a real release and reports its version (e.g.
# v1.3.2) rather than tracking the moving `master` branch. If the lookup
# fails -- offline, API unreachable, or no tags yet -- fall back to master,
# preserving the historical behavior instead of aborting.
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
if [ -z "$MOVE_VERSION" ]; then
if MOVE_VERSION=$(resolve_latest_tag); then
printf '==> Latest release is %s\n' "$MOVE_VERSION"
else
MOVE_VERSION=master
printf '==> Could not determine latest release; installing from master\n' >&2
fi
fi
# --- Clean install dir -------------------------------------------------------
# --- Existing install check --------------------------------------------------
#
# Both questions this script can ask are asked here, before anything is
# downloaded, deleted, or written. Declining therefore costs the user
# nothing, and no prompt appears minutes into a `bun install`.
assert_safe_dir "$INSTALL_DIR"
assert_safe_dir "$CONFIG_DIR"
INSTALLED_VERSION=''
if [ -f "$INSTALL_DIR/.installed-version" ]; then
INSTALLED_VERSION=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '')
fi
# Look wider than the version marker: a half-finished or hand-edited install
# can leave a tree or a wrapper behind without one, and steamrolling that
# silently is precisely what this check exists to prevent.
FOUND_EXISTING=0
if [ -d "$INSTALL_DIR" ] || [ -e "$WRAPPER" ] || [ -L "$WRAPPER" ]; then
FOUND_EXISTING=1
fi
# Decided here, applied at the end -- the seed file it copies from only
# exists once the tarball has been extracted.
RESEED_CONFIG="$MOVE_RESEED_CONFIG"
if [ "$FOUND_EXISTING" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then
if [ -n "$INSTALLED_VERSION" ]; then
printf '==> Found an existing move install (%s) at %s\n' \
"$INSTALLED_VERSION" "$INSTALL_DIR"
else
printf '==> Found an existing move install at %s (version unknown)\n' \
"$INSTALL_DIR"
fi
if [ "$INTERACTIVE" = "1" ]; then
# The defaults below are chosen so that a bare Enter reproduces
# exactly what this script did before it learned to ask: skip when
# the version is identical, replace when it differs.
if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then
REPLACE_PROMPT="Reinstall move $MOVE_VERSION over it?"
REPLACE_DEFAULT=n
else
REPLACE_PROMPT="Replace it with $MOVE_VERSION?"
REPLACE_DEFAULT=y
fi
if ! confirm "$REPLACE_PROMPT" "$REPLACE_DEFAULT"; then
printf 'Leaving the existing install alone. Nothing was changed.\n'
exit 0
fi
else
# Nowhere to ask, so fall back to the historical contract.
if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then
printf 'move %s is already installed at %s.\n' "$MOVE_VERSION" "$WRAPPER"
printf 'Set MOVE_FORCE=1 to reinstall, or set MOVE_VERSION to a different ref.\n'
exit 0
fi
printf '==> No terminal available; replacing %s with %s\n' \
"${INSTALLED_VERSION:-unknown}" "$MOVE_VERSION"
fi
fi
if [ -e "$CONFIG_FILE" ] && [ "$RESEED_CONFIG" != "1" ] &&
[ "$INTERACTIVE" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then
printf '==> A config file already exists at %s\n' "$CONFIG_FILE"
if confirm 'Overwrite it with the shipped defaults?' n; then
RESEED_CONFIG=1
fi
fi
# --- Stage, download, build --------------------------------------------------
#
# Everything is assembled in a temp staging dir first; the existing install
# is removed only once the staged tree is fully built and ready to swap in.
# A failed download, extract, or `bun install` therefore leaves a working
# install untouched -- unlike the old flow, which wiped INSTALL_DIR before
# the download even started and left nothing behind on any failure.
mkdir -p "$BIN_DIR"
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
# --- Download source ---------------------------------------------------------
DATA_ROOT=$(dirname "$INSTALL_DIR")
mkdir -p "$DATA_ROOT"
TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz"
TARBALL_TMP=$(mktemp) || die "could not create temp file"
# Stage on the same filesystem as INSTALL_DIR so the final swap is a rename,
# not a cross-device copy.
STAGE_DIR=$(mktemp -d "$DATA_ROOT/.move-stage.XXXXXX") ||
{ rm -f "$TARBALL_TMP"; die "could not create staging dir under $DATA_ROOT"; }
# On any exit, clean up the tarball and any leftover staging dir. After a
# successful swap STAGE_DIR has been renamed away, so the rm -rf is a no-op.
cleanup() {
rm -f "$TARBALL_TMP"
rm -rf "$STAGE_DIR"
}
trap cleanup EXIT INT TERM
TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz"
printf '==> Downloading %s\n' "$TARBALL_URL"
if ! curl -fsSL "$TARBALL_URL" -o "$TARBALL_TMP"; then
die "could not download $TARBALL_URL (check MOVE_VERSION='$MOVE_VERSION' and network)"
fi
printf '==> Extracting source to %s\n' "$INSTALL_DIR"
if ! tar -xzf "$TARBALL_TMP" -C "$INSTALL_DIR" --strip-components=1; then
printf '==> Extracting source\n'
if ! tar -xzf "$TARBALL_TMP" -C "$STAGE_DIR" --strip-components=1; then
die "could not extract tarball from $TARBALL_URL"
fi
# --- Install runtime deps ----------------------------------------------------
printf '==> Installing runtime dependencies (bun install --production)\n'
(cd "$INSTALL_DIR" && bun install --production)
(cd "$STAGE_DIR" && bun install --production)
# Sanity-check the staged tree before we disturb the existing install: a
# truncated or wrong tarball that's missing the config seed should fail here,
# while the old install is still intact and swappable-out.
if [ ! -f "$STAGE_DIR/scripts/config.default.json" ]; then
die "downloaded tree is missing scripts/config.default.json (bad MOVE_VERSION='$MOVE_VERSION'?)"
fi
# Record the version inside the staged tree so the install is self-consistent
# the instant it lands.
printf '%s\n' "$MOVE_VERSION" > "$STAGE_DIR/.installed-version"
# --- Swap staged tree into place ---------------------------------------------
#
# The only destructive step, kept as late as possible: the window where
# INSTALL_DIR is absent is just this rm + rename, not the whole build.
assert_safe_dir "$INSTALL_DIR"
printf '==> Installing to %s\n' "$INSTALL_DIR"
rm -rf "$INSTALL_DIR"
if ! mv "$STAGE_DIR" "$INSTALL_DIR"; then
die "could not move staged install into place at $INSTALL_DIR"
fi
# --- Drop the wrapper --------------------------------------------------------
WRAPPER="$BIN_DIR/move"
printf '==> Writing wrapper to %s\n' "$WRAPPER"
cat > "$WRAPPER" <<EOF
#!/usr/bin/env sh
@@ -154,23 +347,21 @@ 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) ----------------------------------
# --- Seed user config file ---------------------------------------------------
#
# The defaults file shipped with the source tree (scripts/config.default.json)
# is also the single source of truth for the runtime defaults loaded by
# src/config.ts, so seeding a fresh user file from the same place keeps the
# CLI behavior and the user-visible config in sync.
#
# 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.
# Policy: an existing user config is never overwritten *silently*. It is
# replaced only on an explicit answer to the prompt above or an explicit
# MOVE_RESEED_CONFIG=1, and even then the previous file is kept as a .bak
# rather than destroyed. Everything else -- MOVE_FORCE=1, a non-interactive
# run -- leaves it untouched, so automation that reinstalls the software
# can't take a user's customizations down with it. The uninstaller follows
# the matching policy of never removing the config at all.
assert_safe_dir "$CONFIG_DIR"
SEED_SRC="$INSTALL_DIR/scripts/config.default.json"
if [ ! -f "$SEED_SRC" ]; then
@@ -181,6 +372,11 @@ mkdir -p "$CONFIG_DIR"
if [ ! -e "$CONFIG_FILE" ]; then
cp "$SEED_SRC" "$CONFIG_FILE"
printf '==> Wrote default config to %s\n' "$CONFIG_FILE"
elif [ "$RESEED_CONFIG" = "1" ]; then
cp "$CONFIG_FILE" "$CONFIG_FILE.bak"
cp "$SEED_SRC" "$CONFIG_FILE"
printf '==> Reseeded %s (previous file saved as %s)\n' \
"$CONFIG_FILE" "$CONFIG_FILE.bak"
else
printf '==> Config already exists at %s; leaving it alone\n' "$CONFIG_FILE"
fi
+1 -1
View File
@@ -4,7 +4,7 @@
#
# Curl-pipe ready:
#
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/uninstall.sh | sh
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/uninstall.sh | sh
#
# Removes the `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.
+1 -1
View File
@@ -10,7 +10,7 @@
* `resolveConfig` rather than mutating the defaults, so the defaults stay
* genuinely constant and the resolved config stays structurally typed.
*
* All numeric `Config` fields are in their internal units (ms, pixels).
* All numeric `Config` fields are in their internal units (milliseconds).
* The CLI and config file expose the time-valued fields in seconds for
* ergonomics; `resolveConfig` performs the seconds->ms conversion at the
* boundary so downstream code never has to think about it.
+9 -5
View File
@@ -4,23 +4,27 @@
* -------
* Entry point for the `move` CLI.
*
* Thin shim that ties the four logic modules together:
* Thin shim that ties the logic modules together:
* - `cli.ts` parses and validates `process.argv`.
* - `configFile.ts` loads and validates the JSON config file.
* - `config.ts` holds defaults and the layered `resolveConfig` overlay.
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
* - `editor.ts` backs `--edit` (open the config file in `$EDITOR`).
* - `keeper.ts` owns the idle-watch loop and drives the movement
* machinery (device / strategy / executor).
*
* Order of operations:
* 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
* 2. `--help` / `--version` short-circuit before any I/O, config load, or
* mouse work. `keeper.ts` is also lazy-imported (see below) so these
* flags don't pay the cost of loading the nut.js native binary.
* 3. Load + validate the config file (default XDG path, or `--config
* 3. `--edit` opens the resolved config file in `$EDITOR` and is a
* terminal action (propagates the editor's exit code).
* 4. Load + validate the config file (default XDG path, or `--config
* <path>` if supplied). Validation failures share the exit-2 path.
* 4. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose
* 5. 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
* 6. Lazy-import `keeper.ts` (dynamic import keeps nut.js out of the
* `--help` / `--version` startup path) and run it. Any unhandled
* rejection — from the import itself or from the loop — exits 1.
*