an Update button, so a server is not a terminal away from current

Maintenance had "rebuild the frontend" and "restart the server" — one compiles what is on disk, the
other reloads it, and neither CHANGES what is on disk. Doing that meant ssh, on every server, every
time.

Refuses a dirty tree and a non-fast-forward, the same two refusals as pullPlugin and for the same
reason: this checkout is somebody's working copy on at least one machine, and a button that discards
uncommitted work is worse than no button. Fetches `origin HEAD` rather than a branch name, because a
checkout made before the default branch was renamed is on one the remote no longer has — the trap
plugins/update.ts already hit, and it bites hardest on the installs most in need of updating.

It deliberately does not chain into anything. A pull touching bun.lock means an install, which the
frozen-lockfile rule exists to keep deliberate; one touching a schema means db:push against a live
database. So it REPORTS what the update implies — install, db:push, restart or rebuild — from the file
list, and leaves the decision with whoever pressed it. The commits pulled are shown, because "it
worked" is less useful than knowing what arrived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 04:22:30 +00:00
co-authored by Claude Opus 5
parent 4e2db3dc2f
commit 03abdeb246
2 changed files with 155 additions and 3 deletions
@@ -40,6 +40,86 @@ serverSettingsRouter.post('/rebuild-frontend', async (ctx) => {
return ctx.json({ ok: true, ms: result.ms, outputs: result.outputs });
});
/**
* Pull the platform's own repository.
*
* The button that was missing between the two below: `/rebuild-frontend` compiles what is on disk and
* `/restart` reloads it, but neither CHANGES what is on disk. Doing that meant a terminal, on every
* server, every time.
*
* ── What it refuses, and why ──
*
* A dirty tree, and a non-fast-forward. Same two refusals as `pullPlugin`, for the same reason: this
* checkout is somebody's working copy on at least one machine, and a button that discards uncommitted
* work is worse than a button that does not exist. `--ff-only` so a divergence is reported rather than
* merged by a machine.
*
* ── What it does NOT do ──
*
* It does not rebuild, restart, install dependencies or push the schema. Those are separate buttons and
* separate decisions — `bun install` after a lockfile change is a supply-chain moment the frozen-lockfile
* rule exists to make deliberate, and `db:push` alters a live database. So this REPORTS when the pull
* touched `package.json`, `bun.lock` or a schema file, and leaves the choice with whoever pressed it.
*/
serverSettingsRouter.post('/pull', async (ctx) => {
const git = async (args: string[]) => {
const proc = Bun.spawn(['git', ...args], { cwd: PLATFORM_DIR, stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return { code: await proc.exited, out: out.trim(), err: err.trim() };
};
const dirty = await git(['status', '--porcelain']);
if (dirty.out) {
const files = dirty.out.split('\n').length;
return ctx.json(
{
ok: false,
error: `${files} uncommitted change(s) — commit or discard them first, refusing to overwrite local work`,
},
400,
);
}
const before = (await git(['rev-parse', 'HEAD'])).out;
// `origin HEAD`, not a branch name: a checkout made before the default branch was renamed is on one
// the remote no longer has, and `git pull origin <local-name>` fails on exactly the installs that most
// need updating. Learned in `plugins/update.ts`; the same trap applies here.
const fetched = await git(['fetch', 'origin', 'HEAD']);
if (fetched.code !== 0) return ctx.json({ ok: false, error: fetched.err || 'git fetch failed' }, 500);
const merged = await git(['merge', '--ff-only', 'FETCH_HEAD']);
if (merged.code !== 0) {
return ctx.json(
{ ok: false, error: merged.err || merged.out || 'not a fast-forward — this checkout has diverged' },
409,
);
}
const after = (await git(['rev-parse', 'HEAD'])).out;
if (before === after) return ctx.json({ ok: true, updated: false, commits: [], needs: [] });
const log = await git(['log', '--oneline', '--no-decorate', `${before}..${after}`]);
const changed = (await git(['diff', '--name-only', `${before}..${after}`])).out.split('\n');
// What the pull implies but must not do on its own.
const needs: string[] = [];
if (changed.some((f) => f === 'package.json' || f === 'bun.lock')) needs.push('bun install');
if (changed.some((f) => f.includes('/schema.ts') || f.endsWith('drizzle-schema.ts'))) needs.push('bun db:push');
if (changed.some((f) => f.startsWith('src/servers/') || f.startsWith('src/databases/'))) needs.push('restart');
else if (changed.some((f) => f.startsWith('src/'))) needs.push('rebuild-frontend');
return ctx.json({
ok: true,
updated: true,
from: before.slice(0, 7),
to: after.slice(0, 7),
commits: log.out ? log.out.split('\n') : [],
files: changed.length,
needs,
});
});
/**
* Restart the platform process. The only way to load changed SERVER code.
*