Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details>
<summary>From a clone, for development</summary>

Expand Down
188 changes: 181 additions & 7 deletions bin/cli-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]
Expand All @@ -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
Expand All @@ -62,17 +77,24 @@ Commands:
Keys (config set <key>):
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 {
Expand Down Expand Up @@ -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<string>, 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<string, string> = {};
Expand Down Expand Up @@ -382,7 +545,9 @@ export async function run(argv: readonly string[]): Promise<number> {
// 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) {
Expand Down Expand Up @@ -465,7 +630,16 @@ export async function run(argv: readonly string[]): Promise<number> {
}

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'] : []);
Expand Down
40 changes: 39 additions & 1 deletion plugins/tools/commands/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading