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.
204 lines
8.3 KiB
TypeScript
204 lines
8.3 KiB
TypeScript
/**
|
|
* config.ts
|
|
* ---------
|
|
* Runtime configuration types, defaults, the layered resolver, and the
|
|
* default config-file path.
|
|
*
|
|
* The keeper is driven by a single `Config` object that carries every
|
|
* tunable it cares about, including the `verbose` flag. Defaults live in
|
|
* `DEFAULT_CONFIG`; CLI and config-file overrides are layered on top by
|
|
* `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 (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.
|
|
*
|
|
* Layering precedence (highest wins):
|
|
* CLI overrides > file overrides > DEFAULT_CONFIG
|
|
*/
|
|
|
|
import { join } from "node:path";
|
|
|
|
import { CliError } from "./errors.ts";
|
|
import { isPatternName, type PatternName } from "./strategies.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); 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
|
|
* accidental mutation a type error.
|
|
*
|
|
* - `moveInterval` — how long the mouse must be idle (no real movement)
|
|
* before a synthetic sweep is triggered. Milliseconds.
|
|
* - `checkInterval` — cadence of the idleness poll in the main loop.
|
|
* Milliseconds.
|
|
* - `stepDelay` — pause between individual synthetic mouse steps inside
|
|
* a sweep. Also the window in which the user can
|
|
* "interrupt" by moving the cursor. Milliseconds.
|
|
* - `pattern` — name of the movement strategy to use (see
|
|
* `strategies.ts`; e.g. `line`, `walk`, `arc`). Each
|
|
* pattern owns its own size and step count.
|
|
* - `verbose` — whether per-sweep / interrupt / bounds events are
|
|
* logged. The startup banner is always printed.
|
|
*/
|
|
export interface Config {
|
|
readonly moveInterval: number;
|
|
readonly checkInterval: number;
|
|
readonly stepDelay: number;
|
|
readonly pattern: PatternName;
|
|
readonly verbose: boolean;
|
|
}
|
|
|
|
/**
|
|
* Shape of `scripts/config.default.json` after parsing. The cast below
|
|
* 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
|
|
pattern: string; // strategy name
|
|
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"] 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.pattern !== "string" || !isPatternName(r.pattern)) {
|
|
throw new Error(`scripts/config.default.json: 'pattern' must be a known strategy name (got ${JSON.stringify(r.pattern)})`);
|
|
}
|
|
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 = {
|
|
moveInterval: seed.moveInterval * 1000,
|
|
checkInterval: seed.checkInterval * 1000,
|
|
stepDelay: seed.stepDelay,
|
|
pattern: seed.pattern,
|
|
verbose: seed.verbose,
|
|
};
|
|
|
|
/**
|
|
* Common shape for override layers (CLI args and config-file content).
|
|
*
|
|
* `undefined` means "this layer doesn't supply a value"; the next layer
|
|
* down (file overrides, then DEFAULT_CONFIG) is consulted in that case.
|
|
*
|
|
* Numeric fields are in CLI / config-file units:
|
|
* moveInterval, checkInterval — seconds
|
|
* stepDelay — milliseconds
|
|
*
|
|
* `pattern` is a strategy name (`string | undefined`) and `verbose` is
|
|
* `boolean | undefined`, so every field shares 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 {
|
|
readonly moveInterval: number | undefined;
|
|
readonly checkInterval: number | undefined;
|
|
readonly stepDelay: number | undefined;
|
|
readonly pattern: string | undefined;
|
|
readonly verbose: boolean | undefined;
|
|
}
|
|
|
|
/**
|
|
* Resolve the default config-file path per the XDG Base Directory Spec.
|
|
* Honors `$XDG_CONFIG_HOME` if set; otherwise falls back to
|
|
* `$HOME/.config`. The file itself is always `move/config.json` under
|
|
* that base.
|
|
*
|
|
* Computed at call time (not at module load) so tests can override
|
|
* `XDG_CONFIG_HOME` after import.
|
|
*/
|
|
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 {
|
|
moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval),
|
|
checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval),
|
|
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
|
|
pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
|
|
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
|
|
};
|
|
}
|