Skip to content

Commit ad37c80

Browse files
ralyodioclaude
andauthored
Keep a board current on its own: check for releases, install, restart (#11)
Every self-hosted board has to be updated by hand — git fetch, checkout the tag, pnpm install, restart — and a board nobody types that for drifts a version further behind with each release. Now the board does it itself. A minute after boot and every five minutes after that, the server asks GitHub for the newest release. When one is newer than the running version and the board runs from a git checkout, it fetches the tag, checks it out, runs the install, and restarts itself. It is on by default; an administrator turns it off under Board settings → Updates, and the overview page always shows what is running, what is out, a Check now button, and an Update now button when there is something to install. What it refuses, deliberately: - A checkout with local changes. That is somebody's work, and an update that discarded it would be the worst thing this code could do. - A container. The image is built from a tree with no .git, so there is nothing to fetch into; the panel shows the new version and says to redeploy. Both house boards run this way, so they are unaffected. - Anything that is not a release tag of the form vX.Y.Z. An install that fails puts the previous checkout back, and every check and apply records its outcome in settings, so a failure shows on the overview where the button was rather than only in a log. The restart spawns a fresh copy of the process with the same command line and environment and then exits, so `pnpm start` in a terminal, nohup or tmux carries on with the new code. Under a supervisor that restarts on exit, TSBB_RESTART=exit skips the spawn. TSBB_UPDATES=off stops the check as well as the apply. `tsbb update [--check]` does the same from a shell. Claude-Session: https://claude.ai/code/session_01PY5Y9gpnQ9kevUFwXJakyn Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 57d33ad commit ad37c80

11 files changed

Lines changed: 787 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ tsbb init Create .env, migrate and seed a new board
234234
tsbb serve [--port N] Run the board (it migrates at boot)
235235
tsbb worker Run the mail worker separately
236236
tsbb status What this board is and how big it is
237+
tsbb update [--check] Install the newest release, or just report it
237238
tsbb admin <email> Make somebody an administrator
238239
tsbb invite <email> Email somebody a sign-in link
239240
tsbb plugin ls|enable|disable
@@ -263,9 +264,37 @@ one with a fresh session secret and whatever you already had set.
263264
| `RESEND_API_KEY` / `SMTP_URL` | — | For the matching transport |
264265
| `TSBB_PLUGIN_DIR` | `./plugins` | Extra plugins, on top of the bundled ones |
265266
| `TSBB_WORKER` | in-process | Set to `external` to run the worker yourself |
267+
| `TSBB_UPDATES` | on | Set to `off` and the board never checks for a release |
268+
| `TSBB_RESTART` | `respawn` | `exit` under a supervisor that restarts the process itself |
269+
| `TSBB_UPDATE_REPO` | `profullstack/tsbb` | A fork that publishes its own releases |
266270

267271
The worker runs inside the server by default, so email works from one command.
268272

273+
## Updates
274+
275+
A board keeps itself current. A minute after it starts, and every five minutes
276+
after that, it asks GitHub for the newest release; when there is one it fetches
277+
the tag, runs `pnpm install`, and restarts itself. The whole thing is the three
278+
commands you would otherwise type, and a self-hosted board that nobody types
279+
them for is how every forum ends up three years behind.
280+
281+
It is on by default and an administrator can turn it off under **Board
282+
settings → Updates**. The overview page always shows what is running, what is
283+
out, and a **Check now** button; with automatic updates off it also offers
284+
**Update now**, and `tsbb update` does the same from a shell.
285+
286+
Two things it will not do. It never touches a checkout with local changes —
287+
that is somebody's work, and it stops and says so. And it never updates a
288+
container: the Docker image has no `.git` to move, so a board on Railway, Fly
289+
or a plain `docker run` sees the notice and is updated by redeploying the image,
290+
which is how those platforms expect it anyway.
291+
292+
On restart the board spawns a fresh copy of itself with the same command line
293+
and exits, so `pnpm start` in a terminal or under nohup carries on with the new
294+
code. Under systemd, pm2 or anything else that restarts a process when it
295+
exits, set `TSBB_RESTART=exit` and `Restart=always`, so the supervisor's copy
296+
is the only one.
297+
269298
## Layout
270299

271300
```

‎apps/cli/bin/tsbb.mjs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Running a board (these read the database beside you):
1313
tsbb worker Run the mail worker on its own
1414
tsbb migrate Apply pending migrations and stop
1515
tsbb status What this board is and how big it is
16+
tsbb update [--check] Install the newest release (or just say if there is one)
1617
1718
tsbb admin <email> Make somebody an administrator
1819
tsbb unadmin <email> Take it away (never from the last one)
@@ -140,6 +141,12 @@ try {
140141
await status();
141142
process.exit(0);
142143
}
144+
case 'update':
145+
case 'upgrade': {
146+
const { update } = await import('../src/commands.ts');
147+
await update({ checkOnly: argv.includes('--check') });
148+
process.exit(process.exitCode ?? 0);
149+
}
143150
case 'admin': {
144151
const { promote } = await import('../src/commands.ts');
145152
if (!argv[1]) throw new Error('Which email address? Run: tsbb admin you@example.com');

‎apps/cli/src/commands.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,33 @@ export async function setPluginEnabled(slug: string, enabled: boolean): Promise<
176176
console.log(`${slug} is now ${enabled ? 'enabled' : 'disabled'}. Restart the board to apply it.`);
177177
}
178178

179+
/**
180+
* The same update the running board performs on its own, done by hand.
181+
*
182+
* Useful when automatic updates are off, when the board is not running, or
183+
* when an operator wants to watch it happen. This changes what is on disk and
184+
* says so; the board picks the new code up when it is next started.
185+
*/
186+
export async function update(options: { checkOnly?: boolean } = {}): Promise<void> {
187+
const { applyUpdate, checkForUpdate } = await import('@tsbb/core');
188+
const check = await checkForUpdate();
189+
console.log(` running ${check.current}`);
190+
console.log(` latest ${check.latest?.version ?? 'no release found'}`);
191+
if (!check.available || !check.latest) {
192+
console.log(' up to date');
193+
return;
194+
}
195+
console.log(` release ${check.latest.url}`);
196+
if (options.checkOnly) return;
197+
if (check.kind !== 'git') {
198+
console.error(' this board was not installed from a git checkout; update it by redeploying');
199+
process.exitCode = 1;
200+
return;
201+
}
202+
await applyUpdate(check.latest.version, { log: (line) => console.log(` ${line}`) });
203+
console.log(` restart the board to run ${check.latest.version}`);
204+
}
205+
179206
export async function status(): Promise<void> {
180207
const settings = await loadSettings();
181208
const counts = await one<{ users: number; topics: number; posts: number; pending: number }>(

‎apps/server/src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { loadSettings, pruneExpired } from '@tsbb/core';
66
import { loadPlugins } from '@tsbb/plugin-host';
77
import { startWorker } from '../../worker/src/index.ts';
88
import { createApp } from './app.ts';
9+
import { registerServer, startUpdater } from './updates.ts';
910

1011
const HERE = dirname(fileURLToPath(import.meta.url));
1112
const REPO_ROOT = resolve(HERE, '../../..');
@@ -55,6 +56,12 @@ export async function boot(options: { port?: number; listen?: boolean } = {}) {
5556
const server = serve({ fetch: app.fetch, port }, (info) => {
5657
console.log(`[tsbb] listening on http://localhost:${info.port} (base URL ${baseUrl})`);
5758
});
59+
60+
// The board checks for a new release once it is up, and installs it unless
61+
// an administrator has said not to. See updates.ts for what "installs" means
62+
// for a checkout versus a container.
63+
registerServer(server as unknown as Parameters<typeof registerServer>[0]);
64+
startUpdater();
5865
return { app, registry, baseUrl, server };
5966
}
6067

