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'); + }); +});