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

main
Last change on this file since da71f9d was da71f9d, checked in by roboburr <roboburr@…>, 2 months ago

fix(updates): Android "Latest" reads the installable bundle version

The Android Updates page read the stable branch's package.json, which
runs ahead of the phone bundle during the CI window in which the bundle
is still being built - the page showed "1.3.4 available" while the
update button could only install the 1.3.3 tarball, so updating
appeared stuck on the same version.

"Latest" now reads BUILD-INFO.txt's new bundle-version line from the
release (= exactly what klonkt-update installs), falling back to the
stable branch for releases that predate the line. Parse logic verified
against the workflow's BUILD-INFO shape (with and without the line).

  • src/routes/admin-updates.js - androidLatestVersion(): BUILD-INFO first, stable-branch fallback; shared fetchWithTimeout helper
  • CHANGELOG(.nl/.de).md - Unreleased entry (3 languages)

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

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