Skip to content

Commit cfba134

Browse files
ralyodioclaude
andcommitted
cli-tools autoupdate: keep the checkout current on a timer
`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) <noreply@anthropic.com>
1 parent ed39d55 commit cfba134

5 files changed

Lines changed: 583 additions & 8 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,38 @@ cli-tools list # * runs from here, ! is shadowed by another copy
5050
cli-tools aliases --install # /blog /free /merge /prs /whois
5151
cli-tools config # API keys: what is set, and where it came from
5252
cli-tools update # git pull, reinstall, relink
53+
cli-tools autoupdate --install # …or have a timer do that daily
5354
```
5455

56+
### Keeping it current
57+
58+
`cli-tools autoupdate --install` writes a systemd **user** timer that runs
59+
`cli-tools update --auto` once a day — `--hours N` to change the interval,
60+
`--remove` to take it away, bare `autoupdate` to see when it last ran.
61+
62+
`update --auto` is mostly a set of reasons not to act, and deliberately so. The
63+
install is symlinks into a working tree, so updating moves your actual checkout;
64+
an unattended pull that discards work is much worse than a command being a day
65+
old. It proceeds only on a clean tree, on the default branch, with nothing
66+
unpushed, and only when genuinely behind — and names the blocker otherwise, on
67+
stderr, which is the journal when a timer runs it:
68+
69+
```sh
70+
cli-tools update --auto --force # ignore the once-a-day stamp
71+
journalctl --user -u cli-tools-update # what it decided, and why
72+
```
73+
74+
A checkout parked on a feature branch is therefore left alone. That is the
75+
design rather than a failure.
76+
77+
The unit **carries your current `PATH`**, because a user unit otherwise starts
78+
with roughly `/usr/bin:/bin` while every command here runs through a `npx --yes
79+
tsx` shebang whose node is usually a version manager's shim under `$HOME`. Get
80+
that wrong and the timer fires perfectly on schedule, fails to find node, and
81+
nothing anywhere looks broken. Note also that **user timers stop at logout**
82+
unless lingering is on (`loginctl enable-linger`, which needs root);
83+
`Persistent=true` means it catches up at the next login instead.
84+
5585
<details>
5686
<summary>From a clone, for development</summary>
5787

bin/cli-tools.ts

Lines changed: 181 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,21 @@
1616
*/
1717

1818
import { spawnSync } from 'node:child_process';
19-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
20+
import { homedir } from 'node:os';
2021
import { dirname, join } from 'node:path';
2122

