Compare commits
2
Commits
c37575f662
...
407eaecc23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407eaecc23 | ||
|
|
ac1f17b9ed |
@@ -3,7 +3,8 @@
|
||||
Modular production dashboard web app for Waters Church, developed in this repo.
|
||||
The app lives at the repo root and is built: zero-dependency Node server
|
||||
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
|
||||
`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in
|
||||
`/admin`, modules in `modules/` (the repo's copies — a packaged install ships
|
||||
none and installs them from the admin page's catalog), and a native macOS menu-bar launcher in
|
||||
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||
the server and its own Node runtime, supervises them, and owns the macOS
|
||||
permissions modules declare; see its README). Runtime state (module
|
||||
|
||||
@@ -34,7 +34,9 @@ and a clear degraded state, then recover on their own.
|
||||
|
||||
Open the disk image and drag **ProdDash** to Applications. That's the whole
|
||||
installation: the app carries the dashboard and its own Node runtime, so
|
||||
nothing else has to be installed and nothing can be missing. It lives in the
|
||||
nothing else has to be installed. It carries no modules — open **Admin →
|
||||
Available modules**, tick the ones this machine needs and press **Install
|
||||
selected**; each updates on its own from then on. It lives in the
|
||||
menu bar, starts and stops the server, can open at login, and holds the
|
||||
permissions the modules need — the microphone the LTC listener reads, and
|
||||
local network access for ProdCom and ProPresenter. macOS grants those to the
|
||||
@@ -179,13 +181,15 @@ below. Nothing is installed without someone clicking.
|
||||
- **Available modules** lists modules in the repo that aren't installed
|
||||
here. **Install** downloads the module into the data directory's `modules/`
|
||||
and starts it right away — nothing is written into the app folder, and no
|
||||
restart is needed. Modules that ship with your copy of ProdDash are
|
||||
"bundled"; ones you installed are "installed"; if both exist, the newer
|
||||
version runs.
|
||||
restart is needed. Tick several and press **Install selected** to fetch
|
||||
them in one go. A packaged ProdDash (the app, the Windows bundle) ships
|
||||
with no modules at all: this list is how every one of them arrives, and a
|
||||
ProdDash update never brings one along. A git checkout also runs the
|
||||
modules in its own tree ("bundled"); if both exist, the newer version runs.
|
||||
- **Uninstall** on a module card removes an installed module's files. For a
|
||||
bundled module (its folder belongs to the app) it hides the module from
|
||||
every dashboard instead; it reappears under Available modules and can be
|
||||
brought back without downloading. Settings are kept either way.
|
||||
checkout's bundled module (its folder belongs to the repo) it hides the
|
||||
module from every dashboard instead; it reappears under Available modules
|
||||
and can be brought back without downloading. Settings are kept either way.
|
||||
- **Module updates**: when the repo has a newer version of an installed
|
||||
module, its card shows **Update to vX**.
|
||||
- **Version requirements**: every module declares the ProdDash versions it
|
||||
|
||||
+10
-7
@@ -96,13 +96,16 @@ modules/<id>/
|
||||
**Installation is exactly this:** in the admin page, open **Available
|
||||
modules** and press **Install** — the module is downloaded from the ProdDash
|
||||
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
|
||||
immediately. Developing locally? Drop the folder into `modules/` (bundled
|
||||
with the checkout) or into the data directory's `modules/` and restart. No
|
||||
shell code changes, ever. Modules bundled with a checkout and modules
|
||||
installed from the admin page live side by side; when both have the same id
|
||||
the newer version runs. **Uninstall** in the admin page removes an installed
|
||||
module's files (a bundled one is hidden instead, since its folder belongs to
|
||||
the app); its settings are kept for a later reinstall.
|
||||
immediately. A packaged ProdDash — the macOS app, the Windows bundle — ships
|
||||
with **no modules at all**: every one of them arrives this way, and the
|
||||
shell's own update never brings one along, so what is in git is exactly what
|
||||
runs on a booth. Developing locally? A git checkout runs whatever is in its
|
||||
own `modules/` folder (the repo's copies, "bundled"), or drop a folder into
|
||||
the data directory's `modules/` and restart. No shell code changes, ever. In
|
||||
a checkout the two live side by side; when both have the same id the newer
|
||||
version runs. **Uninstall** in the admin page removes an installed module's
|
||||
files (in a checkout, the tree's copy is hidden instead, since that folder
|
||||
belongs to the repo); its settings are kept for a later reinstall.
|
||||
|
||||
### Native helpers
|
||||
|
||||
|
||||
@@ -68,9 +68,10 @@ Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit
|
||||
against nodejs.org's published checksums either way.
|
||||
|
||||
The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon
|
||||
is the app's icon. A module's native helper is compiled during the build
|
||||
(`<name>.swift` beside a module's files becomes `<name>`), so the Mac running
|
||||
ProdDash never needs a compiler.
|
||||
is the app's icon. The app carries no modules: they are installed from the
|
||||
admin page's catalog into the data directory, and a module's native helper
|
||||
ships prebuilt inside it (see the module guide), so the Mac running ProdDash
|
||||
never needs a compiler.
|
||||
|
||||
## Signing
|
||||
|
||||
|
||||
+5
-22
@@ -1,6 +1,8 @@
|
||||
#!/bin/zsh
|
||||
# Build ProdDash.app — the whole application: launcher, server and its own
|
||||
# Node runtime, with nothing to install first.
|
||||
# Node runtime, with nothing to install first. It carries no modules: those
|
||||
# come from the admin page's catalog into the data directory, and a module's
|
||||
# native helper ships prebuilt inside it (docs/MODULE-GUIDE.md).
|
||||
#
|
||||
# ./build.sh build/ProdDash.app, self-contained
|
||||
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
|
||||
@@ -127,23 +129,14 @@ if [[ $DEV -eq 0 ]]; then
|
||||
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
||||
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
|
||||
|
||||
echo " embedding ProdDash $VERSION"
|
||||
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||
mkdir -p "$PAYLOAD/config"
|
||||
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
|
||||
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/"
|
||||
cp -R "$REPO_ROOT/public" "$PAYLOAD/"
|
||||
# Only the checked-in defaults: config/ also holds this machine's own
|
||||
# modules.json and layouts when ProdDash has ever run from the checkout.
|
||||
cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/"
|
||||
find "$PAYLOAD" -name '.DS_Store' -delete
|
||||
|
||||
# A module's native helper is built here, so the Mac running ProdDash needs
|
||||
# no compiler: <name>.swift beside a module's files becomes <name>.
|
||||
for source in "$PAYLOAD"/modules/*/*.swift; do
|
||||
[[ -e "$source" ]] || continue
|
||||
helper="${source%.swift}"
|
||||
echo " compiling $(basename "$(dirname "$source")")/$(basename "$helper")"
|
||||
swiftc -O -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$helper" "$source"
|
||||
done
|
||||
fi
|
||||
|
||||
# ── signing ──────────────────────────────────────────────────────────────
|
||||
@@ -165,16 +158,6 @@ fi
|
||||
if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then
|
||||
codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node"
|
||||
fi
|
||||
if [[ $DEV -eq 0 ]]; then
|
||||
for helper in "$PAYLOAD"/modules/*/*; do
|
||||
# A compiled helper is the extensionless executable beside a module's
|
||||
# sources — test the name, not the path, which has dots all over it.
|
||||
name=$(basename "$helper")
|
||||
[[ -f "$helper" && -x "$helper" && "$name" != *.* ]] || continue
|
||||
echo " signing $name"
|
||||
codesign "${SIGN[@]}" --entitlements Resources/helper.entitlements "$helper"
|
||||
done
|
||||
fi
|
||||
codesign "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP"
|
||||
codesign --verify --deep --strict "$APP" && echo " signature verifies"
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.9.2",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -443,6 +443,7 @@ a.btn { text-decoration: none; }
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.catalog-main { flex: 1; min-width: 0; }
|
||||
.catalog-pick { margin: 5px 10px 0 0; flex: none; }
|
||||
.catalog-title { display: flex; align-items: center; gap: 8px; }
|
||||
.catalog-row .module-desc { margin: 4px 0 2px; }
|
||||
.catalog-row .module-health { margin-top: 2px; }
|
||||
|
||||
@@ -962,6 +962,13 @@ async function loadState() {
|
||||
for (const mod of state.modules || []) {
|
||||
modulesList.appendChild(buildModuleCard(mod));
|
||||
}
|
||||
if (!(state.modules || []).length) {
|
||||
// A packaged ProdDash starts like this: the catalog below is the way in.
|
||||
const none = document.createElement('div');
|
||||
none.className = 'layouts-empty';
|
||||
none.textContent = 'Nothing installed yet — pick from Available modules below.';
|
||||
modulesList.appendChild(none);
|
||||
}
|
||||
renderPin(state);
|
||||
loadTheme();
|
||||
loadLayouts();
|
||||
@@ -1163,6 +1170,7 @@ function renderAvailable(cat) {
|
||||
els.updateSlot.appendChild(btn);
|
||||
}
|
||||
const available = cat.available || [];
|
||||
installSelectedBtn.hidden = available.filter((a) => a.compatible).length < 2;
|
||||
if (!available.length) {
|
||||
const none = document.createElement('div');
|
||||
none.className = 'layouts-empty';
|
||||
@@ -1205,11 +1213,46 @@ function renderAvailable(cat) {
|
||||
btn.disabled = !item.compatible;
|
||||
btn.title = item.compatible ? '' : item.reason;
|
||||
btn.addEventListener('click', () => installFromCatalog(item, btn));
|
||||
if (item.compatible) {
|
||||
const pick = document.createElement('input');
|
||||
pick.type = 'checkbox';
|
||||
pick.className = 'catalog-pick';
|
||||
pick.dataset.id = item.id;
|
||||
pick.dataset.name = item.name;
|
||||
pick.title = 'Tick several, then Install selected';
|
||||
row.append(pick);
|
||||
}
|
||||
row.append(main, btn);
|
||||
availableList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
const installSelectedBtn = document.getElementById('install-selected');
|
||||
|
||||
/** The ticked modules, in one request; the answer says which ones landed. */
|
||||
async function installSelected() {
|
||||
const picks = [...availableList.querySelectorAll('.catalog-pick:checked')];
|
||||
if (!picks.length) return toast('Tick the modules to install first', true);
|
||||
installSelectedBtn.disabled = true;
|
||||
installSelectedBtn.textContent = `Installing ${picks.length}…`;
|
||||
try {
|
||||
const r = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: picks.map((p) => p.dataset.id) }) });
|
||||
const done = (r.results || []).filter((x) => x.ok).length;
|
||||
const failed = (r.results || []).filter((x) => !x.ok);
|
||||
toast(failed.length
|
||||
? `${done} installed; ${failed.map((f) => `${f.id}: ${f.error}`).join(' · ')}`
|
||||
: `${done} module${done === 1 ? '' : 's'} installed`, failed.length > 0);
|
||||
await loadState();
|
||||
} catch (e) {
|
||||
if (e.authRequired) showLogin();
|
||||
else toast(e.message, true);
|
||||
} finally {
|
||||
installSelectedBtn.disabled = false;
|
||||
installSelectedBtn.textContent = 'Install selected';
|
||||
}
|
||||
}
|
||||
installSelectedBtn.addEventListener('click', installSelected);
|
||||
|
||||
async function installFromCatalog(item, btn) {
|
||||
btn.disabled = true;
|
||||
const label = btn.textContent;
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
<section id="available-section" hidden>
|
||||
<h1 class="admin-h1">Available modules</h1>
|
||||
<div class="catalog-toolbar">
|
||||
<button class="btn small primary" id="install-selected" hidden>Install selected</button>
|
||||
<button class="btn small" id="catalog-refresh">Check again</button>
|
||||
<span class="admin-status" id="catalog-status"></span>
|
||||
</div>
|
||||
|
||||
@@ -510,6 +510,7 @@ function reconcileOfferedTiles() {
|
||||
}
|
||||
if (changed) rebuildDividers();
|
||||
scheduleOfferedRecheck();
|
||||
refreshEmptyState(); // the registry may have gone from nothing to something
|
||||
}
|
||||
|
||||
/* A module's entry list can change without anyone saving admin config: PCO
|
||||
@@ -676,6 +677,15 @@ function buildTile(tile) {
|
||||
|
||||
function refreshEmptyState() {
|
||||
emptyEl.hidden = tiles.length > 0;
|
||||
if (emptyEl.hidden) return;
|
||||
// Two different empties: a bare board, and a ProdDash with nothing to put
|
||||
// on one — a packaged install starts without modules until Admin adds some.
|
||||
const noModules = registry.size === 0;
|
||||
if (noModules === emptyEl.classList.contains('no-modules')) return;
|
||||
emptyEl.classList.toggle('no-modules', noModules);
|
||||
emptyEl.innerHTML = noModules
|
||||
? 'No modules yet — <a href="/admin">open Admin</a> and install some under Available modules.'
|
||||
: 'No tiles yet — use “+ Add tile” to build this dashboard.<br>Every browser keeps its own layout.';
|
||||
}
|
||||
|
||||
/* ── drag to move ───────────────────────────────────────────────────── */
|
||||
|
||||
@@ -277,6 +277,52 @@ function isUninstalled(id) {
|
||||
return moduleState(id).uninstalled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A packaged install that still carries modules in its app folder — every
|
||||
* install made before 1.10.0 does — moves them into the data directory
|
||||
* once, as installed copies, and drops the app-folder copies. Nothing
|
||||
* changes for the operator: the same modules run with the same settings
|
||||
* (config is keyed by module id), and from now on each updates on its own
|
||||
* from the catalog. A compiled helper sitting in a bundled folder travels
|
||||
* with it. A module the operator had uninstalled is simply dropped — the
|
||||
* catalog still offers it, and its settings stay in modules.json.
|
||||
*/
|
||||
function unbundleModules() {
|
||||
if (!PACKAGED || !fs.existsSync(BUNDLED_MODULES_DIR)) return;
|
||||
const installed = new Map(readManifests(INSTALLED_MODULES_DIR, 'installed').map((m) => [m.id, m]));
|
||||
let moved = 0;
|
||||
let failed = 0;
|
||||
for (const man of readManifests(BUNDLED_MODULES_DIR, 'bundled')) {
|
||||
if (isUninstalled(man.id)) continue;
|
||||
const copy = installed.get(man.id);
|
||||
if (copy && compareVersions(copy.version, man.version) >= 0) continue; // the data directory already has as good or better
|
||||
const dest = path.join(INSTALLED_MODULES_DIR, man.id);
|
||||
const staging = path.join(INSTALLED_MODULES_DIR, `.${man.id}.moving`);
|
||||
try {
|
||||
fs.mkdirSync(INSTALLED_MODULES_DIR, { recursive: true });
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
fs.cpSync(man.dir, staging, { recursive: true }); // keeps modes: a compiled helper stays executable
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
fs.renameSync(staging, dest);
|
||||
moved += 1;
|
||||
console.log(`[proddash] moved bundled module "${man.id}" v${man.version} into the data directory${copy ? ` (over the installed v${copy.version})` : ''}`);
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.error(`[proddash] could not move bundled module "${man.id}" into the data directory:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
if (failed) {
|
||||
console.warn(`[proddash] ${failed} bundled module${failed === 1 ? '' : 's'} stay in the app folder for now — tried again at the next start`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(BUNDLED_MODULES_DIR, { recursive: true, force: true });
|
||||
console.log(`[proddash] the app folder carries no modules now${moved ? ` — ${moved} moved into the data directory` : ''}; the admin page's catalog installs and updates them`);
|
||||
} catch (err) {
|
||||
console.warn("[proddash] could not remove the app folder's modules:", err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
function moduleState(id) {
|
||||
const s = modulesConfig[id];
|
||||
return s && typeof s === 'object' ? s : {};
|
||||
@@ -1235,9 +1281,18 @@ const DATA_DIR_INSIDE_ROOT = (() => {
|
||||
return rel.split(path.sep).join('/') + '/';
|
||||
})();
|
||||
|
||||
/* A packaged install — the macOS app, the Windows bundle: anything that is
|
||||
not a git checkout, the same test the updater uses to choose the archive
|
||||
over git pull — carries no modules of its own. Every module comes from
|
||||
the catalog into the data directory, and the shell's own update never
|
||||
brings one along. A checkout keeps running the modules in its tree, which
|
||||
is what development wants. */
|
||||
const PACKAGED = !fs.existsSync(path.join(ROOT, '.git'));
|
||||
|
||||
function isKeptOnUpdate(rel) {
|
||||
if (rel.startsWith('.git/')) return true;
|
||||
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
|
||||
if (PACKAGED && (rel === 'modules' || rel.startsWith('modules/'))) return true;
|
||||
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
|
||||
}
|
||||
|
||||
@@ -1580,6 +1635,29 @@ async function handleAdminApi(req, res, urlPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Several at once — a fresh install picking its set. One result per id;
|
||||
// a failure stops nothing else.
|
||||
if (urlPath === '/api/admin/modules/install' && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
let body = {};
|
||||
try {
|
||||
body = JSON.parse((await readBody(req)) || '{}');
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||
}
|
||||
const ids = Array.isArray(body.ids) ? [...new Set(body.ids.map((id) => String(id)))] : [];
|
||||
if (!ids.length) return sendJson(res, 400, { error: 'Nothing to install.' });
|
||||
const results = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
results.push({ ok: true, ...(await installModule(id)) });
|
||||
} catch (err) {
|
||||
results.push({ ok: false, id, error: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
return sendJson(res, 200, { ok: results.every((r) => r.ok), results });
|
||||
}
|
||||
|
||||
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
|
||||
if (install && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
@@ -2074,6 +2152,7 @@ server.requestTimeout = 0;
|
||||
|
||||
/* ── boot ───────────────────────────────────────────────────────────── */
|
||||
|
||||
unbundleModules();
|
||||
discoverModules();
|
||||
mountAllModules();
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
'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);
|
||||
})();
|
||||
Reference in New Issue
Block a user