‎apps/server/src/routes/admin.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
toBool,
2424
updateFeedSource,
2525
updateForum,
26+
updateState,
2627
userByUsername,
2728
} from '@tsbb/core';
2829
import { escapeHtml } from '@tsbb/markup';
@@ -41,6 +42,7 @@ import {
4142
TimeAgo,
4243
} from '@tsbb/ui';
4344
import { render, slot, type AppEnv, type Services } from '../context.ts';
45+
import { runUpdateCycle, updatesEnabled } from '../updates.ts';
4446

4547
const NAV = [
4648
{ href: '/admin', label: 'Overview' },
@@ -129,12 +131,20 @@ export function adminRoutes(services: Services) {
129131
'SELECT status, COUNT(*) AS n FROM email_queue GROUP BY status',
130132
);
131133
const registry = services.registry;
134+
const updates = await updateState();
135+
const updateNotice = c.req.query('updates');
132136

133137
return page(
134138
c,
135139
'Overview',
136140
html`
137141
<div class="page-head"><h1 class="page-title">Overview</h1></div>
142+
${updateNotice === 'restarting'
143+
? Alert('The new version is installed and the board is restarting. Give it a few seconds, then reload.', {
144+
variant: 'warning',
145+
title: 'Updating',
146+
})
147+
: ''}
138148
${Number(stats?.reports ?? 0) > 0
139149
? Alert(html`<a href="/admin/moderation">${stats?.reports} reports are waiting.</a>`, {
140150
variant: 'warning',
@@ -148,6 +158,7 @@ export function adminRoutes(services: Services) {
148158
<div class="profile-stat"><strong>${stats?.topics ?? 0}</strong><span>topics</span></div>
149159
<div class="profile-stat"><strong>${stats?.posts ?? 0}</strong><span>posts</span></div>
150160
</div>`)}`)}
161+
${updatesCard(updates)}
151162
${Card(html`${CardHeader('Mail queue', { description: 'The worker drains this every 15 seconds.' })}
152163
${CardContent(
153164
mail.length
@@ -171,6 +182,23 @@ export function adminRoutes(services: Services) {
171182
);
172183
});
173184

