The macOS app and the Windows bundle shipped every module in the repo, on
by default, and a shell update silently added whatever new module had
landed on main — Band Lineup appeared on AB that way. A module was also
only as fresh as the last shell update. Now a packaged install (no .git —
the same test the updater uses to choose the archive over git pull)
carries no modules at all: every one arrives from the admin page's catalog
into the data directory, and the shell's own update never writes modules/.
A git checkout keeps running the modules in its tree.
Existing installs move over by themselves: at the first start, the bundled
modules that are switched on are copied into the data directory as
installed copies (a compiled helper keeps its mode), an uninstalled one is
dropped, and the app folder's modules/ is removed. Settings are keyed by
module id, so nothing changes for the operator.
Around it: the dashboard's empty state says "No modules yet — open Admin"
when the registry is empty; the admin page says "Nothing installed yet",
puts a checkbox on every compatible catalog entry and an Install selected
button (POST /api/admin/modules/install {ids}) that fetches them in one go.
The Mac build stops copying modules/ and no longer compiles or signs Swift
helpers — those ship prebuilt inside a module, per the guide.
tools/unbundled.test.js drives a packaged copy of the shell through the
move, an archive update that must not bring modules back, and a multi-install.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
204 lines
9.5 KiB
JavaScript
204 lines
9.5 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* A packaged ProdDash carries no modules. This drives a packaged copy of the
|
|
* shell — server.js and its client in a folder with no .git — through the
|
|
* three things that make that true:
|
|
*
|
|
* 1. an install made before 1.10.0 still has modules in its app folder:
|
|
* at boot they move into the data directory once, executables keeping
|
|
* their mode, an uninstalled one is dropped, and the app folder's
|
|
* modules/ is gone;
|
|
* 2. a shell update from an archive that carries modules/ never writes
|
|
* them into the app folder — they show up in the catalog instead;
|
|
* 3. a fresh install lists everything the repo offers, and installing
|
|
* several at once lands them in the data directory and mounts them.
|
|
*
|
|
* node tools/unbundled.test.js
|
|
*
|
|
* Plain Node, no dependencies: a mock forge, a temp app folder, a temp data
|
|
* directory. Exits non-zero on the first failure and cleans up after itself.
|
|
*/
|
|
|
|
const assert = require('node:assert/strict');
|
|
const { spawn, execFileSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const http = require('node:http');
|
|
const net = require('node:net');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const REPO = path.join(__dirname, '..');
|
|
|
|
function freePort() {
|
|
return new Promise((resolve, reject) => {
|
|
const probe = net.createServer();
|
|
probe.listen(0, '127.0.0.1', () => {
|
|
const { port } = probe.address();
|
|
probe.close(() => resolve(port));
|
|
});
|
|
probe.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function waitFor(url, ms = 15000) {
|
|
const deadline = Date.now() + ms;
|
|
let last = '';
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const r = await fetch(url);
|
|
if (r.ok) return;
|
|
last = 'HTTP ' + r.status;
|
|
} catch (err) {
|
|
last = err.message;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
}
|
|
throw new Error('server did not come up: ' + last);
|
|
}
|
|
|
|
function writeModule(dir, id, version, extra = {}) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ id, name: id[0].toUpperCase() + id.slice(1), version, client: 'client.js', ...extra }));
|
|
fs.writeFileSync(path.join(dir, 'client.js'), 'export default function () {}\n');
|
|
}
|
|
|
|
/** The repo as a forge would archive it: one top-level folder, the shell files, and modules/. */
|
|
function buildArchive(dir) {
|
|
const top = path.join(dir, 'ProdDash-main');
|
|
fs.mkdirSync(top, { recursive: true });
|
|
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
|
|
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
|
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
|
|
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
|
|
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
|
|
const out = path.join(dir, 'main.tar.gz');
|
|
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
|
|
return fs.readFileSync(out);
|
|
}
|
|
|
|
/** A packaged install as it was before 1.10.0: the shell plus bundled modules, no .git. */
|
|
function buildPackagedApp(app) {
|
|
fs.mkdirSync(path.join(app, 'config'), { recursive: true });
|
|
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(app, 'server.js'));
|
|
fs.copyFileSync(path.join(REPO, 'package.json'), path.join(app, 'package.json'));
|
|
fs.cpSync(path.join(REPO, 'public'), path.join(app, 'public'), { recursive: true });
|
|
fs.writeFileSync(path.join(app, 'config', 'proddash.json'), JSON.stringify({ port: 24500 }));
|
|
writeModule(path.join(app, 'modules', 'demo'), 'demo', '1.0.0');
|
|
fs.mkdirSync(path.join(app, 'modules', 'demo', 'bin'));
|
|
fs.writeFileSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), '#!/bin/sh\necho helper\n');
|
|
fs.chmodSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), 0o755);
|
|
writeModule(path.join(app, 'modules', 'gone'), 'gone', '1.0.0');
|
|
}
|
|
|
|
(async () => {
|
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-unbundled-'));
|
|
const app = path.join(work, 'app');
|
|
const dataDir = path.join(work, 'data');
|
|
fs.mkdirSync(dataDir);
|
|
buildPackagedApp(app);
|
|
// The operator had uninstalled "gone" before this update.
|
|
fs.writeFileSync(path.join(dataDir, 'modules.json'), JSON.stringify({ gone: { uninstalled: true } }));
|
|
const archive = buildArchive(path.join(work, 'forge'));
|
|
|
|
const forge = http.createServer((req, res) => {
|
|
if (req.url.startsWith('/michael/ProdDash/archive/main.tar.gz')) {
|
|
res.writeHead(200, { 'Content-Type': 'application/gzip' });
|
|
return res.end(archive);
|
|
}
|
|
res.writeHead(404);
|
|
res.end();
|
|
});
|
|
const forgePort = await freePort();
|
|
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
|
|
|
|
const port = await freePort();
|
|
const base = `http://127.0.0.1:${port}`;
|
|
const env = {
|
|
...process.env,
|
|
PRODDASH_DATA_DIR: dataDir,
|
|
PORT: String(port),
|
|
PRODDASH_FORGE: `http://127.0.0.1:${forgePort}`,
|
|
PRODDASH_REPO: 'michael/ProdDash',
|
|
PRODDASH_NO_REMOTE_CHECK: '1',
|
|
PRODDASH_LAUNCHER: '1', // a supervisor is watching: an update exits 75 instead of respawning
|
|
};
|
|
let log = '';
|
|
let child = null;
|
|
const start = () => {
|
|
child = spawn(process.execPath, ['server.js'], { cwd: app, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
child.stdout.on('data', (d) => { log += d; });
|
|
child.stderr.on('data', (d) => { log += d; });
|
|
return child;
|
|
};
|
|
const exited = (proc) => new Promise((resolve) => proc.on('exit', resolve));
|
|
const api = async (p, init = {}) => {
|
|
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
|
|
return { status: r.status, body: await r.json().catch(() => ({})) };
|
|
};
|
|
const moduleIds = async () => (await api('/api/modules')).body.modules.map((m) => m.id).sort();
|
|
|
|
let failed = false;
|
|
try {
|
|
// ── 1. the move ──
|
|
start();
|
|
await waitFor(base + '/api/theme');
|
|
const moved = path.join(dataDir, 'modules', 'demo');
|
|
assert.ok(fs.existsSync(path.join(moved, 'module.json')), 'demo moved into the data directory');
|
|
const helper = path.join(moved, 'bin', 'demo-helper');
|
|
assert.ok(fs.existsSync(helper), 'its compiled helper came along');
|
|
assert.ok(fs.statSync(helper).mode & 0o111, 'and is still executable');
|
|
assert.equal(fs.existsSync(path.join(dataDir, 'modules', 'gone')), false, 'an uninstalled module is dropped, not moved');
|
|
assert.equal(fs.existsSync(path.join(app, 'modules')), false, "the app folder's modules/ is gone");
|
|
assert.deepEqual(await moduleIds(), ['demo'], 'demo runs, gone does not');
|
|
assert.match(log, /moved bundled module "demo" v1\.0\.0 into the data directory/);
|
|
assert.match(log, /the app folder carries no modules now/);
|
|
const state = (await api('/api/admin/state')).body;
|
|
assert.equal(state.modules.find((m) => m.id === 'demo')?.source, 'installed', 'demo is an installed copy now');
|
|
|
|
// The catalog sees the repo's modules/ — as offers, not as a bundle.
|
|
let cat = (await api('/api/admin/catalog?refresh=1')).body;
|
|
assert.equal(cat.shell.remote.error, '', 'the mock forge answers: ' + cat.shell.remote.error);
|
|
assert.deepEqual(cat.updates.map((u) => `${u.id} ${u.installedVersion}->${u.version}`), ['demo 1.0.0->2.0.0']);
|
|
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['gone', 'other', 'third'].filter((id) => id !== 'gone'), 'other and third are on offer');
|
|
|
|
// ── 2. a shell update carries no modules ──
|
|
const running = child;
|
|
const up = await api('/api/admin/update', { method: 'POST', body: '{}' });
|
|
assert.equal(up.status, 200, 'update: ' + JSON.stringify(up.body));
|
|
assert.equal(await exited(running), 75, 'the server asks its supervisor for a restart');
|
|
assert.equal(fs.existsSync(path.join(app, 'modules')), false, 'the archive\'s modules/ was not written into the app folder');
|
|
assert.equal(JSON.parse(fs.readFileSync(path.join(app, 'package.json'), 'utf8')).version, '9.9.9', 'but the shell itself was updated');
|
|
|
|
log = '';
|
|
start();
|
|
await waitFor(base + '/api/theme');
|
|
assert.doesNotMatch(log, /moved bundled module/, 'nothing left to move on the second start');
|
|
assert.deepEqual(await moduleIds(), ['demo'], 'still just demo, from the data directory');
|
|
cat = (await api('/api/admin/catalog')).body;
|
|
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['other', 'third'], 'the new modules are offers, not installs');
|
|
|
|
// ── 3. several at once ──
|
|
const many = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: ['other', 'third', 'nope'] }) });
|
|
assert.equal(many.status, 200);
|
|
assert.equal(many.body.ok, false, 'one of them does not exist');
|
|
assert.deepEqual(many.body.results.map((r) => `${r.id}:${r.ok}`), ['other:true', 'third:true', 'nope:false']);
|
|
assert.ok(fs.existsSync(path.join(dataDir, 'modules', 'other', 'module.json')), 'other landed in the data directory');
|
|
assert.deepEqual(await moduleIds(), ['demo', 'other', 'third'], 'and both are mounted');
|
|
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
|
|
assert.equal(empty.status, 400, 'nothing to install is said');
|
|
|
|
console.log('unbundled shell: every check passed');
|
|
} catch (err) {
|
|
failed = true;
|
|
console.error('FAIL:', err.message);
|
|
console.error('--- last of the server log ---\n' + log.slice(-2500));
|
|
} finally {
|
|
try { child?.kill('SIGTERM'); } catch { /* gone */ }
|
|
forge.close();
|
|
await new Promise((r) => setTimeout(r, 400));
|
|
fs.rmSync(work, { recursive: true, force: true });
|
|
}
|
|
process.exit(failed ? 1 : 0);
|
|
})();
|