source: Klonkt/src/routes/admin-updates.js@ 0c8768a

main
Last change on this file since 0c8768a was 7a235bc, checked in by Robin Genis <roboburr@…>, 3 months ago

feat(updates): make the in-app updater GitHub/upstream-aware for self-hosters

The Updates page checked a hardcoded bare repo (KLONKT_GIT_DIR) that only exists on
Robin's VPS fleet, so a GitHub-clone install logged 'fatal: not a git repository' and
couldn't show its version. Now it auto-detects topology: a git CHECKOUT (app dir has
.git, origin=GitHub) checks origin/<branch> (fetched on view); the BARE-repo fleet is
unchanged. git stderr is ignored so a foreign/missing repo never spams the console.
External installs can't self-update in-app (klonkt-update needs root for systemd), so
the page shows the 'sudo klonkt-update' command instead of a button. Fleet keeps the
detached-script button.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 5.2 KB
RevLine 
[ff08153]1/**
2 * Admin: Updates (god-only).
[834bcc3]3 * GET /admin/updates -> current vs. latest version + status
[7a235bc]4 * POST /admin/updates/run -> fetch latest + restart (fleet only; see below)
[ff08153]5 *
[7a235bc]6 * Two topologies are supported, detected automatically:
7 * - CHECKOUT (external self-hoster): the app dir is itself a git clone with
8 * origin = GitHub. "Latest" = origin/<branch> (fetched on view). Updating is
9 * done out-of-band by `klonkt-update` (needs root for systemd), so the page
10 * shows that command instead of an in-app button.
11 * - BARE (Robin's own VPS fleet): a bare repo at KLONKT_GIT_DIR; the app dir is
12 * a `checkout -f` work-tree (no .git). "Latest" = <branch>. The detached
13 * self-update script runs in-app (no root needed) → the button works.
14 * git stderr is ignored so a foreign/missing repo never spams "fatal: ...".
[ff08153]15 */
16
17import express from 'express';
18import { execFileSync, spawn } from 'child_process';
19import fs from 'fs';
20import path from 'path';
21import { renderPage } from '../middleware/render.js';
22import { requireGod } from '../middleware/auth.js';
23
24const router = express.Router();
25
26const HOME = process.env.HOME || '';
[7a235bc]27const APP_DIR = process.cwd();
28const BRANCH = process.env.KLONKT_BRANCH || 'main';
[ff08153]29const GIT_DIR = process.env.KLONKT_GIT_DIR || path.join(HOME, 'git-repos/prutfolio.git');
30const UPDATE_SCRIPT = process.env.KLONKT_UPDATE_SCRIPT || path.join(HOME, 'bin/klonkt-self-update.sh');
31
[7a235bc]32// The app dir is a git CHECKOUT (GitHub install) when it has a .git; otherwise we
33// fall back to the BARE repo (fleet). This split keeps the version check pointed at
34// a repo that actually exists, so it never logs "fatal: not a git repository".
35const IS_CHECKOUT = (() => { try { return fs.existsSync(path.join(APP_DIR, '.git')); } catch { return false; } })();
36const REMOTE_REF = IS_CHECKOUT ? `origin/${BRANCH}` : BRANCH; // what "latest" resolves to
37
[ff08153]38function appVersion() {
[7a235bc]39 try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')).version || null; }
[ff08153]40 catch { return null; }
41}
[7a235bc]42// stderr is ignored on purpose → a missing/foreign repo fails silently (returns null).
[ff08153]43function git(args) {
[7a235bc]44 try {
45 const base = IS_CHECKOUT ? ['-C', APP_DIR] : ['--git-dir', GIT_DIR];
46 return execFileSync('git', [...base, ...args], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
47 } catch { return null; }
[ff08153]48}
49function currentSha() {
[7a235bc]50 if (IS_CHECKOUT) return git(['rev-parse', 'HEAD']);
51 try { return fs.readFileSync(path.join(APP_DIR, '.klonkt-version'), 'utf8').trim() || null; } catch { return null; }
[ff08153]52}
53
[7a235bc]54// Last 5 commits = the "recent changes" you'll get when updating.
[fe6aac9]55function recentChanges() {
[7a235bc]56 const out = git(['log', '-5', '--format=%s%x1f%cd', '--date=short', REMOTE_REF]);
[fe6aac9]57 if (!out) return [];
58 return out.split('\n').map((l) => {
59 const i = l.indexOf('\x1f');
60 return i >= 0 ? { msg: l.slice(0, i), date: l.slice(i + 1) } : { msg: l, date: '' };
61 });
62}
63
[ff08153]64router.get('/', requireGod, (req, res) => {
[7a235bc]65 // For a GitHub checkout, refresh the remote ref so "latest" is current. Quiet +
66 // shallow; offline just leaves the last-known ref. stderr ignored (no log noise).
67 if (IS_CHECKOUT) {
68 try { execFileSync('git', ['-C', APP_DIR, 'fetch', '--quiet', '--depth', '1', 'origin', BRANCH], { timeout: 20000, stdio: 'ignore' }); } catch { /* offline / no remote */ }
69 }
[ff08153]70 const cur = currentSha();
[7a235bc]71 const latest = git(['rev-parse', REMOTE_REF]);
72 // The in-app "Update now" button only works with the detached self-update script
73 // (the fleet). A systemd install updates via `klonkt-update` (root) → show that.
74 const canSelfUpdate = (() => { try { return fs.existsSync(UPDATE_SCRIPT); } catch { return false; } })();
[ff08153]75 renderPage(req, res, 'pages/admin-updates', {
76 pageTitle: 'Updates',
77 bodyClass: 'on-admin',
78 appVersion: appVersion(),
79 currentSha: cur ? cur.slice(0, 8) : null,
80 currentDesc: cur ? git(['log', '-1', '--format=%s · %cd', '--date=short', cur]) : null,
81 latestSha: latest ? latest.slice(0, 8) : null,
[7a235bc]82 latestDesc: latest ? git(['log', '-1', '--format=%s · %cd', '--date=short', REMOTE_REF]) : null,
[ff08153]83 upToDate: !!(cur && latest && cur === latest),
84 canCheck: !!latest,
[7a235bc]85 canSelfUpdate,
86 manualCommand: (!canSelfUpdate && IS_CHECKOUT) ? 'sudo klonkt-update' : null,
87 behind: (cur && latest && cur !== latest) ? git(['rev-list', '--count', cur + '..' + REMOTE_REF]) : null,
[fe6aac9]88 changes: recentChanges(),
[ff08153]89 success: req.query.success || null,
90 error: req.query.error || null,
91 });
92});
93
94router.post('/run', requireGod, (req, res) => {
95 if (!fs.existsSync(UPDATE_SCRIPT)) {
[7a235bc]96 return res.redirect('/admin/updates?error=' + encodeURIComponent('In-app updaten is hier niet beschikbaar — werk bij met `klonkt-update` op de server.'));
[ff08153]97 }
98 try {
[7a235bc]99 // Detached + unlinked: survives the reload that restarts this app.
100 const child = spawn('bash', [UPDATE_SCRIPT, APP_DIR], { detached: true, stdio: 'ignore' });
[ff08153]101 child.unref();
102 } catch (e) {
103 return res.redirect('/admin/updates?error=' + encodeURIComponent('Kon update niet starten: ' + (e.message || e)));
104 }
105 res.redirect('/admin/updates?success=' + encodeURIComponent('Bijwerken gestart — de site herstart over ~10 seconden. Ververs daarna deze pagina.'));
106});
107
108export default router;
Note: See TracBrowser for help on using the repository browser.