source: Klonkt/src/routes/admin-updates.js@ 72ec6a4

main
Last change on this file since 72ec6a4 was 72f936a, checked in by Robin <roboburr@…>, 8 weeks ago

Fix: update check tracks the branch a checkout is on, not always main

BRANCH was hardcoded to main (unless KLONKT_BRANCH was set), so a self-hoster
who checked out the stable branch got the update panel comparing against
origin/main: main's commits shown as 'latest' plus a bogus 'behind' count.
A git checkout now defaults to its current branch (env override still wins,
then current branch, then main). The bare fleet is unaffected (it sets
KLONKT_BRANCH); Android already used stable.

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

  • Property mode set to 100644
File size: 9.4 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 *
[422b20d]6 * Three topologies are supported, detected automatically:
[7a235bc]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.
[422b20d]14 * - ANDROID (the Klonkt phone app, Termux — node reports platform 'android'):
15 * installed from a prebuilt tarball, no git at all. "Latest" = the package
16 * version on the klonkt STABLE branch (the channel the phone tarballs are
17 * built from). The button runs the phone's own `klonkt-update` command
18 * detached, which survives the server restart it causes.
[7a235bc]19 * git stderr is ignored so a foreign/missing repo never spams "fatal: ...".
[ff08153]20 */
21
22import express from 'express';
23import { execFileSync, spawn } from 'child_process';
24import fs from 'fs';
25import path from 'path';
26import { renderPage } from '../middleware/render.js';
27import { requireGod } from '../middleware/auth.js';
28
29const router = express.Router();
30
31const HOME = process.env.HOME || '';
[7a235bc]32const APP_DIR = process.cwd();
[ff08153]33const GIT_DIR = process.env.KLONKT_GIT_DIR || path.join(HOME, 'git-repos/prutfolio.git');
34const UPDATE_SCRIPT = process.env.KLONKT_UPDATE_SCRIPT || path.join(HOME, 'bin/klonkt-self-update.sh');
35
[7a235bc]36// The app dir is a git CHECKOUT (GitHub install) when it has a .git; otherwise we
37// fall back to the BARE repo (fleet). This split keeps the version check pointed at
38// a repo that actually exists, so it never logs "fatal: not a git repository".
39const IS_CHECKOUT = (() => { try { return fs.existsSync(path.join(APP_DIR, '.git')); } catch { return false; } })();
[72f936a]40
41// A checkout must track the branch it is ACTUALLY on: a self-hoster who checked out
42// `stable` should be compared to origin/stable, not main — otherwise the update panel
43// shows main's commits as "latest" and a bogus "behind" count (confusing for stable
44// users). Env override wins (the bare fleet sets KLONKT_BRANCH); then the checkout's
45// current branch; then main as a last resort.
46const CHECKOUT_BRANCH = IS_CHECKOUT ? (() => {
47 try {
48 const b = execFileSync('git', ['-C', APP_DIR, 'rev-parse', '--abbrev-ref', 'HEAD'],
49 { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
50 return (b && b !== 'HEAD') ? b : null;
51 } catch { return null; }
52})() : null;
53const BRANCH = process.env.KLONKT_BRANCH || CHECKOUT_BRANCH || 'main';
[7a235bc]54const REMOTE_REF = IS_CHECKOUT ? `origin/${BRANCH}` : BRANCH; // what "latest" resolves to
55
[422b20d]56// The Klonkt Android app (Termux): node there reports platform 'android'; the
57// filesystem check is belt-and-braces for exotic node builds.
58const IS_ANDROID = process.platform === 'android'
59 || (() => { try { return fs.existsSync('/data/data/com.termux/files/usr/bin'); } catch { return false; } })();
[da71f9d]60// "Latest" for a phone = what the update button can actually INSTALL: the version
61// of the prebuilt bundle on the release (BUILD-INFO.txt's bundle-version). Reading
62// the stable branch instead showed a new version during the CI window in which the
63// bundle was still being built — pressing update then reinstalled the old version.
64const ANDROID_BUILDINFO_URL = 'https://github.com/roboburr/klonkt-android/releases/download/termux-latest/BUILD-INFO.txt';
65const ANDROID_STABLE_URL = 'https://raw.githubusercontent.com/roboburr/klonkt/stable/package.json';
66
67async function fetchWithTimeout(url, ms) {
68 const ctl = new AbortController();
69 const t = setTimeout(() => ctl.abort(), ms);
70 try { return await fetch(url, { signal: ctl.signal, redirect: 'follow' }); }
71 finally { clearTimeout(t); }
72}
[422b20d]73
74async function androidLatestVersion() {
75 try {
[da71f9d]76 const r = await fetchWithTimeout(ANDROID_BUILDINFO_URL, 8000);
77 if (r.ok) {
78 const m = (await r.text()).match(/^bundle-version:\s*(\S+)/m);
79 if (m) return m[1];
80 }
81 } catch { /* fall through */ }
82 // Older releases have no bundle-version line → fall back to the stable branch.
83 try {
84 const r = await fetchWithTimeout(ANDROID_STABLE_URL, 8000);
[422b20d]85 if (!r.ok) return null;
86 return (await r.json()).version || null;
87 } catch { return null; }
88}
89
[ff08153]90function appVersion() {
[7a235bc]91 try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')).version || null; }
[ff08153]92 catch { return null; }
93}
[7a235bc]94// stderr is ignored on purpose → a missing/foreign repo fails silently (returns null).
[ff08153]95function git(args) {
[7a235bc]96 try {
97 const base = IS_CHECKOUT ? ['-C', APP_DIR] : ['--git-dir', GIT_DIR];
98 return execFileSync('git', [...base, ...args], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
99 } catch { return null; }
[ff08153]100}
101function currentSha() {
[7a235bc]102 if (IS_CHECKOUT) return git(['rev-parse', 'HEAD']);
103 try { return fs.readFileSync(path.join(APP_DIR, '.klonkt-version'), 'utf8').trim() || null; } catch { return null; }
[ff08153]104}
105
[7a235bc]106// Last 5 commits = the "recent changes" you'll get when updating.
[fe6aac9]107function recentChanges() {
[7a235bc]108 const out = git(['log', '-5', '--format=%s%x1f%cd', '--date=short', REMOTE_REF]);
[fe6aac9]109 if (!out) return [];
110 return out.split('\n').map((l) => {
111 const i = l.indexOf('\x1f');
112 return i >= 0 ? { msg: l.slice(0, i), date: l.slice(i + 1) } : { msg: l, date: '' };
113 });
114}
115
[422b20d]116router.get('/', requireGod, async (req, res) => {
117 // ANDROID: version-based check against the stable branch; the update button
118 // runs the phone's klonkt-update (always present, the start script writes it).
119 if (IS_ANDROID) {
120 const cur = appVersion();
121 const latest = await androidLatestVersion();
122 return renderPage(req, res, 'pages/admin-updates', {
123 pageTitleKey: 'admin.t_updates',
124 bodyClass: 'on-admin',
125 appVersion: cur,
126 currentSha: cur ? 'v' + cur : null,
127 currentDesc: null,
128 latestSha: latest ? 'v' + latest : null,
129 latestDesc: null,
130 upToDate: !!(cur && latest && cur === latest),
131 canCheck: !!latest,
132 canSelfUpdate: true,
133 manualCommand: null,
134 behind: null,
135 changes: [],
136 success: req.query.success || null,
137 error: req.query.error || null,
138 });
139 }
[7a235bc]140 // For a GitHub checkout, refresh the remote ref so "latest" is current. Quiet +
141 // shallow; offline just leaves the last-known ref. stderr ignored (no log noise).
142 if (IS_CHECKOUT) {
143 try { execFileSync('git', ['-C', APP_DIR, 'fetch', '--quiet', '--depth', '1', 'origin', BRANCH], { timeout: 20000, stdio: 'ignore' }); } catch { /* offline / no remote */ }
144 }
[ff08153]145 const cur = currentSha();
[7a235bc]146 const latest = git(['rev-parse', REMOTE_REF]);
147 // The in-app "Update now" button only works with the detached self-update script
148 // (the fleet). A systemd install updates via `klonkt-update` (root) → show that.
149 const canSelfUpdate = (() => { try { return fs.existsSync(UPDATE_SCRIPT); } catch { return false; } })();
[ff08153]150 renderPage(req, res, 'pages/admin-updates', {
[3487567]151 pageTitleKey: 'admin.t_updates',
[ff08153]152 bodyClass: 'on-admin',
153 appVersion: appVersion(),
154 currentSha: cur ? cur.slice(0, 8) : null,
155 currentDesc: cur ? git(['log', '-1', '--format=%s · %cd', '--date=short', cur]) : null,
156 latestSha: latest ? latest.slice(0, 8) : null,
[7a235bc]157 latestDesc: latest ? git(['log', '-1', '--format=%s · %cd', '--date=short', REMOTE_REF]) : null,
[ff08153]158 upToDate: !!(cur && latest && cur === latest),
159 canCheck: !!latest,
[7a235bc]160 canSelfUpdate,
161 manualCommand: (!canSelfUpdate && IS_CHECKOUT) ? 'sudo klonkt-update' : null,
162 behind: (cur && latest && cur !== latest) ? git(['rev-list', '--count', cur + '..' + REMOTE_REF]) : null,
[fe6aac9]163 changes: recentChanges(),
[ff08153]164 success: req.query.success || null,
165 error: req.query.error || null,
166 });
167});
168
169router.post('/run', requireGod, (req, res) => {
[422b20d]170 // ANDROID: run the phone's updater detached. It kills node (this process),
171 // swaps the code while keeping storage/.env, and restarts everything — the
172 // detached shell survives the pkill because it isn't a node process.
173 if (IS_ANDROID) {
174 try {
175 const child = spawn('bash', ['-c', 'klonkt-update >> "$HOME/klonkt-update.log" 2>&1'], { detached: true, stdio: 'ignore' });
176 child.unref();
177 } catch (e) {
178 return res.redirect('/admin/updates?error=' + encodeURIComponent('Kon update niet starten: ' + (e.message || e)));
179 }
180 return res.redirect('/admin/updates?success=' + encodeURIComponent('Bijwerken gestart — de site is ~1 minuut bezig (downloaden + herstarten). Ververs daarna deze pagina.'));
181 }
[ff08153]182 if (!fs.existsSync(UPDATE_SCRIPT)) {
[7a235bc]183 return res.redirect('/admin/updates?error=' + encodeURIComponent('In-app updaten is hier niet beschikbaar — werk bij met `klonkt-update` op de server.'));
[ff08153]184 }
185 try {
[7a235bc]186 // Detached + unlinked: survives the reload that restarts this app.
187 const child = spawn('bash', [UPDATE_SCRIPT, APP_DIR], { detached: true, stdio: 'ignore' });
[ff08153]188 child.unref();
189 } catch (e) {
190 return res.redirect('/admin/updates?error=' + encodeURIComponent('Kon update niet starten: ' + (e.message || e)));
191 }
192 res.redirect('/admin/updates?success=' + encodeURIComponent('Bijwerken gestart — de site herstart over ~10 seconden. Ververs daarna deze pagina.'));
193});
194
195export default router;
Note: See TracBrowser for help on using the repository browser.