185+
// --- Updates ------------------------------------------------------------
186+
// "Check now" only asks; "Update now" installs and restarts. Both come back
187+
// to the overview, which reads what the cycle recorded in settings, so a
188+
// failure is shown where the button was rather than lost in a log.
189+
190+
app.post('/admin/updates/check', async (c) => {
191+
if (!updatesEnabled()) return c.redirect('/admin', 303);
192+
await runUpdateCycle({ apply: false }).catch(() => {});
193+
return c.redirect('/admin', 303);
194+
});
195+
196+
app.post('/admin/updates/apply', async (c) => {
197+
if (!updatesEnabled()) return c.redirect('/admin', 303);
198+
const outcome = await runUpdateCycle({ apply: true }).catch(() => 'failed');
199+
return c.redirect(outcome.startsWith('updated') ? '/admin?updates=restarting' : '/admin', 303);
200+
});
201+
174202
// --- Board settings -----------------------------------------------------
175203

176204
const SETTING_GROUPS: { title: string; keys: string[] }[] = [
@@ -195,6 +223,7 @@ export function adminRoutes(services: Services) {
195223
title: 'Feeds and search',
196224
keys: ['feeds.enabled', 'feeds.itemLimit', 'feeds.importEnabled', 'search.enabled', 'search.minLength'],
197225
},
226+
{ title: 'Updates', keys: ['updates.auto'] },
198227
];
199228

