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
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code>` |
| [`sysupdate`](#sysupdate) | Update this box: apt lists, apt packages, snaps |

One thing here is not a `PATH` command and does not need Node:

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1187,6 +1232,7 @@ without writing anything.
| `/names` | `free-names` |
| `/prs` | `gh-prs` |
| `/speak` | `tts` |
| `/update` | `sysupdate` |
| `/web` | `ask-web` |
| `/whois` | `domainjson` |

Expand Down
77 changes: 77 additions & 0 deletions bin/sysupdate.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
10 changes: 10 additions & 0 deletions plugins/tools/commands/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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` |

Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const SUMMARIES: Record<string, string> = {
'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/<code>',
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',
Expand Down Expand Up @@ -185,6 +186,11 @@ export const PIT_ALIASES: Record<string, string> = {
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',
};
Expand Down
182 changes: 182 additions & 0 deletions src/sysupdate.ts
Original file line number Diff line number Diff line change
@@ -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<Step, 'file' | 'args'> =>
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<number>;

/**
* 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<number> {
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 '';
}
}
Loading
Loading