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

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

fix(updates): in-app updates on the Android app (third topology)

The Updates page only knew git topologies (GitHub checkout / fleet bare
repo); the Android app installs from a prebuilt tarball with no git, so
it showed "no update source reachable" and could never update itself.

New ANDROID topology, detected via process.platform === 'android'
(Termux node) with a filesystem fallback:

  • "Latest" = the package.json version on the klonkt STABLE branch (the channel the phone tarballs are built from), fetched with an 8s abort.
  • The update button spawns the phone's own klonkt-update detached; the shell survives the pkill/restart the updater performs.
  • Renders version chips (v1.3.1) instead of commit hashes; the existing view handles the null git fields as-is.
  • src/routes/admin-updates.js — IS_ANDROID detection, androidLatestVersion(), android branches in GET / and POST /run
  • CHANGELOG(.nl/.de).md — user-facing entry under Unreleased (3 languages)

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

  • Property mode set to 100644
File size: 7.9 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// Phone tarballs are built from the stable branch → that's the phone's "latest".
48const ANDROID_LATEST_URL = 'https://raw.githubusercontent.com/roboburr/klonkt/stable/package.json';
49
50async function androidLatestVersion() {
51 try {
52 const ctl = new AbortController();
53 const t = setTimeout(() => ctl.abort(), 8000);
54 const r = await fetch(ANDROID_LATEST_URL, { signal: ctl.signal });
55 clearTimeout(t);
56 if (!r.ok) return null;
57 return (await r.json()).version || null;
58 } catch { return null; }
59}
60
61function appVersion() {
62 try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')).version || null; }
63 catch { return null; }
64}
65// stderr is ignored on purpose → a missing/foreign repo fails silently (returns null).
66function git(args) {
67 try {
68 const base = IS_CHECKOUT ? ['-C', APP_DIR] : ['--git-dir', GIT_DIR];
69 return execFileSync('git', [...base, ...args], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
70 } catch { return null; }
71}
72function currentSha() {
73 if (IS_CHECKOUT) return git(['rev-parse', 'HEAD']);
74 try { return fs.readFileSync(path.join(APP_DIR, '.klonkt-version'), 'utf8').trim() || null; } catch { return null; }
75}
76
77// Last 5 commits = the "recent changes" you'll get when updating.
78function recentChanges() {
79 const out = git(['log', '-5', '--format=%s%x1f%cd', '--date=short', REMOTE_REF]);
80 if (!out) return [];
81 return out.split('\n').map((l) => {
82 const i = l.indexOf('\x1f');
83 return i >= 0 ? { msg: l.slice(0, i), date: l.slice(i + 1) } : { msg: l, date: '' };
84 });
85}
86
87router.get('/', requireGod, async (req, res) => {
88 // ANDROID: version-based check against the stable branch; the update button
89 // runs the phone's klonkt-update (always present, the start script writes it).
90 if (IS_ANDROID) {
91 const cur = appVersion();
92 const latest = await androidLatestVersion();
93 return renderPage(req, res, 'pages/admin-updates', {
94 pageTitleKey: 'admin.t_updates',
95 bodyClass: 'on-admin',
96 appVersion: cur,
97 currentSha: cur ? 'v' + cur : null,
98 currentDesc: null,
99 latestSha: latest ? 'v' + latest : null,
100 latestDesc: null,
101 upToDate: !!(cur && latest && cur === latest),
102 canCheck: !!latest,
103 canSelfUpdate: true,
104 manualCommand: null,
105 behind: null,
106 changes: [],
107 success: req.query.success || null,
108 error: req.query.error || null,
109 });
110 }
111 // For a GitHub checkout, refresh the remote ref so "latest" is current. Quiet +
112 // shallow; offline just leaves the last-known ref. stderr ignored (no log noise).
113 if (IS_CHECKOUT) {
114 try { execFileSync('git', ['-C', APP_DIR, 'fetch', '--quiet', '--depth', '1', 'origin', BRANCH], { timeout: 20000, stdio: 'ignore' }); } catch { /* offline / no remote */ }
115 }
116 const cur = currentSha();
117 const latest = git(['rev-parse', REMOTE_REF]);
118 // The in-app "Update now" button only works with the detached self-update script
119 // (the fleet). A systemd install updates via `klonkt-update` (root) → show that.
120 const canSelfUpdate = (() => { try { return fs.existsSync(UPDATE_SCRIPT); } catch { return false; } })();
121 renderPage(req, res, 'pages/admin-updates', {
122 pageTitleKey: 'admin.t_updates',
123 bodyClass: 'on-admin',
124 appVersion: appVersion(),
125 currentSha: cur ? cur.slice(0, 8) : null,
126 currentDesc: cur ? git(['log', '-1', '--format=%s · %cd', '--date=short', cur]) : null,
127 latestSha: latest ? latest.slice(0, 8) : null,
128 latestDesc: latest ? git(['log', '-1', '--format=%s · %cd', '--date=short', REMOTE_REF]) : null,
129 upToDate: !!(cur && latest && cur === latest),
130 canCheck: !!latest,
131 canSelfUpdate,
132 manualCommand: (!canSelfUpdate && IS_CHECKOUT) ? 'sudo klonkt-update' : null,
133 behind: (cur && latest && cur !== latest) ? git(['rev-list', '--count', cur + '..' + REMOTE_REF]) : null,
134 changes: recentChanges(),
135 success: req.query.success || null,
136 error: req.query.error || null,
137 });
138});
139
140router.post('/run', requireGod, (req, res) => {
141 // ANDROID: run the phone's updater detached. It kills node (this process),
142 // swaps the code while keeping storage/.env, and restarts everything — the
143 // detached shell survives the pkill because it isn't a node process.
144 if (IS_ANDROID) {
145 try {
146 const child = spawn('bash', ['-c', 'klonkt-update >> "$HOME/klonkt-update.log" 2>&1'], { detached: true, stdio: 'ignore' });
147 child.unref();
148 } catch (e) {
149 return res.redirect('/admin/updates?error=' + encodeURIComponent('Kon update niet starten: ' + (e.message || e)));
150 }
151 return res.redirect('/admin/updates?success=' + encodeURIComponent('Bijwerken gestart — de site is ~1 minuut bezig (downloaden + herstarten). Ververs daarna deze pagina.'));
152 }
153 if (!fs.existsSync(UPDATE_SCRIPT)) {
154 return res.redirect('/admin/updates?error=' + encodeURIComponent('In-app updaten is hier niet beschikbaar — werk bij met `klonkt-update` op de server.'));
155 }
156 try {
157 // Detached + unlinked: survives the reload that restarts this app.
158 const child = spawn('bash', [UPDATE_SCRIPT, APP_DIR], { detached: true, stdio: 'ignore' });
159 child.unref();
160 } catch (e) {
161 return res.redirect('/admin/updates?error=' + encodeURIComponent('Kon update niet starten: ' + (e.message || e)));
162 }
163 res.redirect('/admin/updates?success=' + encodeURIComponent('Bijwerken gestart — de site herstart over ~10 seconden. Ververs daarna deze pagina.'));
164});
165
166export default router;
Note: See TracBrowser for help on using the repository browser.