200229
const HELP: Record<string, string> = {
@@ -215,6 +244,8 @@ export function adminRoutes(services: Services) {
215244
'posts.editWindowMinutes': 'How long an author may edit their own post. 0 means forever.',
216245
'feeds.importEnabled':
217246
'Whether the worker fetches the RSS and Atom feeds that fill forums. Which feeds fill which forum is set on each forum under Forums.',
247+
'updates.auto':
248+
'When a new release is published the board fetches it, installs it and restarts itself, within five minutes. Off means the overview still says a new version exists, and you install it. A board running from a container is never updated this way: redeploy the image.',
218249
};
219250

220251
app.get('/admin/settings', async (c) => {
@@ -909,6 +940,60 @@ function memberPostingField(current: string) {
909940
</div>`;
910941
}
911942

943+
/**
944+
* The overview's Updates card: what is running, what is out, and the buttons.
945+
*
946+
* It reads only what the last cycle recorded, never the network, so the
947+
* overview stays fast and the same whichever process rendered it.
948+
*/
949+
function updatesCard(state: Awaited<ReturnType<typeof updateState>>) {
950+
const enabled = updatesEnabled();
951+
const actions = enabled
952+
? html`<div class="row">
953+
<form method="post" action="/admin/updates/check">
954+
${Button('Check now', { type: 'submit', variant: 'outline', size: 'sm' })}
955+
</form>
956+
${state.available && state.kind === 'git'
957+
? html`<form method="post" action="/admin/updates/apply">
958+
${Button(`Update to ${state.latestVersion}`, { type: 'submit', size: 'sm' })}
959+
</form>`
960+
: ''}
961+
</div>`
962+
: '';
963+
964+
return Card(html`${CardHeader('Updates', {
965+
description: html`Running <strong>${state.current}</strong>. Automatic updates are
966+
<a href="/admin/settings">${state.auto ? 'on' : 'off'}</a>.`,
967+
actions,
968+
})}
969+
${CardContent(html`
970+
${!enabled
971+
? html`<p class="small muted">Update checks are switched off in the environment (TSBB_UPDATES).</p>`
972+
: state.checkedAt === null
973+
? html`<p class="small muted">Not checked yet. The board checks a minute after it starts, then every five minutes.</p>`
974+
: state.available
975+
? Alert(
976+
html`<a href="${state.latestUrl ?? '#'}">Version ${state.latestVersion}</a> is available.
977+
${state.kind === 'git'
978+
? state.auto
979+
? 'It will be installed on the next check, or now with the button above.'
980+
: 'Install it with the button above, or run tsbb update on the server.'
981+
: 'This board runs from an image, so redeploy it to get the new version.'}`,
982+
{ variant: 'warning', title: 'A new version is out' },
983+
)
984+
: html`<p class="small muted">Up to date. Last checked ${TimeAgo(state.checkedAt)}.</p>`}
985+
${state.checkError
986+
? html`<div style="margin-top:.75rem">${Alert(state.checkError, { variant: 'destructive', title: 'The last check failed' })}</div>`
987+
: ''}
988+
${state.applyError
989+
? html`<div style="margin-top:.75rem">${Alert(state.applyError, { variant: 'destructive', title: 'The last update failed' })}</div>`
990+
: ''}
991+
${state.appliedVersion && state.appliedAt
992+
? html`<p class="small muted" style="margin-top:.75rem">Last updated to ${state.appliedVersion} ${TimeAgo(state.appliedAt)}.</p>`
993+
: ''}
994+
`)}`);
995+
}
996+
912997
/** A board setting rendered from the type of its default value. */
913998
function settingField(key: string, value: unknown, help?: string) {
914999
const label = key.split('.').pop()?.replace(/([A-Z])/g, ' $1').replace(/^./, (m) => m.toUpperCase()) ?? key;

‎apps/server/src/updates.ts‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { spawn } from 'node:child_process';
2+
import type { Server } from 'node:http';
3+
import { applyUpdate, checkForUpdate, installKind, loadSettings } from '@tsbb/core';
4+
5+
/*
6+
* The board keeps itself current.
7+
*
8+
* A minute after boot, and every five minutes after that, it asks GitHub for
9+
* the newest release. When there is one and the board runs from a git
10+
* checkout with `updates.auto` left on, it fetches the tag, installs, and
11+
* restarts itself. A container gets the check but not the apply — there is no
12+
* checkout inside it to move — so the admin panel shows the new version and
13+
* the operator redeploys.
14+
*
15+
* TSBB_UPDATES=off stops the check as well as the apply, for a board whose
16+
* operator would rather it never phoned anywhere.
17+
*/
18+
19+
const FIRST_CHECK_MS = 60_000;
20+
const INTERVAL_MS = 5 * 60_000;
21+
22+
type Closable = Pick<Server, 'close' | 'closeAllConnections'>;
23+
24+
let listening: Closable | null = null;
25+
let restarting = false;
26+
27+
export function updatesEnabled(): boolean {
28+
return !['off', '0', 'false'].includes(String(process.env.TSBB_UPDATES ?? '').toLowerCase());
29+
}
30+
31+
/** Remember the listener so a restart can stop taking requests before it exits. */
32+
export function registerServer(server: Closable): void {
33+
listening = server;
34+
}
35+
36+
/**
37+
* Hand the port to a fresh copy of this process and leave.
38+
*
39+
* The replacement is spawned detached with the same command line and
40+
* environment, so `pnpm start` in a terminal, nohup, tmux and the like all
41+
* carry on with the new code. Under a supervisor that restarts on exit
42+
* (systemd with Restart=always, pm2, Docker) the exit alone is enough, and
43+
* TSBB_RESTART=exit skips the spawn so the supervisor's copy is the only one.
44+
*/
45+
export function restartProcess(reason: string): void {
46+
if (restarting) return;
47+
restarting = true;
48+
console.log(`[tsbb] restarting: ${reason}`);
49+
50+
const finish = () => {
51+
if (String(process.env.TSBB_RESTART ?? 'respawn').toLowerCase() !== 'exit') {
52+
const child = spawn(process.execPath, process.argv.slice(1), {
53+
cwd: process.cwd(),
54+
env: process.env,
55+
detached: true,
56+
stdio: 'inherit',
57+
});
58+
child.unref();
59+
}
60+
process.exit(0);
61+
};
62+
63+
if (!listening) {
64+
finish();
65+
return;
66+
}
67+
// Stop accepting, let in-flight requests finish, then cut what is left.
68+
const server = listening;
69+
server.close(() => finish());
70+
setTimeout(() => {
71+
server.closeAllConnections?.();
72+
finish();
73+
}, 5_000).unref();
74+
}
75+
76+
/**
77+
* One check, and the apply if it is due. Returns what it did, for the log and
78+
* for the admin "Check now" button, which runs exactly this.
79+
*/
80+
export async function runUpdateCycle(options: { apply?: boolean } = {}): Promise<string> {
81+
const check = await checkForUpdate();
82+
if (!check.available || !check.latest) return `up to date at ${check.current}`;
83+
84+
const settings = await loadSettings();
85+
const auto = options.apply ?? settings['updates.auto'] !== false;
86+
if (!auto) return `${check.latest.version} is available; automatic updates are off`;
87+
if (installKind() !== 'git') return `${check.latest.version} is available; redeploy to get it`;
88+
89+
await applyUpdate(check.latest.version, { log: (line) => console.log(`[tsbb] update: ${line}`) });
90+
restartProcess(`updated to ${check.latest.version}`);
91+
return `updated to ${check.latest.version}, restarting`;
92+
}
93+
94+
export function startUpdater(): void {
95+
if (!updatesEnabled()) {
96+
console.log('[tsbb] updates: off (TSBB_UPDATES)');
97+
return;
98+
}
99+
const tick = async () => {
100+
try {
101+
const outcome = await runUpdateCycle();
102+
console.log(`[tsbb] updates: ${outcome}`);
103+
} catch (error) {
104+
console.warn(`[tsbb] updates: ${error instanceof Error ? error.message : String(error)}`);
105+
}
106+
};
107+
setTimeout(() => {
108+
void tick();
109+
setInterval(() => void tick(), INTERVAL_MS).unref();
110+
}, FIRST_CHECK_MS).unref();
111+
}

0 commit comments

Comments
 (0)