bun run plugins:update, and the deploy prompt stops carrying its own server list

Updating plugins was only possible through POST /api/plugins/update-all, which is authenticated — so a
deploy had to mint an API key to update the machine it was already logged into. A credential created,
used and hopefully deleted, on every host, for a local operation.

Same work through the same functions with the HTTP layer removed, not a second implementation beside
it. Sequential like the route (each update pushes a schema, republishes assets, rewrites the ecosystem
file and rebuilds the frontend — two at once is a race over one file each), one plugin's failure does
not stop the rest, and it exits non-zero if any failed so a deploy can stop rather than report a
success it did not have. Verified: 3/3 in 18s.

Its header says the one thing that is not obvious — it runs OUTSIDE the server process, so files,
schema, assets and the SPA bundle are current when it finishes but the route table is not, until
something restarts officer. A deploy restarts anyway, which is exactly why that would otherwise be
found out rather than known.

The prompt had a table of six hosts under a line saying docs/servers.md is authoritative. Both cannot
be true, and the copy is the one people trust while it rots. Removed, with the reason written in its
place so it does not come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 17:24:47 +00:00
co-authored by Claude Opus 5
parent 07f854bf11
commit 2261d16a8a
3 changed files with 74 additions and 15 deletions
+1
View File
@@ -12,6 +12,7 @@
"gen:index": "bun run ./scripts/gen-index.ts",
"fix:env": "bun run ./scripts/fix-env.ts",
"todo:index": "bun run ./scripts/todo-index.ts",
"plugins:update": "bun run ./scripts/update-plugins.ts",
"nudge": "bun run --env-file=.env ./scripts/nudge-agent.ts",
"doctor": "bun run --env-file=.env ./scripts/doctor.ts",
"secret:set": "bun run --env-file=.env ./scripts/secret-set.ts",
+8 -15
View File
@@ -46,16 +46,11 @@ at, and guessing is how a database gets a schema it was not ready for.
## The servers
`docs/servers.md` is the list, and it is authoritative. Six platforms as of 2026-08-21:
**Read `docs/servers.md`.** Do not take a list of hosts from this file — there is deliberately none here,
because a copy rots the day a server moves and the copy is always the one somebody trusts.
| Host | Checkout |
| ---------------------------- | ----------------------------------- |
| `officer-dev` | `~/officer-dev/platform` |
| `alpha` | `~/officerdev/platform` |
| `fofos` | `~/officerdev/platform` |
| `macbookpro` | `~/projects/officer-suite/platform` |
| `edge-pertento-bridge` | `~/officerdev/platform` |
| `production-pertento-bridge` | `~/officerdev/platform` |
That document holds every machine, its tailnet name, its checkout and whether a platform runs on it. Read
it at the start of every deploy, and treat the hosts marked as having no platform as out of scope.
Four things that will catch you out, all verified rather than assumed:
@@ -79,8 +74,10 @@ assemble at the end from memory is a report that will be wrong about the host th
1. **Refuse a dirty tree.** `git status --porcelain` — if anything is uncommitted, skip this host and say
so. Never discard someone's work to ship a release.
2. `git pull --ff-only`. A non-fast-forward means that checkout has diverged: skip and report.
3. **Update the plugins.** There is no CLI for this yet — see "What is missing" below. Until there is,
the plan file says how, or the step is skipped and the report says it was skipped.
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
checkout.
4. **Run the plan's commands** for that host, in the order given. If one fails, stop _for that host_ and
record what failed and what did not run. Do not continue past a failed `bun install` or `db:push`.
5. **Restart what the plan names**, and nothing else. Never `pm2 restart ecosystem.config.cjs` — it
@@ -112,9 +109,5 @@ that are hard to reconstruct afterwards.
## What is missing, as of writing
**There is no CLI for updating plugins.** The only entry point is `POST /api/plugins/update-all`, which is
authenticated, so it needs a key. Until `bun run plugins:update` exists, the plan file must say explicitly
how — or say to skip it, and the report must record that it was skipped.
**Nothing wakes you yet.** A watcher, a cron entry or a repeating prompt has to exist and does not. Until
then this runs when a person starts it.
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bun
import { snapshotPlugins } from '../src/servers/plugins/mount';
import { updatePlugin, syncCapabilities } from '../src/servers/plugins/update';
// Update every installed plugin, from a terminal.
//
// bun run plugins:update
//
// ── Why this exists ──
//
// The only way to do this was `POST /api/plugins/update-all`, which is authenticated — so a deploy had to
// mint an API key to update the plugins on the machine it was already logged into. That is a credential
// created, used and hopefully deleted, on every host, for a local operation.
//
// This is the same work through the same functions, with the HTTP layer removed rather than a second
// implementation beside it. `prompts/deploy-commander.md` calls it.
//
// ── It runs OUTSIDE the server process ──
//
// Which matters, and is the one thing to know before reading the output. `updatePlugin` pulls, re-applies
// and rebuilds the frontend — all of which touch disk and are picked up by the running server. But
// remounting happens in the process that calls it, and this is not that process, so the routes the
// server is serving do not change until it restarts.
//
// So: the files, the schema, the assets and the SPA bundle are all current when this finishes. The route
// table is not. A deploy restarts `officer` afterwards, which is what makes the difference invisible —
// and the reason this says so rather than leaving somebody to find out.
const started = Date.now();
const { states } = await snapshotPlugins();
const installed = states.filter((st) => st.install).map((st) => st.plugin.appName);
if (installed.length === 0) {
console.log('no plugins installed — nothing to update');
process.exit(0);
}
console.log(`updating ${installed.length} plugin(s): ${installed.join(', ')}\n`);
let failed = 0;
// SEQUENTIAL, like the route: each update pushes a schema, republishes assets, rewrites the ecosystem
// file and rebuilds the frontend. Two at once is a race over one file each.
for (const appName of installed) {
process.stdout.write(`→ ${appName}\n`);
// One plugin failing must not stop the rest. A dirty checkout is the EXPECTED failure — every plugin
// directory on a developer machine is a live one — and it should not hold back the other eleven.
const result = await updatePlugin(appName, async (step) => console.log(` ${step}`));
if (!result.ok) {
failed++;
console.error(` FAILED: ${result.error}`);
}
}
// The capabilities repository rides along, the same as it does through the button: it is one question —
// "get me what has been published since I installed" — and a second gesture for it is one nobody remembers.
const capabilities = await syncCapabilities();
console.log(`\ncapabilities: ${capabilities.action}${capabilities.error ? ` — ${capabilities.error}` : ''}`);
const ok = installed.length - failed;
console.log(`${ok}/${installed.length} updated in ${Math.round((Date.now() - started) / 1000)}s`);
if (failed) console.log("a failure above is usually an uncommitted change in that plugin's checkout");
// Non-zero on failure, so a deploy script can stop rather than reporting a success it did not have.
process.exit(failed > 0 ? 1 : 0);