22-
import { parseArgs, UsageError } from '../src/args.ts';
23+
import { integer, parseArgs, UsageError } from '../src/args.ts';
24+
import {
25+
SERVICE_NAME,
26+
TIMER_NAME,
27+
decide,
28+
formatInterval,
29+
isDue,
30+
parseStatus,
31+
renderService,
32+
renderTimer,
33+
} from '../src/selfupdate.ts';
2334
import {
2435
credentialsPath,
2536
keyStates,
@@ -42,7 +53,8 @@ import {
4253

4354
const USAGE = `Usage:
4455
cli-tools list
45-
cli-tools update
56+
cli-tools update [--auto]
57+
cli-tools autoupdate [--install [--hours N] | --remove]
4658
cli-tools link [--force]
4759
cli-tools unlink
4860
cli-tools aliases [--install]
@@ -52,6 +64,9 @@ const USAGE = `Usage:
5264
Commands:
5365
list Every command here, and whether it is on PATH
5466
update git pull, reinstall dependencies, relink
67+
"--auto" is the unattended form: at most once a day, and only on a
68+
clean checkout of the default branch with nothing unpushed
69+
autoupdate A systemd user timer that runs "update --auto" for you
5570
link Symlink the commands into ~/.local/bin
5671
unlink Remove the symlinks we own
5772
aliases Print the moshcode pit aliases, or write them with --install
@@ -62,17 +77,24 @@ Commands:
6277
Keys (config set <key>):
6378
openai OPENAI_API_KEY generate-names
6479
anthropic ANTHROPIC_API_KEY generate-names
80+
perplexity PERPLEXITY_API_KEY ask-web
81+
elevenlabs ELEVENLABS_API_KEY tts
6582
6683
Options:
6784
--force link: take over a symlink owned by another checkout
85+
update --auto: check now, ignoring the once-a-day stamp
6886
--install aliases: merge them into ~/.moshcode/aliases.json
87+
autoupdate: write and enable the systemd user timer
88+
--remove autoupdate: disable it and delete the units
89+
--hours N autoupdate --install: how often to check (default: 24)
90+
--auto update: the unattended form, safe to run from a timer
6991
--json list/aliases/config: machine-readable (config never prints a key)
7092
-h, --help
7193
`;
7294

7395
const SPEC = {
74-
boolean: ['--force', '--install', '--json', '-h', '--help'],
75-
string: [],
96+
boolean: ['--force', '--install', '--json', '--auto', '--remove', '-h', '--help'],
97+
string: ['--hours'],
7698
} as const;
7799

78100
function runLinks(root: string, args: readonly string[]): number {
@@ -105,6 +127,147 @@ function update(root: string): number {
105127
return runLinks(root, []);
106128
}
107129

130+
/** Where the last automatic check is remembered. */
131+
function stampPath(env: NodeJS.ProcessEnv = process.env): string {
132+
const state = env.XDG_STATE_HOME || join(env.HOME ?? homedir(), '.local', 'state');
133+
return join(state, 'cli-tools', 'update-stamp');
134+
}
135+
136+
function readStamp(): number | null {
137+
try {
138+
return Number(readFileSync(stampPath(), 'utf8').trim());
139+
} catch {
140+
return null;
141+
}
142+
}
143+
144+
function writeStamp(now: number): void {
145+
const path = stampPath();
146+
mkdirSync(dirname(path), { recursive: true });
147+
writeFileSync(path, `${now}\n`);
148+
}
149+
150+
/** `origin/HEAD` when the remote publishes it, else master. */
151+
function defaultBranch(root: string): string {
152+
const result = spawnSync('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], {
153+
cwd: root,
154+
encoding: 'utf8',
155+
});
156+
if (result.status !== 0) return 'master';
157+
const name = (result.stdout ?? '').trim().split('/').pop();
158+
return name || 'master';
159+
}
160+
161+
/**
162+
* The unattended path: check rarely, move only when it is unambiguously safe.
163+
*
164+
* Every refusal is printed rather than swallowed. This normally runs from a
165+
* timer, where stderr lands in the journal, and "why is my checkout not
166+
* updating" is otherwise unanswerable without reproducing the decision by hand.
167+
*/
168+
function autoUpdate(root: string, force: boolean): number {
169+
const now = Date.now();
170+
if (!force && !isDue(readStamp(), now)) return 0;
171+
172+
// Stamped before the work, not after: a fetch that fails should not mean a
173+
// retry on every single invocation for as long as the network is down.
174+
writeStamp(now);
175+
176+
const fetched = spawnSync('git', ['fetch', '--quiet'], { cwd: root, encoding: 'utf8' });
177+
if (fetched.status !== 0) {
178+
process.stderr.write(`update --auto: git fetch failed — ${(fetched.stderr ?? '').trim()}\n`);
179+
return 0;
180+
}
181+
182+
const status = spawnSync('git', ['status', '--porcelain=v2', '--branch'], {
183+
cwd: root,
184+
encoding: 'utf8',
185+
});
186+
if (status.status !== 0) {
187+
process.stderr.write('update --auto: could not read git status\n');
188+
return 0;
189+
}
190+
191+
const decision = decide(parseStatus(status.stdout ?? ''), {
192+
defaultBranch: defaultBranch(root),
193+
});
194+
if (decision.action === 'skip') {
195+
process.stderr.write(`update --auto: skipped — ${decision.reason}\n`);
196+
return 0;
197+
}
198+
199+
process.stderr.write(`update --auto: ${decision.reason}\n`);
200+
return update(root);
201+
}
202+
203+
function unitDir(env: NodeJS.ProcessEnv = process.env): string {
204+
return join(env.XDG_CONFIG_HOME || join(env.HOME ?? homedir(), '.config'), 'systemd', 'user');
205+
}
206+
207+
function systemctl(args: readonly string[]): number {
208+
const result = spawnSync('systemctl', ['--user', ...args], { stdio: 'inherit' });
209+
if (result.error) {
210+
process.stderr.write('autoupdate: systemctl --user is not available on this machine.\n');
211+
return 1;
212+
}
213+
return result.status ?? 1;
214+
}
215+
216+
/**
217+
* Install, remove or report the timer.
218+
*
219+
* systemd rather than cron because the units are declarative, `Persistent=true`
220+
* catches up a machine that was asleep, and the output of a failed run is in
221+
* the journal instead of an email nobody configured.
222+
*/
223+
function autoupdate(root: string, flags: Set<string>, hours: number): number {
224+
const dir = unitDir();
225+
const service = join(dir, SERVICE_NAME);
226+
const timer = join(dir, TIMER_NAME);
227+
228+
if (flags.has('--remove')) {
229+
systemctl(['disable', '--now', TIMER_NAME]);
230+
for (const path of [service, timer]) {
231+
try {
232+
rmSync(path);
233+
} catch {
234+
// Already gone is the outcome we wanted.
235+
}
236+
}
237+
systemctl(['daemon-reload']);
238+
process.stdout.write('autoupdate: removed\n');
239+
return 0;
240+
}
241+
242+
if (flags.has('--install')) {
243+
// The installed symlink is preferred over this checkout's path: it is the
244+
// name the operator actually uses, and it keeps working if the checkout
245+
// moves and is re-linked.
246+
const linked = join(process.env.HOME ?? homedir(), '.local', 'bin', 'cli-tools');
247+
const exec = existsSync(linked) ? linked : join(root, 'bin', 'cli-tools.ts');
248+
249+
mkdirSync(dir, { recursive: true });
250+
writeFileSync(service, renderService(exec, process.env.PATH));
251+
writeFileSync(timer, renderTimer(hours * 3600));
252+
253+
if (systemctl(['daemon-reload']) !== 0) return 1;
254+
if (systemctl(['enable', '--now', TIMER_NAME]) !== 0) return 1;
255+
256+
process.stdout.write(`autoupdate: enabled, every ${formatInterval(hours * 3600)}\n${timer}\n`);
257+
process.stdout.write(
258+
'Note: user timers stop when you log out unless lingering is on\n' +
259+
' (`loginctl enable-linger` — needs root).\n',
260+
);
261+
return 0;
262+
}
263+
264+
if (!existsSync(timer)) {
265+
process.stdout.write('autoupdate: not installed — `cli-tools autoupdate --install`\n');
266+
return 0;
267+
}
268+
return systemctl(['list-timers', '--all', TIMER_NAME]);
269+
}
270+
108271
function writeAliases(): number {
109272
const path = aliasesPath();
110273
let existing: Record<string, string> = {};
@@ -382,7 +545,9 @@ export async function run(argv: readonly string[]): Promise<number> {
382545
// Anything that is not one of ours is one of the commands: pass it straight
383546
// through, arguments and streams untouched, so `cli-tools gh-prs --orgs x`
384547
// behaves exactly as `gh-prs --orgs x` does.
385-
const known = new Set(['list', 'update', 'link', 'unlink', 'aliases', 'config', 'where']);
548+
const known = new Set([
549+
'list', 'update', 'autoupdate', 'link', 'unlink', 'aliases', 'config', 'where',
550+
]);
386551
if (!known.has(command)) {
387552
const match = commands(root).find((entry) => entry.name === command);
388553
if (!match) {
@@ -465,7 +630,16 @@ export async function run(argv: readonly string[]): Promise<number> {
465630
}
466631

467632
case 'update':
468-
return update(root);
633+
return options.flags.has('--auto')
634+
? autoUpdate(root, options.flags.has('--force'))
635+
: update(root);
636+
637+
case 'autoupdate':
638+
return autoupdate(
639+
root,
640+
options.flags,
641+
integer(options.values, '--hours', 24, { min: 1, max: 24 * 30 }),
642+
);
469643

470644
case 'link':
471645
return runLinks(root, options.flags.has('--force') ? ['--force'] : []);

plugins/tools/commands/install.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,22 @@ The installer clones to `~/.local/share/cli-tools` (override with
3434

3535
| Command | What it does |
3636
| --- | --- |
37+
| `affiliate` | Work through a list of programs you mean to sign up for |
38+
| `ask-web` | Answer a question from the live web, with its sources |
3739
| `blog-post` | Publish to a plain-HTML blog without breaking the feed |
3840
| `cli-tools` | This dispatcher |
3941
| `domainfree` | Which of these domains you can actually register |
4042
| `domainjson` | whois-style, JSON-first name lookup |
43+
| `generate-names` | Turn a sentence about a product into candidate names |
4144
| `gh-prs` | Every open PR across the owners you name |
4245
| `gh-prs-fix-all` | Repair the open scan PRs that are broken because of us |
4346
| `gh-prs-merge` | Squash-merge the PRs that are genuinely ready |
4447
| `tcfeed` | Find repositories worth scanning, scan them, print a shortlist |
48+
| `tts` | Read text aloud and keep the audio |
49+
50+
Rather than listing them by hand, `cli-tools list` reads `bin/` — a new command
51+
is a new file there and nothing else has to be edited, so that output is right
52+
when this table has gone stale.
4553

4654
Check what took:
4755

@@ -109,12 +117,42 @@ been sourced first. The aliases only buy you a shorter word.
109117
## Keeping it current
110118

111119
```bash
112-
cli-tools update # git pull, reinstall dependencies, relink
120+
cli-tools autoupdate --install # a systemd user timer; check daily from now on
121+
cli-tools update # or do it now, by hand
113122
```
114123

115124
`update` refuses to move a dirty or diverged checkout rather than discarding
116125
work. If it stops, sort the checkout out at `cli-tools where` and retry.
117126

127+
### Auto-update
128+
129+
`cli-tools autoupdate --install` writes a systemd **user** timer that runs
130+
`cli-tools update --auto` once a day (`--hours N` to change it, `--remove` to
131+
take it away, and bare `autoupdate` to see when it last ran).
132+
133+
`update --auto` is the unattended form, and almost all of it is about deciding
134+
*not* to act. The install is symlinks into a working tree, so updating means
135+
moving somebody's real checkout — an unattended pull that discards work is far
136+
worse than a command being a day out of date. It proceeds only when all of
137+
these hold, and names the one in the way otherwise:
138+
139+
- the tree is clean,
140+
- HEAD is the default branch (`origin/HEAD`, else `master`),
141+
- nothing is unpushed,
142+
- and it is genuinely behind.
143+
144+
So on a checkout parked on a feature branch it does nothing and says so. That is
145+
the design, not a failure — the refusals go to stderr, which is the journal when
146+
the timer runs it (`journalctl --user -u cli-tools-update`).
147+
148+
Two things worth knowing. The unit **bakes today's `PATH` in**, because a user
149+
unit otherwise starts with roughly `/usr/bin:/bin` while these commands run
150+
through a `npx --yes tsx` shebang whose node is usually a version manager's shim
151+
under `$HOME` — without it the timer fires on schedule, fails to find node, and
152+
nothing looks wrong. And **user timers stop when you log out** unless lingering
153+
is enabled (`loginctl enable-linger`, which needs root); on a laptop that is
154+
fine, since `Persistent=true` makes it catch up on the next login.
155+
118156
Note that the installed command runs **whatever branch the checkout is on**
119157
these are symlinks into a working tree, not a copied build. A checkout parked on
120158
an old branch silently runs old code, so `cli-tools where` and a `git branch

0 commit comments

Comments
 (0)