Introduce a continuous "loop" setting so a triggered sweep keeps the
cursor moving until the user moves the mouse (or Ctrl+C), instead of
firing a single sweep.
- strategies.ts: add optional `loopPath` to MovementStrategy; give `line`
and `diagonal` infinite loop generators that pick a direction once and
ramp forever (4px/step). Their finite `path` and declared `bounds` are
unchanged, so single-sweep behavior is identical.
- executor.ts: add ExecuteOptions { restore?, bounds?, loop? }. Omitting
options reproduces the original single-sweep contract exactly.
- keeper.ts: in loop mode, run an infinite loopPath once (stopped only by
interruption) or chain a finite path cycle after cycle; force `reflect`
bounds for every pattern and suppress the between-cycle restore, so
line/diagonal bounce edge-to-edge instead of stopping at the first edge.
- config plumbing: new boolean `loop` through config.default.json,
config.ts, configFile.ts, cli.ts (-l/--loop), and move.ts, mirroring
the existing `verbose` precedence.
- docs: README loop-mode section + usage/validation updates; CHANGELOG
Unreleased entry.
- tests: loopPath generators, executor options (bounds override, loop
selection, restore suppression), config/configFile loop plumbing, and
keeper-level loop behavior (ramps far vs. bounded single-sweep, chained
cycles). 79 pass.
184 lines
6.6 KiB
TypeScript
184 lines
6.6 KiB
TypeScript
/**
|
|
* 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
|
|
* pattern string a registered strategy name
|
|
* verbose boolean
|
|
* loop 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. The removed `stepCount` /
|
|
* `stepSize` keys are the exception: they're tolerated (ignored with a
|
|
* one-line notice) so an older seeded config keeps working after upgrade.
|
|
*
|
|
* 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";
|
|
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
|
|
|
|
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
|
|
"moveInterval",
|
|
"checkInterval",
|
|
"stepDelay",
|
|
"pattern",
|
|
"verbose",
|
|
"loop",
|
|
]);
|
|
|
|
/**
|
|
* Keys that used to be valid but have since been removed. They're tolerated
|
|
* (not rejected like a genuine unknown key) so upgrading doesn't hard-fail a
|
|
* config that was seeded with them — every pre-1.3.0 install has `stepCount`
|
|
* in its file. They no longer do anything: sweep size and step count are now
|
|
* properties of each movement pattern. A one-line notice points the user at
|
|
* the file so they can remove them at leisure.
|
|
*/
|
|
const DEPRECATED_KEYS: ReadonlySet<string> = new Set<string>([
|
|
"stepCount",
|
|
"stepSize",
|
|
]);
|
|
|
|
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;
|
|
}
|
|
|
|
function requirePatternName(name: string, raw: unknown, path: string): string {
|
|
const canonical: string | null = typeof raw === "string" ? resolvePatternName(raw) : null;
|
|
if (canonical === null) {
|
|
throw new CliError(
|
|
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (valid: ${PATTERN_NAMES.join(", ")})`,
|
|
);
|
|
}
|
|
return canonical;
|
|
}
|
|
|
|
/**
|
|
* 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'), but tolerate keys we've since removed — collect those
|
|
// and warn once, rather than hard-failing a config seeded by an older
|
|
// install.
|
|
const deprecatedFound: string[] = [];
|
|
for (const key of Object.keys(parsed)) {
|
|
if (ALLOWED_KEYS.has(key)) continue;
|
|
if (DEPRECATED_KEYS.has(key)) {
|
|
deprecatedFound.push(key);
|
|
continue;
|
|
}
|
|
const allowed: string = [...ALLOWED_KEYS].join(", ");
|
|
throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`);
|
|
}
|
|
if (deprecatedFound.length > 0) {
|
|
const names: string = deprecatedFound.map((k) => `'${k}'`).join(", ");
|
|
process.stderr.write(
|
|
`move: ignoring obsolete key(s) ${names} in ${path}\n` +
|
|
` (sweep size is now defined by each movement pattern)\n`,
|
|
);
|
|
}
|
|
|
|
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,
|
|
pattern:
|
|
"pattern" in parsed
|
|
? requirePatternName("pattern", parsed.pattern, path)
|
|
: undefined,
|
|
verbose:
|
|
"verbose" in parsed
|
|
? requireBoolean("verbose", parsed.verbose, path)
|
|
: undefined,
|
|
loop:
|
|
"loop" in parsed
|
|
? requireBoolean("loop", parsed.loop, path)
|
|
: undefined,
|
|
};
|
|
}
|