Files

142 lines
5.8 KiB
JavaScript

'use strict';
// Updates this Haven install in place. Started by Update Now in Settings, Admin
// (src/routes/maintenance.js), which then exits so the files are free to replace.
//
// node scripts/update.js git|release [--wait-pid N] [--port N] [--no-restart] [--early]
//
// git git pull, for installs cloned from GitHub
// release download the latest release from GitHub and copy it over this folder,
// for installs made by Install Haven.bat, install.sh or a zip
//
// Then npm install, then Haven is started again (in its own window on Windows),
// unless --no-restart says a service manager (systemd, pm2) will do that.
// The data folder (database, uploads, .env) lives elsewhere and is never touched.
// Output goes to update.log in the data folder.
const fs = require('fs');
const net = require('net');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const IS_WIN = process.platform === 'win32';
const releaseChannel = require('../src/releaseChannel');
const args = process.argv.slice(2);
const mode = args[0];
const opt = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : null; };
const waitPid = parseInt(opt('--wait-pid'), 10) || 0;
const port = parseInt(opt('--port'), 10) || 0;
const restart = !args.includes('--no-restart');
// Early access (Settings > Admin) also takes pre-releases (src/releaseChannel.js).
const early = args.includes('--early');
const log = (msg) => console.log(`[update ${new Date().toISOString()}] ${msg}`);
// Release archives hold the repo under one top folder. These paths in this
// install belong to it and not to the release, so they are never overwritten.
const KEEP = ['node_modules/', '.git/'];
async function downloadRelease() {
const headers = { 'User-Agent': 'haven-updater', Accept: 'application/vnd.github+json' };
const { tag_name: tag, zipball_url: url } = await releaseChannel.latestRelease({ early, headers });
if (!url) throw new Error('the latest release has no source archive');
log(`Downloading ${tag}`);
const zip = await fetch(url, { headers: { 'User-Agent': 'haven-updater' } });
if (!zip.ok) throw new Error(`download failed with ${zip.status}`);
return { tag, data: Buffer.from(await zip.arrayBuffer()) };
}
// Copies every file of the archive over ROOT. Files the release no longer has
// are left in place; nothing loads a file that the new code stopped importing.
function unpackRelease(data) {
// Loaded from this install's node_modules before npm install can change it.
const AdmZip = require(path.join(ROOT, 'node_modules', 'adm-zip'));
let written = 0;
for (const entry of new AdmZip(data).getEntries()) {
if (entry.isDirectory) continue;
const rel = entry.entryName.replace(/\\/g, '/').split('/').slice(1).join('/');
if (!rel || KEEP.some(k => rel.startsWith(k))) continue;
const dest = path.resolve(ROOT, rel);
if (!dest.startsWith(ROOT + path.sep)) throw new Error(`archive path escapes the install folder: ${rel}`);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, entry.getData());
written++;
}
log(`Copied ${written} files into ${ROOT}`);
}
// Only ever called with the fixed commands below, never with outside input.
function run(command) {
log(`> ${command}`);
// shell: npm is a .cmd file on Windows, which Node only starts through a shell.
const r = spawnSync(command, { cwd: ROOT, stdio: 'inherit', shell: true });
if (r.status !== 0) throw new Error(`${command} exited with ${r.status}`);
}
async function waitForExit(pid) {
for (let i = 0; i < 120; i++) {
try { process.kill(pid, 0); } catch (err) { if (err.code === 'ESRCH') return; }
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`Haven (pid ${pid}) did not exit within a minute`);
}
function portInUse(p) {
return new Promise(resolve => {
const sock = net.connect({ port: p, host: '127.0.0.1' });
sock.once('connect', () => { sock.destroy(); resolve(true); });
sock.once('error', () => resolve(false));
});
}
async function startHaven() {
if (port && await portInUse(port)) {
log(`Port ${port} is already in use, so something else restarted Haven. Not starting a second copy.`);
return;
}
if (IS_WIN) {
// A new console window, like Start Haven.bat gives. /s plus verbatim
// arguments keeps a folder name with spaces or "&" in one piece.
const cmd = `"start "Haven" /D "${ROOT}" "${process.execPath}" server.js"`;
spawn('cmd.exe', ['/d', '/s', '/c', cmd], { windowsVerbatimArguments: true, windowsHide: true, detached: true, stdio: 'ignore' }).unref();
} else {
// No terminal to open here, so the server writes to the same log.
spawn(process.execPath, ['server.js'], { cwd: ROOT, detached: true, stdio: ['ignore', 'inherit', 'inherit'] }).unref();
}
log('Started Haven');
}
async function main() {
if (mode !== 'git' && mode !== 'release') throw new Error(`unknown update mode "${mode}"`);
log(`Updating ${ROOT} (${mode})`);
// Download while Haven is still up, so a failed download costs no downtime.
let release = null;
let ok = true;
if (mode === 'release') {
try { release = await downloadRelease(); } catch (err) { log(`Update failed: ${err.message}`); ok = false; }
}
if (waitPid) await waitForExit(waitPid);
if (ok) {
try {
if (mode === 'git') run('git pull --ff-only');
else unpackRelease(release.data);
run('npm install --no-audit --no-fund');
log(release ? `Updated to ${release.tag}` : 'Updated');
} catch (err) {
log(`Update failed: ${err.message}`);
ok = false;
}
}
// Haven comes back either way: updated, or as it was if the update failed.
if (restart) await startHaven();
if (!ok) process.exitCode = 1;
}
main().catch(err => {
log(`Update failed: ${err.message}`);
process.exitCode = 1;
});