diff --git a/README.md b/README.md index 8ef26a9..fec5152 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ TypeScript, installed as executables on `PATH`. | [`torrent`](#torrent) | Make a torrent out of a directory, and get it seeded | | [`codeburn`](#codeburn) | See where your AI spend goes, by task, tool, model and project | | [`shorten`](#shorten) | Mint a short link on the pit, and follow it from `/f/` | +| [`sysupdate`](#sysupdate) | Update this box: apt lists, apt packages, snaps | One thing here is not a `PATH` command and does not need Node: @@ -46,6 +47,9 @@ One thing here is not a `PATH` command and does not need Node: best single stream and says so - **[`yt-dlp`](https://github.com/yt-dlp/yt-dlp)** — `dl` only (`moshcode install yt-dlp`, `pipx install yt-dlp`, `brew install yt-dlp`) +- **`apt`, and `sudo` unless you are root** — `sysupdate` only, and the one + command here that is not portable: it updates Debian and Ubuntu boxes and + refuses anything else. `snap` is optional; without snapd that step is skipped - **[`create-torrent`](https://www.npmjs.com/package/create-torrent)** — `torrent` only (`npm i -g create-torrent`); `torrent seed` additionally needs [torlnk](https://www.npmjs.com/package/torlnk) running @@ -121,7 +125,7 @@ Check what landed, and wire up the pit aliases: cli-tools list # * runs from here, ! is shadowed by another copy cli-tools companions # the two from npm, and whether they are on PATH cli-tools companions --install # install the missing ones (--force updates all) -cli-tools aliases --install # /aff /blog /free /merge /names /prs /speak /web /whois +cli-tools aliases --install # /aff /blog /free /merge /names /prs /speak /update /web /whois cli-tools config # API keys: what is set, and where it came from cli-tools update # git pull, reinstall, relink, update companions cli-tools autoupdate --install # …or have a timer do that daily @@ -1001,6 +1005,47 @@ the only thing on stdout, so it pipes. The same thing lives inside moshcode as `/shorten`; this is the copy that pipes. +### `sysupdate` + +Bring this box up to date — package lists, packages, snaps: + +```sh +sysupdate # apt update, apt upgrade, snap refresh +sysupdate --yes # ...without stopping to ask +sysupdate --no-snap # apt only +sysupdate --dry-run # print the commands, run nothing +``` + +It runs the three steps in order and **stops at the first one that fails**, +which is the `&&` the shell one-liner had: there is no point upgrading against +package lists that failed to refresh, and a snap refresh afterwards only buries +the real error further up the scrollback. + +Each step goes through `sudo` unless you are already root — a minimal image may +have no `sudo` on it at all, and asking for it there fails for a reason that +has nothing to do with updating anything. A box with no snapd skips the snap +step and says so, rather than reporting a failure for something that was never +going to run. + +**`apt`, not `apt-get`, and that is deliberate.** They are not the same +command: `apt upgrade` installs a package that needs a new dependency, while +`apt-get upgrade` holds it back. That difference is how kernels and security +updates quietly never land on a box everybody believes is current — the same +trap [`root-ubuntu.sh`](#root-ubuntush) works around with +`apt-get --with-new-pkgs`. + +**Not `cli-tools update`.** That one moves *this checkout* to the current +commit. One word cannot usefully mean both that and "upgrade the operating +system", so the command is `sysupdate` and the pit alias is `/update` — which +is safe precisely because nothing on `PATH` answers to that name. + +When the upgrade lands a kernel or a libc, it says a reboot is required and +names the packages. That is the moment people stop thinking about it, and the +one still running is not the one now on disk. + +Debian and Ubuntu only; it refuses a machine with no `apt` rather than running +two thirds of a three-step plan on a box it was never meant for. + ### `root-ubuntu.sh` Sets up a server the way we like them, and keeps it that way. It is the odd one @@ -1187,6 +1232,7 @@ without writing anything. | `/names` | `free-names` | | `/prs` | `gh-prs` | | `/speak` | `tts` | +| `/update` | `sysupdate` | | `/web` | `ask-web` | | `/whois` | `domainjson` | diff --git a/bin/sysupdate.ts b/bin/sysupdate.ts new file mode 100755 index 0000000..4695058 --- /dev/null +++ b/bin/sysupdate.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * sysupdate — bring this Debian/Ubuntu box up to date. + * + * sysupdate # apt update, apt upgrade, snap refresh + * sysupdate --yes # ...without stopping to ask + * sysupdate --dry-run # print what it would run, run nothing + * + * The pit calls it `/update`. It is not called `update` here because + * `cli-tools update` already means "move this checkout to the current commit", + * and one word cannot usefully mean both that and "upgrade the operating + * system". + */ + +import { UsageError, parseArgs } from '../src/args.ts'; +import { isMain } from '../src/is-main.ts'; +import { SysUpdateError, formatPlan, planSteps, rebootRequired, runPlan } from '../src/sysupdate.ts'; + +const USAGE = `Usage: + sysupdate [--yes] [--no-snap] [--dry-run] + +Updates this machine: apt update, then apt upgrade, then snap refresh. +Stops at the first step that fails rather than carrying on regardless. + +Options: + -y, --yes answer apt's prompts with yes + --no-snap skip the snap refresh + -n, --dry-run print the commands, run none of them + -h, --help show this help + +Runs each step through sudo unless you are already root, and skips the snap +step entirely on a box with no snapd. Says so afterwards if the upgrade needs +a reboot to take effect. +`; + +if (isMain(import.meta.url)) { + try { + const { flags } = parseArgs(process.argv.slice(2), { + boolean: ['-h', '--help', '-y', '--yes', '--no-snap', '-n', '--dry-run'], + }); + + if (flags.has('-h') || flags.has('--help')) { + process.stdout.write(USAGE); + process.exit(0); + } + + const plan = planSteps({ + yes: flags.has('-y') || flags.has('--yes'), + snap: !flags.has('--no-snap'), + }); + + if (flags.has('-n') || flags.has('--dry-run')) { + process.stdout.write(formatPlan(plan)); + process.exit(0); + } + + const code = await runPlan(plan); + + if (code === 0) { + const pending = rebootRequired(); + process.stderr.write('\nsysupdate: up to date\n'); + if (pending !== null) { + process.stderr.write('sysupdate: a reboot is required for this to take effect\n'); + if (pending) process.stderr.write(` ${pending}\n`); + } + } + + process.exit(code); + } catch (error) { + if (error instanceof UsageError || error instanceof SysUpdateError) { + process.stderr.write(`sysupdate: ${error.message}\n`); + process.exit(1); + } + process.stderr.write(`sysupdate: ${error instanceof Error ? error.message : error}\n`); + process.exit(2); + } +} diff --git a/plugins/tools/commands/install.md b/plugins/tools/commands/install.md index 3ae6d44..6f9575c 100644 --- a/plugins/tools/commands/install.md +++ b/plugins/tools/commands/install.md @@ -48,6 +48,7 @@ The installer clones to `~/.local/share/cli-tools` (override with | `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 | | `img` | Resize, convert and inspect images, with sharp or ImageMagick | +| `sysupdate` | Update this box: apt lists, apt packages, snaps | | `tcfeed` | Find repositories worth scanning, scan them, print a shortlist | | `tts` | Read text aloud and keep the audio | | `vid` | Inspect, thumbnail, clip and shrink video, through ffmpeg | @@ -100,8 +101,10 @@ asked with `--fix`. Taking it over silently changes what a merge run does. | `/blog` | `blog-post` | | `/free` | `domainfree` | | `/merge` | `gh-prs-merge --apply` | +| `/names` | `free-names` | | `/prs` | `gh-prs` | | `/speak` | `tts` | +| `/update` | `sysupdate` | | `/web` | `ask-web` | | `/whois` | `domainjson` | @@ -115,6 +118,13 @@ yours is kept. The pit re-reads the file on every lookup, so an open pit picks them up with no restart. Arguments append rather than substitute, so `/merge --limit 5` works. +`/update` runs `sysupdate`, which updates the *machine* — apt lists, apt +packages, snaps. It is not `cli-tools update`, which moves this checkout to the +current commit. That is why the command is called `sysupdate` rather than +`update`: `update` is already a dispatcher verb, and a verb wins over the +passthrough, so a command of that name would be unreachable through +`cli-tools` and would fail the test that says so. + None of these shares a name with a command, deliberately. A shell function beats `PATH`, so an alias named after the file it wraps silently shadows it and the two drift apart. Keep them thin for the same reason `/merge` carries only diff --git a/src/registry.ts b/src/registry.ts index c5273fa..fc94324 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -45,6 +45,7 @@ const SUMMARIES: Record = { 'gh-prs-merge': 'Squash-merge the PRs that are genuinely ready', porkbun: 'Read and change DNS at Porkbun, and un-park a domain', shorten: 'Mint a short link on the pit, and follow it from /f/', + sysupdate: 'Update this box: apt lists, apt packages, snaps', tcfeed: 'Find repositories worth scanning, scan them, print a shortlist', torrent: 'Make a torrent out of a directory, and get it seeded', tts: 'Read text aloud and keep the audio', @@ -185,6 +186,11 @@ export const PIT_ALIASES: Record = { names: 'free-names', prs: 'gh-prs', speak: 'tts', + // The short word for `sysupdate`, and the reason that command is not itself + // called `update`: `cli-tools update` already means "move this checkout", + // and the same word cannot also mean "upgrade the operating system". Safe as + // an alias because nothing on PATH answers to `update`. + update: 'sysupdate', web: 'ask-web', whois: 'domainjson', }; diff --git a/src/sysupdate.ts b/src/sysupdate.ts new file mode 100644 index 0000000..68f52cb --- /dev/null +++ b/src/sysupdate.ts @@ -0,0 +1,182 @@ +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { onPath } from './registry.ts'; + +/** + * Bring a Debian or Ubuntu box up to date: package lists, packages, snaps. + * + * Deliberately not called `update`. `cli-tools update` already means "move + * this checkout to the current commit", and a second, unrelated meaning of the + * same word — one that reaches for sudo and upgrades the whole operating + * system — is the kind of collision somebody discovers by running the wrong + * one. The pit alias is `/update`, which is the short word people actually + * want, and it cannot shadow anything because nothing on PATH is called that. + * + * The plan is built separately from running it so the decisions — whether sudo + * is needed, whether there are snaps to refresh at all — can be tested without + * a machine to upgrade. + */ + +export class SysUpdateError extends Error {} + +export interface Step { + /** What this step is for, in words, so the output is readable. */ + name: string; + file: string; + args: string[]; +} + +export interface Plan { + steps: Step[]; + /** Why something is *not* in the list. Silence about a skip reads as a bug. */ + notes: string[]; +} + +export interface PlanOptions { + /** Answer apt's prompts with yes. */ + yes?: boolean; + /** Refresh snaps too, when snapd is installed. Default true. */ + snap?: boolean; + /** The caller's uid. 0 means the privileges are already in hand. */ + uid?: number; + /** Is this name on PATH? Injected so the plan can be tested anywhere. */ + has?: (name: string) => boolean; +} + +/** + * What updating this box actually involves. + * + * Throws when there is no apt, rather than running the first two steps of a + * three step plan on a machine this was never meant for. + */ +export function planSteps(options: PlanOptions = {}): Plan { + const { + yes = false, + snap = true, + uid = typeof process.getuid === 'function' ? process.getuid() : 0, + has = (name: string) => onPath(name), + } = options; + + if (!has('apt')) { + throw new SysUpdateError('no apt here — sysupdate updates Debian and Ubuntu boxes'); + } + + // Root already has the privileges, and a minimal image may not even have + // sudo on it. Asking for it there fails for a reason that has nothing to do + // with updating anything, which is the worst kind of error message. + const lift = (file: string, args: string[]): Pick => + uid === 0 ? { file, args } : { file: 'sudo', args: [file, ...args] }; + + const steps: Step[] = [ + { name: 'refresh the package lists', ...lift('apt', ['update']) }, + // `apt upgrade`, not `apt-get upgrade`. They are not the same command: + // apt installs a package that needs a new dependency, apt-get holds it + // back. That difference is how kernels and security updates quietly never + // land on a box someone believes is current — the same trap root-ubuntu.sh + // works around with `apt-get --with-new-pkgs`. + { name: 'upgrade the packages', ...lift('apt', yes ? ['upgrade', '-y'] : ['upgrade']) }, + ]; + + const notes: string[] = []; + + if (!snap) { + notes.push('snaps not refreshed (--no-snap)'); + } else if (has('snap')) { + steps.push({ name: 'refresh the snaps', ...lift('snap', ['refresh']) }); + } else { + // Plenty of boxes have no snapd at all — containers, Debian, a trimmed + // server image. That is not a failure, but it is worth saying so nobody + // waits for a step that was never going to run. + notes.push('no snapd on this box — nothing to refresh'); + } + + return { steps, notes }; +} + +/** The plan as the commands it will run, for --dry-run and for the log. */ +export function formatPlan(plan: Plan): string { + const lines = plan.steps.map((step) => ` ${step.file} ${step.args.join(' ')}`); + const notes = plan.notes.map((note) => ` (${note})`); + return [...lines, ...notes].join('\n') + '\n'; +} + +export type Spawner = (file: string, args: readonly string[]) => Promise; + +/** + * Run a child with our own stdio. + * + * Inherited rather than captured, because both halves of this need a terminal: + * sudo prompts for a password on one, and apt draws progress on the other. + * Capturing the output would hang on the password prompt with nothing on + * screen to explain why. + */ +export const inheritSpawner: Spawner = (file, args) => + new Promise((resolve, reject) => { + const child = spawn(file, [...args], { stdio: 'inherit' }); + child.on('error', (error: NodeJS.ErrnoException) => { + reject( + error.code === 'ENOENT' + ? new SysUpdateError(`command not found: ${file}`) + : error, + ); + }); + // A child killed by a signal has a null code; report it as a failure + // rather than as the success that `?? 0` would quietly produce. + child.on('close', (code, signal) => resolve(signal ? 1 : (code ?? 0))); + }); + +export interface RunPlanOptions { + spawner?: Spawner; + write?: (text: string) => void; +} + +/** + * Run the steps in order, stopping at the first failure. + * + * That is the `&&` the shell one-liner had: there is no point upgrading + * against package lists that failed to refresh, and a snap refresh after a + * broken apt run only buries the error further up the scrollback. + * + * Returns the exit status of whatever stopped it, or 0. + */ +export async function runPlan(plan: Plan, options: RunPlanOptions = {}): Promise { + const { spawner = inheritSpawner, write = (text) => process.stderr.write(text) } = options; + + for (const note of plan.notes) write(`sysupdate: ${note}\n`); + + for (const step of plan.steps) { + write(`\n==> ${step.name}\n ${step.file} ${step.args.join(' ')}\n`); + const code = await spawner(step.file, step.args); + if (code !== 0) { + write(`\nsysupdate: ${step.name} failed (exit ${code}) — stopping here\n`); + return code; + } + } + + return 0; +} + +/** + * Did the upgrade land something that only takes effect after a reboot? + * + * Worth saying at the end: a kernel or libc that has been replaced on disk is + * not the one still running, and "I updated it" is exactly when people stop + * thinking about it. + */ +export function rebootRequired(root = ''): string | null { + const flag = `${root}/var/run/reboot-required`; + if (!existsSync(flag)) return null; + + try { + const packages = readFileSync(`${root}/var/run/reboot-required.pkgs`, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const unique = [...new Set(packages)]; + return unique.length > 0 ? unique.join(' ') : ''; + } catch { + // The flag is the fact; the package list beside it is a nicety that a + // permission or a missing file must not turn into a failed command. + return ''; + } +} diff --git a/test/sysupdate.test.ts b/test/sysupdate.test.ts new file mode 100644 index 0000000..1e28b4a --- /dev/null +++ b/test/sysupdate.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; + +import { PIT_ALIASES, commands } from '../src/registry.ts'; +import { + type Plan, + type Step, + SysUpdateError, + formatPlan, + planSteps, + runPlan, +} from '../src/sysupdate.ts'; + +/** Everything on the box, so the plan is about the flags and not the machine. */ +const everything = () => true; +const nothing = () => false; +const only = + (...names: string[]) => + (name: string) => + names.includes(name); + +/** The plan as plain command lines, which is what these tests are about. */ +const lines = (plan: Plan): string[] => + plan.steps.map((step) => [step.file, ...step.args].join(' ')); + +describe('planSteps', () => { + it('is the one-liner it replaces: lists, then packages, then snaps', () => { + expect(lines(planSteps({ uid: 1000, has: everything }))).toEqual([ + 'sudo apt update', + 'sudo apt upgrade', + 'sudo snap refresh', + ]); + }); + + it('does not reach for sudo when it is already root', () => { + // A minimal image may have no sudo at all, so asking for it there fails + // for a reason that has nothing to do with updating anything. + expect(lines(planSteps({ uid: 0, has: everything }))).toEqual([ + 'apt update', + 'apt upgrade', + 'snap refresh', + ]); + }); + + it('passes -y only where it means something', () => { + // `apt update` has nothing to confirm; -y there is noise that suggests the + // flag does more than it does. + const plan = planSteps({ uid: 0, yes: true, has: everything }); + expect(lines(plan)).toEqual(['apt update', 'apt upgrade -y', 'snap refresh']); + }); + + it('skips snaps on a box with no snapd, and says why', () => { + const plan = planSteps({ uid: 0, has: only('apt') }); + expect(lines(plan)).toEqual(['apt update', 'apt upgrade']); + expect(plan.notes.join(' ')).toContain('no snapd'); + }); + + it('skips snaps when asked, and says that too', () => { + // A silent skip and a skip-because-absent look identical from outside, + // and only one of them is something the caller chose. + const plan = planSteps({ uid: 0, snap: false, has: everything }); + expect(lines(plan)).toEqual(['apt update', 'apt upgrade']); + expect(plan.notes.join(' ')).toContain('--no-snap'); + }); + + it('refuses a machine with no apt rather than half-running', () => { + // Two of three steps on a box this was never meant for is worse than an + // error: it looks like it worked. + expect(() => planSteps({ uid: 0, has: nothing })).toThrow(SysUpdateError); + expect(() => planSteps({ uid: 0, has: nothing })).toThrow(/apt/); + }); + + it('uses apt, not apt-get, and that is deliberate', () => { + // They are different commands: apt installs a package that needs a new + // dependency, apt-get holds it back. That is how security updates quietly + // never land on a box somebody believes is current. + const plan = planSteps({ uid: 0, has: everything }); + expect(plan.steps.every((step) => !step.args.includes('apt-get'))).toBe(true); + expect(lines(plan)[0]).toBe('apt update'); + }); +}); + +describe('formatPlan', () => { + it('prints what would run, including the skips', () => { + const text = formatPlan(planSteps({ uid: 1000, has: only('apt') })); + expect(text).toContain('sudo apt update'); + expect(text).toContain('sudo apt upgrade'); + expect(text).toContain('(no snapd'); + }); +}); + +describe('runPlan', () => { + /** Records what was run and answers with the codes it was given. */ + function recorder(codes: number[]) { + const ran: string[] = []; + const spawner = async (file: string, args: readonly string[]): Promise => { + ran.push([file, ...args].join(' ')); + return codes[ran.length - 1] ?? 0; + }; + return { ran, spawner }; + } + + const plan = (): Plan => planSteps({ uid: 0, has: everything }); + + it('runs every step when each one succeeds', async () => { + const { ran, spawner } = recorder([0, 0, 0]); + const code = await runPlan(plan(), { spawner, write: () => {} }); + expect(code).toBe(0); + expect(ran).toEqual(['apt update', 'apt upgrade', 'snap refresh']); + }); + + it('stops at the first failure, which is the && it replaces', async () => { + // Upgrading against package lists that failed to refresh is pointless, and + // a snap refresh afterwards only buries the real error up the scrollback. + const { ran, spawner } = recorder([1]); + const code = await runPlan(plan(), { spawner, write: () => {} }); + expect(code).toBe(1); + expect(ran).toEqual(['apt update']); + }); + + it('hands back the failing exit status rather than a generic 1', async () => { + const { spawner } = recorder([0, 100]); + expect(await runPlan(plan(), { spawner, write: () => {} })).toBe(100); + }); + + it('says which step failed', async () => { + let said = ''; + const { spawner } = recorder([0, 1]); + await runPlan(plan(), { spawner, write: (text) => (said += text) }); + expect(said).toContain('upgrade the packages'); + expect(said).toContain('failed'); + }); +}); + +describe('how it is wired into the command set', () => { + it('is a real command, so it works from anywhere on PATH', () => { + expect(commands().map((entry) => entry.name)).toContain('sysupdate'); + }); + + it('is reachable from the pit as /update', () => { + expect(PIT_ALIASES.update).toBe('sysupdate'); + }); + + it('is not itself called update, because that word is taken', () => { + // `cli-tools update` moves this checkout. One word cannot also mean + // "upgrade the operating system" — whichever you meant, you get the other. + expect(commands().map((entry) => entry.name)).not.toContain('update'); + }); + + it('keeps the rule that no alias shares a name with a command', () => { + // A shell function beats PATH, so an alias named after the file it wraps + // silently shadows it and the two drift apart. + const names = new Set(commands().map((entry) => entry.name)); + for (const alias of Object.keys(PIT_ALIASES)) { + expect(names.has(alias), `/${alias} shadows the command ${alias}`).toBe(false); + } + }); +});