Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7777b16540 | |||
| cff1c482a3 | |||
| 10dcc1791a | |||
| cc7a487aca |
@@ -0,0 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `move` are documented here.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.2.0] - 2026-06-17
|
||||
|
||||
### Added
|
||||
- `-e, --edit` flag opens the resolved config file in `$EDITOR`.
|
||||
|
||||
### Changed
|
||||
- `keeper.ts` (and `@nut-tree-fork/nut-js`) is lazy-imported, so `--help`
|
||||
and `--version` skip the nut.js load and start ~10x faster.
|
||||
|
||||
## [1.1.1] - 2026-06-17
|
||||
|
||||
### Changed
|
||||
- Tests moved from `src/` to a top-level `tests/` directory.
|
||||
- `tsconfig.json` sets `"types": ["bun"]` so VS Code resolves `bun:test`.
|
||||
|
||||
### Fixed
|
||||
- `package.json` version now matches the published tag.
|
||||
|
||||
## [1.1.0] - 2026-06-17
|
||||
|
||||
### Added
|
||||
- JSON config file support at `${XDG_CONFIG_HOME:-~/.config}/move/config.json`.
|
||||
Precedence: CLI flags > config file > defaults. Strict validation.
|
||||
- `-C, --config <path>` to override the default config path.
|
||||
- Installer seeds `config.json` with project defaults on fresh install only.
|
||||
- Bun test suite for `resolveConfig` and `loadConfigFile`.
|
||||
|
||||
### Changed
|
||||
- Installer scripts now live in `scripts/`.
|
||||
- `verbose` is now a first-class `Config` field; `resolveVerbose` removed.
|
||||
- `CliError` extracted into `src/errors.ts`.
|
||||
- `defaultConfigPath()` throws when both `$XDG_CONFIG_HOME` and `$HOME` are unset.
|
||||
- `mouse.config.autoDelayMs = 0` moved into `runKeeper` (no module-load side effect).
|
||||
- Runtime errors go through a `failRuntime` helper that mirrors `failUser`.
|
||||
- `keeper.ts` declares a named `Logger` interface.
|
||||
- `dev-setup.sh` is now POSIX `sh`.
|
||||
- `tsconfig.json`: enabled `noUncheckedIndexedAccess` and `resolveJsonModule`.
|
||||
|
||||
## [1.0.1] - 2026-06-17
|
||||
|
||||
### Added
|
||||
- End-user `install.sh` runnable via `curl ... | sh`. XDG-respecting, idempotent.
|
||||
- `uninstall.sh` removes the wrapper and install tree; leaves Bun and user
|
||||
config alone.
|
||||
- `dev-setup.sh` for contributors.
|
||||
|
||||
### Removed
|
||||
- `DISTRIBUTION-PLAN.md` (design notes, superseded by the implementation).
|
||||
|
||||
## [1.0.0] - 2026-06-15
|
||||
|
||||
Initial release.
|
||||
|
||||
### Added
|
||||
- `move` CLI for keeping presence-tracking apps marked Available by nudging
|
||||
the cursor after a configurable idle period.
|
||||
- Flags: `-h/--help`, `-v/--version`, `-m/--move-interval`,
|
||||
`-c/--check-interval`, `-d/--step-delay`, `-n/--step-count`, `-V/--verbose`.
|
||||
- Quiet-by-default logging.
|
||||
- Source split into `src/{move,cli,config,keeper}.ts`.
|
||||
- `bin` entry + shebang so `bun link` registers `move` globally.
|
||||
|
||||
[1.2.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.1...v1.2.0
|
||||
[1.1.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.0...v1.1.1
|
||||
[1.1.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.0.1...v1.1.0
|
||||
[1.0.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.0.0...v1.0.1
|
||||
[1.0.0]: https://gitea.cahlen.com/nokeo08/Move/releases/tag/v1.0.0
|
||||
@@ -84,6 +84,7 @@ Usage: move [options]
|
||||
Options:
|
||||
-h, --help Show this help 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.
|
||||
@@ -155,6 +156,24 @@ 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:
|
||||
@@ -275,6 +294,8 @@ move --help
|
||||
| `src/cli.ts` | Argument parsing, validation, and help/version output. |
|
||||
| `src/config.ts` | `Config` type (carries every tunable, including `verbose`), `DEFAULT_CONFIG` (derived from `scripts/config.default.json`), `defaultConfigPath`, and the layered `resolveConfig` overlay. |
|
||||
| `src/configFile.ts` | Optional JSON config-file loader with strict schema validation. |
|
||||
| `src/editor.ts` | `move --edit`: opens the active config file in `$EDITOR`. |
|
||||
| `src/errors.ts` | Shared error types (`CliError`). |
|
||||
| `src/keeper.ts` | Synthetic-activity sweep and idle-watch loop. |
|
||||
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
|
||||
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "move",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"type": "module",
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
*
|
||||
* -h, --help Prints `printHelp()` 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.
|
||||
* -c, --check-interval Cursor poll cadence (seconds).
|
||||
@@ -37,6 +40,7 @@ import { CliError } from "./errors.ts";
|
||||
export interface ParsedCliArgs {
|
||||
help: boolean;
|
||||
version: boolean;
|
||||
edit: boolean;
|
||||
config: string | undefined;
|
||||
moveInterval: number | undefined; // seconds
|
||||
checkInterval: number | undefined; // seconds
|
||||
@@ -78,6 +82,7 @@ export function parseCliArgs(): ParsedCliArgs {
|
||||
options: {
|
||||
help: { type: "boolean", short: "h" },
|
||||
version: { type: "boolean", short: "v" },
|
||||
edit: { type: "boolean", short: "e" },
|
||||
config: { type: "string", short: "C" },
|
||||
"move-interval": { type: "string", short: "m" },
|
||||
"check-interval": { type: "string", short: "c" },
|
||||
@@ -99,6 +104,7 @@ export function parseCliArgs(): ParsedCliArgs {
|
||||
return {
|
||||
help: Boolean(values.help),
|
||||
version: Boolean(values.version),
|
||||
edit: Boolean(values.edit),
|
||||
config: values.config as string | undefined,
|
||||
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
|
||||
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
|
||||
@@ -140,6 +146,7 @@ nudging the mouse cursor after a configurable idle period.
|
||||
Options:
|
||||
-h, --help Show this help 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}.
|
||||
|
||||
@@ -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"), "{}");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
+37
-4
@@ -12,13 +12,17 @@
|
||||
*
|
||||
* Order of operations:
|
||||
* 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
|
||||
* 2. `--help` / `--version` short-circuit before any I/O or mouse work.
|
||||
* 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
|
||||
* <path>` if supplied). Validation failures share the exit-2 path.
|
||||
* 4. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose
|
||||
* lives inside `Config` and is layered with the same precedence as
|
||||
* the numeric fields.
|
||||
* 5. Run keeper. Any unhandled rejection exits 1.
|
||||
* 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
|
||||
* above lets this file run as a real CLI once linked via `bun link`.
|
||||
@@ -26,10 +30,17 @@
|
||||
|
||||
import { parseCliArgs, printHelp, VERSION } from "./cli.ts";
|
||||
import type { ParsedCliArgs } from "./cli.ts";
|
||||
import { resolveConfig } from "./config.ts";
|
||||
import { defaultConfigPath, resolveConfig } from "./config.ts";
|
||||
import type { ConfigOverrides } from "./config.ts";
|
||||
import { loadConfigFile } from "./configFile.ts";
|
||||
import { runKeeper } from "./keeper.ts";
|
||||
import { editConfig } from "./editor.ts";
|
||||
|
||||
// `keeper.ts` is intentionally NOT statically imported here. It transitively
|
||||
// pulls in `@nut-tree-fork/nut-js`, which in turn dlopens a sizeable native
|
||||
// `.node` binary. On a cold first run that load dominates startup (~1 s on
|
||||
// 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
|
||||
@@ -81,6 +92,18 @@ if (cliArgs.version) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (cliArgs.edit) {
|
||||
// Edit is a fully terminal action: open the config file in $EDITOR and
|
||||
// hand the user's terminal over. Path resolution mirrors loadConfigFile's:
|
||||
// honor `--config <path>` if set, else use the XDG default.
|
||||
try {
|
||||
const path: string = cliArgs.config ?? defaultConfigPath();
|
||||
editConfig(path);
|
||||
} catch (err: unknown) {
|
||||
failUser(err);
|
||||
}
|
||||
}
|
||||
|
||||
let fileOverrides: ConfigOverrides | null;
|
||||
try {
|
||||
fileOverrides = loadConfigFile(cliArgs.config);
|
||||
@@ -98,4 +121,14 @@ const cliOverrides: ConfigOverrides = {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user