From cfba134bc904590e69acf1929aa86c647df67f58 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 21 Aug 2026 14:51:55 +0000 Subject: [PATCH] cli-tools autoupdate: keep the checkout current on a timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cli-tools autoupdate --install` writes a systemd user timer that runs `cli-tools update --auto` daily. `--hours N` changes the interval, `--remove` takes it away, bare `autoupdate` shows when it last ran. Almost all of `update --auto` is about deciding not to act, and that is the point. The install is symlinks into a working tree, so updating moves somebody's real checkout — an unattended pull that discards work is far worse than a command being a day out of date. It proceeds only when the tree is clean, HEAD is the default branch, nothing is unpushed, and it is genuinely behind. Every refusal names the specific blocker, because the failure mode this guards against is concluding auto-update is broken while it is working exactly as designed. Two things that would have made this silently useless: - The unit bakes today's PATH in. A user unit starts with roughly /usr/bin:/bin, while every command here runs through a `npx --yes tsx` shebang whose node is a version manager's shim under $HOME. Without it the timer fires on schedule, fails to find node, and nothing looks wrong anywhere. - Status is read from `--porcelain=v2`, not the human-readable "Your branch is behind by N commits" line, which is localised — on a non-English machine that check would never fire. The stamp is written before the work rather than after, so a fetch that fails does not mean a retry on every invocation while the network is down. A stamp in the future reads as due, since a moved clock could otherwise stall updates for days. Also refreshes the /tools:install command doc, whose table had gone three commands stale, and the key list in `cli-tools --help`, which still named only openai and anthropic. Suite is 274 tests. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 30 +++++ bin/cli-tools.ts | 188 ++++++++++++++++++++++++++++-- plugins/tools/commands/install.md | 40 ++++++- src/selfupdate.ts | 186 +++++++++++++++++++++++++++++ test/selfupdate.test.ts | 147 +++++++++++++++++++++++ 5 files changed, 583 insertions(+), 8 deletions(-) create mode 100644 src/selfupdate.ts create mode 100644 test/selfupdate.test.ts diff --git a/README.md b/README.md index 35e5190..c115ee5 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,38 @@ cli-tools list # * runs from here, ! is shadowed by another copy cli-tools aliases --install # /blog /free /merge /prs /whois cli-tools config # API keys: what is set, and where it came from cli-tools update # git pull, reinstall, relink +cli-tools autoupdate --install # …or have a timer do that daily ``` +### Keeping it current + +`cli-tools autoupdate --install` writes a systemd **user** timer that runs +`cli-tools update --auto` once a day — `--hours N` to change the interval, +`--remove` to take it away, bare `autoupdate` to see when it last ran. + +`update --auto` is mostly a set of reasons not to act, and deliberately so. The +install is symlinks into a working tree, so updating moves your actual checkout; +an unattended pull that discards work is much worse than a command being a day +old. It proceeds only on a clean tree, on the default branch, with nothing +unpushed, and only when genuinely behind — and names the blocker otherwise, on +stderr, which is the journal when a timer runs it: + +```sh +cli-tools update --auto --force # ignore the once-a-day stamp +journalctl --user -u cli-tools-update # what it decided, and why +``` + +A checkout parked on a feature branch is therefore left alone. That is the +design rather than a failure. + +The unit **carries your current `PATH`**, because a user unit otherwise starts +with roughly `/usr/bin:/bin` while every command here runs through a `npx --yes +tsx` shebang whose node is usually a version manager's shim under `$HOME`. Get +that wrong and the timer fires perfectly on schedule, fails to find node, and +nothing anywhere looks broken. Note also that **user timers stop at logout** +unless lingering is on (`loginctl enable-linger`, which needs root); +`Persistent=true` means it catches up at the next login instead. +
From a clone, for development diff --git a/bin/cli-tools.ts b/bin/cli-tools.ts index 1c7b340..39b7b58 100755 --- a/bin/cli-tools.ts +++ b/bin/cli-tools.ts @@ -16,10 +16,21 @@ */ import { spawnSync } from 'node:child_process'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; -import { parseArgs, UsageError } from '../src/args.ts'; +import { integer, parseArgs, UsageError } from '../src/args.ts'; +import { + SERVICE_NAME, + TIMER_NAME, + decide, + formatInterval, + isDue, + parseStatus, + renderService, + renderTimer, +} from '../src/selfupdate.ts'; import { credentialsPath, keyStates, @@ -42,7 +53,8 @@ import { const USAGE = `Usage: cli-tools list - cli-tools update + cli-tools update [--auto] + cli-tools autoupdate [--install [--hours N] | --remove] cli-tools link [--force] cli-tools unlink cli-tools aliases [--install] @@ -52,6 +64,9 @@ const USAGE = `Usage: Commands: list Every command here, and whether it is on PATH update git pull, reinstall dependencies, relink + "--auto" is the unattended form: at most once a day, and only on a + clean checkout of the default branch with nothing unpushed + autoupdate A systemd user timer that runs "update --auto" for you link Symlink the commands into ~/.local/bin unlink Remove the symlinks we own aliases Print the moshcode pit aliases, or write them with --install @@ -62,17 +77,24 @@ Commands: Keys (config set ): openai OPENAI_API_KEY generate-names anthropic ANTHROPIC_API_KEY generate-names + perplexity PERPLEXITY_API_KEY ask-web + elevenlabs ELEVENLABS_API_KEY tts Options: --force link: take over a symlink owned by another checkout + update --auto: check now, ignoring the once-a-day stamp --install aliases: merge them into ~/.moshcode/aliases.json + autoupdate: write and enable the systemd user timer + --remove autoupdate: disable it and delete the units + --hours N autoupdate --install: how often to check (default: 24) + --auto update: the unattended form, safe to run from a timer --json list/aliases/config: machine-readable (config never prints a key) -h, --help `; const SPEC = { - boolean: ['--force', '--install', '--json', '-h', '--help'], - string: [], + boolean: ['--force', '--install', '--json', '--auto', '--remove', '-h', '--help'], + string: ['--hours'], } as const; function runLinks(root: string, args: readonly string[]): number { @@ -105,6 +127,147 @@ function update(root: string): number { return runLinks(root, []); } +/** Where the last automatic check is remembered. */ +function stampPath(env: NodeJS.ProcessEnv = process.env): string { + const state = env.XDG_STATE_HOME || join(env.HOME ?? homedir(), '.local', 'state'); + return join(state, 'cli-tools', 'update-stamp'); +} + +function readStamp(): number | null { + try { + return Number(readFileSync(stampPath(), 'utf8').trim()); + } catch { + return null; + } +} + +function writeStamp(now: number): void { + const path = stampPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${now}\n`); +} + +/** `origin/HEAD` when the remote publishes it, else master. */ +function defaultBranch(root: string): string { + const result = spawnSync('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { + cwd: root, + encoding: 'utf8', + }); + if (result.status !== 0) return 'master'; + const name = (result.stdout ?? '').trim().split('/').pop(); + return name || 'master'; +} + +/** + * The unattended path: check rarely, move only when it is unambiguously safe. + * + * Every refusal is printed rather than swallowed. This normally runs from a + * timer, where stderr lands in the journal, and "why is my checkout not + * updating" is otherwise unanswerable without reproducing the decision by hand. + */ +function autoUpdate(root: string, force: boolean): number { + const now = Date.now(); + if (!force && !isDue(readStamp(), now)) return 0; + + // Stamped before the work, not after: a fetch that fails should not mean a + // retry on every single invocation for as long as the network is down. + writeStamp(now); + + const fetched = spawnSync('git', ['fetch', '--quiet'], { cwd: root, encoding: 'utf8' }); + if (fetched.status !== 0) { + process.stderr.write(`update --auto: git fetch failed — ${(fetched.stderr ?? '').trim()}\n`); + return 0; + } + + const status = spawnSync('git', ['status', '--porcelain=v2', '--branch'], { + cwd: root, + encoding: 'utf8', + }); + if (status.status !== 0) { + process.stderr.write('update --auto: could not read git status\n'); + return 0; + } + + const decision = decide(parseStatus(status.stdout ?? ''), { + defaultBranch: defaultBranch(root), + }); + if (decision.action === 'skip') { + process.stderr.write(`update --auto: skipped — ${decision.reason}\n`); + return 0; + } + + process.stderr.write(`update --auto: ${decision.reason}\n`); + return update(root); +} + +function unitDir(env: NodeJS.ProcessEnv = process.env): string { + return join(env.XDG_CONFIG_HOME || join(env.HOME ?? homedir(), '.config'), 'systemd', 'user'); +} + +function systemctl(args: readonly string[]): number { + const result = spawnSync('systemctl', ['--user', ...args], { stdio: 'inherit' }); + if (result.error) { + process.stderr.write('autoupdate: systemctl --user is not available on this machine.\n'); + return 1; + } + return result.status ?? 1; +} + +/** + * Install, remove or report the timer. + * + * systemd rather than cron because the units are declarative, `Persistent=true` + * catches up a machine that was asleep, and the output of a failed run is in + * the journal instead of an email nobody configured. + */ +function autoupdate(root: string, flags: Set, hours: number): number { + const dir = unitDir(); + const service = join(dir, SERVICE_NAME); + const timer = join(dir, TIMER_NAME); + + if (flags.has('--remove')) { + systemctl(['disable', '--now', TIMER_NAME]); + for (const path of [service, timer]) { + try { + rmSync(path); + } catch { + // Already gone is the outcome we wanted. + } + } + systemctl(['daemon-reload']); + process.stdout.write('autoupdate: removed\n'); + return 0; + } + + if (flags.has('--install')) { + // The installed symlink is preferred over this checkout's path: it is the + // name the operator actually uses, and it keeps working if the checkout + // moves and is re-linked. + const linked = join(process.env.HOME ?? homedir(), '.local', 'bin', 'cli-tools'); + const exec = existsSync(linked) ? linked : join(root, 'bin', 'cli-tools.ts'); + + mkdirSync(dir, { recursive: true }); + writeFileSync(service, renderService(exec, process.env.PATH)); + writeFileSync(timer, renderTimer(hours * 3600)); + + if (systemctl(['daemon-reload']) !== 0) return 1; + if (systemctl(['enable', '--now', TIMER_NAME]) !== 0) return 1; + + process.stdout.write(`autoupdate: enabled, every ${formatInterval(hours * 3600)}\n${timer}\n`); + process.stdout.write( + 'Note: user timers stop when you log out unless lingering is on\n' + + ' (`loginctl enable-linger` — needs root).\n', + ); + return 0; + } + + if (!existsSync(timer)) { + process.stdout.write('autoupdate: not installed — `cli-tools autoupdate --install`\n'); + return 0; + } + return systemctl(['list-timers', '--all', TIMER_NAME]); +} + function writeAliases(): number { const path = aliasesPath(); let existing: Record = {}; @@ -382,7 +545,9 @@ export async function run(argv: readonly string[]): Promise { // Anything that is not one of ours is one of the commands: pass it straight // through, arguments and streams untouched, so `cli-tools gh-prs --orgs x` // behaves exactly as `gh-prs --orgs x` does. - const known = new Set(['list', 'update', 'link', 'unlink', 'aliases', 'config', 'where']); + const known = new Set([ + 'list', 'update', 'autoupdate', 'link', 'unlink', 'aliases', 'config', 'where', + ]); if (!known.has(command)) { const match = commands(root).find((entry) => entry.name === command); if (!match) { @@ -465,7 +630,16 @@ export async function run(argv: readonly string[]): Promise { } case 'update': - return update(root); + return options.flags.has('--auto') + ? autoUpdate(root, options.flags.has('--force')) + : update(root); + + case 'autoupdate': + return autoupdate( + root, + options.flags, + integer(options.values, '--hours', 24, { min: 1, max: 24 * 30 }), + ); case 'link': return runLinks(root, options.flags.has('--force') ? ['--force'] : []); diff --git a/plugins/tools/commands/install.md b/plugins/tools/commands/install.md index d5c3cb9..459cf12 100644 --- a/plugins/tools/commands/install.md +++ b/plugins/tools/commands/install.md @@ -34,14 +34,22 @@ The installer clones to `~/.local/share/cli-tools` (override with | Command | What it does | | --- | --- | +| `affiliate` | Work through a list of programs you mean to sign up for | +| `ask-web` | Answer a question from the live web, with its sources | | `blog-post` | Publish to a plain-HTML blog without breaking the feed | | `cli-tools` | This dispatcher | | `domainfree` | Which of these domains you can actually register | | `domainjson` | whois-style, JSON-first name lookup | +| `generate-names` | Turn a sentence about a product into candidate names | | `gh-prs` | Every open PR across the owners you name | | `gh-prs-fix-all` | Repair the open scan PRs that are broken because of us | | `gh-prs-merge` | Squash-merge the PRs that are genuinely ready | | `tcfeed` | Find repositories worth scanning, scan them, print a shortlist | +| `tts` | Read text aloud and keep the audio | + +Rather than listing them by hand, `cli-tools list` reads `bin/` — a new command +is a new file there and nothing else has to be edited, so that output is right +when this table has gone stale. Check what took: @@ -109,12 +117,42 @@ been sourced first. The aliases only buy you a shorter word. ## Keeping it current ```bash -cli-tools update # git pull, reinstall dependencies, relink +cli-tools autoupdate --install # a systemd user timer; check daily from now on +cli-tools update # or do it now, by hand ``` `update` refuses to move a dirty or diverged checkout rather than discarding work. If it stops, sort the checkout out at `cli-tools where` and retry. +### Auto-update + +`cli-tools autoupdate --install` writes a systemd **user** timer that runs +`cli-tools update --auto` once a day (`--hours N` to change it, `--remove` to +take it away, and bare `autoupdate` to see when it last ran). + +`update --auto` is the unattended form, and almost all of it is about deciding +*not* to act. The install is symlinks into a working tree, so updating means +moving somebody's real checkout — an unattended pull that discards work is far +worse than a command being a day out of date. It proceeds only when all of +these hold, and names the one in the way otherwise: + +- the tree is clean, +- HEAD is the default branch (`origin/HEAD`, else `master`), +- nothing is unpushed, +- and it is genuinely behind. + +So on a checkout parked on a feature branch it does nothing and says so. That is +the design, not a failure — the refusals go to stderr, which is the journal when +the timer runs it (`journalctl --user -u cli-tools-update`). + +Two things worth knowing. The unit **bakes today's `PATH` in**, because a user +unit otherwise starts with roughly `/usr/bin:/bin` while these commands run +through a `npx --yes tsx` shebang whose node is usually a version manager's shim +under `$HOME` — without it the timer fires on schedule, fails to find node, and +nothing looks wrong. And **user timers stop when you log out** unless lingering +is enabled (`loginctl enable-linger`, which needs root); on a laptop that is +fine, since `Persistent=true` makes it catch up on the next login. + Note that the installed command runs **whatever branch the checkout is on** — these are symlinks into a working tree, not a copied build. A checkout parked on an old branch silently runs old code, so `cli-tools where` and a `git branch diff --git a/src/selfupdate.ts b/src/selfupdate.ts new file mode 100644 index 0000000..d7f43ed --- /dev/null +++ b/src/selfupdate.ts @@ -0,0 +1,186 @@ +/** + * Keeping the checkout current without being asked. + * + * The install is symlinks into a working tree, not a copied build, so "update" + * means moving somebody's actual checkout. That is why almost all of this file + * is about deciding *not* to: an unattended `git pull` that discards work, or + * that quietly moves a branch someone was mid-way through, is far worse than a + * command being a day out of date. + * + * So the rule is narrow and boring. Auto-update runs only when the tree is + * clean, sitting on the default branch, with nothing of its own to push, and + * genuinely behind. Anything else is reported and skipped. A person typing + * `cli-tools update` still gets the old behaviour — their explicit ask, their + * call. + */ + +/** How often the stamp lets an automatic check happen at all. */ +export const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export interface GitStatus { + /** The branch name, or null on a detached HEAD. */ + head: string | null; + upstream: string | null; + ahead: number; + behind: number; + dirty: boolean; +} + +/** + * Parse `git status --porcelain=v2 --branch`. + * + * v2 rather than v1 because it reports ahead/behind as data. The alternative is + * scraping the human-readable "Your branch is behind … by 3 commits" line, + * which is localised — on a machine with a non-English locale that check + * silently never fires. + */ +export function parseStatus(text: string): GitStatus { + const status: GitStatus = { head: null, upstream: null, ahead: 0, behind: 0, dirty: false }; + + for (const line of text.split('\n')) { + if (line.startsWith('# branch.head ')) { + const head = line.slice('# branch.head '.length).trim(); + status.head = head === '(detached)' ? null : head; + continue; + } + if (line.startsWith('# branch.upstream ')) { + status.upstream = line.slice('# branch.upstream '.length).trim(); + continue; + } + if (line.startsWith('# branch.ab ')) { + const match = line.match(/\+(\d+)\s+-(\d+)/); + if (match) { + status.ahead = Number(match[1]); + status.behind = Number(match[2]); + } + continue; + } + // Anything not a header is a changed, untracked or unmerged path. + if (line && !line.startsWith('#')) status.dirty = true; + } + + return status; +} + +export type Decision = + | { action: 'pull'; reason: string } + | { action: 'skip'; reason: string }; + +export interface DecideOptions { + /** The branch an unattended update is allowed to move. */ + defaultBranch?: string; +} + +/** + * Should an unattended update move this checkout? + * + * Each refusal names the specific thing in the way, because the failure this + * guards against is someone concluding auto-update is broken when it is working + * exactly as designed — sitting on a feature branch, refusing to touch it. + */ +export function decide(status: GitStatus, options: DecideOptions = {}): Decision { + const defaultBranch = options.defaultBranch ?? 'master'; + + if (status.head === null) { + return { action: 'skip', reason: 'detached HEAD — nothing to fast-forward' }; + } + if (status.head !== defaultBranch) { + return { + action: 'skip', + reason: `on ${status.head}, not ${defaultBranch} — a branch you are working on is yours to move`, + }; + } + if (status.dirty) { + return { action: 'skip', reason: 'uncommitted changes — not touching them' }; + } + if (!status.upstream) { + return { action: 'skip', reason: 'no upstream branch to compare against' }; + } + if (status.ahead > 0) { + return { + action: 'skip', + reason: `${status.ahead} commit(s) not pushed — pushing or rebasing them is yours to do`, + }; + } + if (status.behind === 0) { + return { action: 'skip', reason: 'already current' }; + } + + return { action: 'pull', reason: `${status.behind} commit(s) behind ${status.upstream}` }; +} + +/** + * Has enough time passed since the last automatic check? + * + * The stamp exists so that wiring this into something that runs often does not + * turn every invocation into a network round trip. A missing or unreadable + * stamp reads as "due", which fails toward doing the check rather than toward + * never doing it. + */ +export function isDue( + stamp: number | null, + now: number, + intervalMs: number = DEFAULT_INTERVAL_MS, +): boolean { + if (stamp === null || !Number.isFinite(stamp)) return true; + // A stamp in the future is a clock that moved; treat it as due rather than + // waiting out a gap that might be days long. + if (stamp > now) return true; + return now - stamp >= intervalMs; +} + +export const SERVICE_NAME = 'cli-tools-update.service'; +export const TIMER_NAME = 'cli-tools-update.timer'; + +/** + * The unit that does the work. + * + * The PATH is baked in rather than inherited, and that is the whole reason this + * is a function instead of a static file. A user unit starts with a minimal + * PATH — roughly `/usr/bin:/bin` — while the commands here run through a `npx + * --yes tsx` shebang and the node behind it is usually a version manager's shim + * somewhere under the home directory. Without the captured PATH the timer fires + * on schedule, fails to find node, and reports success at the only place anyone + * would look, so the checkout silently never updates. + * + * `Nice` because this is never the thing anyone is waiting for. + */ +export function renderService(execPath: string, pathEnv?: string): string { + return `[Unit] +Description=cli-tools auto-update +Documentation=https://github.com/profullstack/cli-tools#keeping-it-current + +[Service] +Type=oneshot +${pathEnv ? `Environment=PATH=${pathEnv}\n` : ''}ExecStart=${execPath} update --auto +Nice=10 +`; +} + +/** + * `Persistent=true` so a machine that was asleep at the scheduled time runs the + * check once when it comes back, rather than skipping the day entirely. + */ +export function renderTimer(intervalSec: number): string { + return `[Unit] +Description=cli-tools auto-update (every ${formatInterval(intervalSec)}) +Documentation=https://github.com/profullstack/cli-tools#keeping-it-current + +[Timer] +OnBootSec=5min +OnUnitActiveSec=${intervalSec}sec +AccuracySec=1min +Persistent=true +Unit=${SERVICE_NAME} + +[Install] +WantedBy=timers.target +`; +} + +export function formatInterval(seconds: number): string { + if (seconds % 86_400 === 0) return `${seconds / 86_400}d`; + if (seconds % 3_600 === 0) return `${seconds / 3_600}h`; + if (seconds % 60 === 0) return `${seconds / 60}min`; + return `${seconds}s`; +} diff --git a/test/selfupdate.test.ts b/test/selfupdate.test.ts new file mode 100644 index 0000000..47d096d --- /dev/null +++ b/test/selfupdate.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_INTERVAL_MS, + SERVICE_NAME, + decide, + formatInterval, + isDue, + parseStatus, + renderService, + renderTimer, +} from '../src/selfupdate.ts'; + +const CLEAN = `# branch.oid abc123 +# branch.head master +# branch.upstream origin/master +# branch.ab +0 -3 +`; + +describe('parseStatus', () => { + // v2 rather than the human-readable "Your branch is behind by 3 commits", + // which is localised — on a non-English machine that check never fires. + it('reads branch, upstream and ahead/behind', () => { + expect(parseStatus(CLEAN)).toEqual({ + head: 'master', + upstream: 'origin/master', + ahead: 0, + behind: 3, + dirty: false, + }); + }); + + it('treats any non-header line as a dirty tree', () => { + expect(parseStatus(`${CLEAN}1 .M N... 100644 100644 100644 aaa bbb src/x.ts`).dirty).toBe(true); + expect(parseStatus(`${CLEAN}? untracked.txt`).dirty).toBe(true); + }); + + it('reports a detached HEAD as no branch', () => { + expect(parseStatus('# branch.head (detached)\n').head).toBeNull(); + }); + + it('survives a status with no upstream', () => { + const status = parseStatus('# branch.head master\n'); + expect(status.upstream).toBeNull(); + expect(status.behind).toBe(0); + }); +}); + +describe('decide', () => { + const base = parseStatus(CLEAN); + + it('pulls a clean default branch that is behind', () => { + expect(decide(base)).toEqual({ + action: 'pull', + reason: '3 commit(s) behind origin/master', + }); + }); + + // The install is symlinks into a working tree, so an unattended pull moves + // somebody's actual checkout. Each refusal names what is in the way. + it('refuses a branch that is not the default one', () => { + const decision = decide({ ...base, head: 'feature-x' }); + expect(decision.action).toBe('skip'); + expect(decision.reason).toMatch(/on feature-x, not master/); + }); + + it('refuses a dirty tree', () => { + expect(decide({ ...base, dirty: true }).reason).toMatch(/uncommitted changes/); + }); + + it('refuses when there are unpushed commits', () => { + expect(decide({ ...base, ahead: 2 }).reason).toMatch(/2 commit\(s\) not pushed/); + }); + + it('refuses a detached HEAD', () => { + expect(decide({ ...base, head: null }).reason).toMatch(/detached HEAD/); + }); + + it('does nothing when already current', () => { + expect(decide({ ...base, behind: 0 })).toEqual({ action: 'skip', reason: 'already current' }); + }); + + it('honours a different default branch', () => { + expect(decide({ ...base, head: 'main' }, { defaultBranch: 'main' }).action).toBe('pull'); + }); +}); + +describe('isDue', () => { + it('is due when nothing has been recorded', () => { + expect(isDue(null, 1_000)).toBe(true); + }); + + it('waits out the interval', () => { + const now = DEFAULT_INTERVAL_MS * 2; + expect(isDue(now - 1_000, now)).toBe(false); + expect(isDue(now - DEFAULT_INTERVAL_MS, now)).toBe(true); + }); + + // A stamp in the future is a clock that moved; waiting it out could be days. + it('is due when the stamp is in the future', () => { + expect(isDue(5_000, 1_000)).toBe(true); + }); + + it('is due when the stamp is unreadable', () => { + expect(isDue(Number.NaN, 1_000)).toBe(true); + }); +}); + +describe('renderService', () => { + // A user unit starts with roughly /usr/bin:/bin, while node here is a version + // manager's shim under $HOME. Without this the timer fires, fails to find + // node, and the checkout silently never updates. + it('bakes the PATH in when given one', () => { + expect(renderService('/home/a/.local/bin/cli-tools', '/home/a/.local/share/mise/shims:/usr/bin')) + .toContain('Environment=PATH=/home/a/.local/share/mise/shims:/usr/bin'); + }); + + it('omits the line entirely when there is no PATH to carry', () => { + expect(renderService('/x/cli-tools')).not.toContain('Environment='); + }); + + it('runs the unattended form', () => { + expect(renderService('/x/cli-tools')).toContain('ExecStart=/x/cli-tools update --auto'); + }); +}); + +describe('renderTimer', () => { + it('catches up a machine that was asleep', () => { + expect(renderTimer(86_400)).toContain('Persistent=true'); + }); + + it('points at the service and installs into timers.target', () => { + const timer = renderTimer(3_600); + expect(timer).toContain(`Unit=${SERVICE_NAME}`); + expect(timer).toContain('OnUnitActiveSec=3600sec'); + expect(timer).toContain('WantedBy=timers.target'); + }); +}); + +describe('formatInterval', () => { + it('picks the largest whole unit', () => { + expect(formatInterval(86_400)).toBe('1d'); + expect(formatInterval(3_600)).toBe('1h'); + expect(formatInterval(1_800)).toBe('30min'); + expect(formatInterval(45)).toBe('45s'); + }); +});