Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407eaecc23 | ||
|
|
ac1f17b9ed | ||
|
|
c37575f662 |
@@ -3,7 +3,8 @@
|
|||||||
Modular production dashboard web app for Waters Church, developed in this repo.
|
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
|
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
|
(`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
|
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||||
the server and its own Node runtime, supervises them, and owns the macOS
|
the server and its own Node runtime, supervises them, and owns the macOS
|
||||||
permissions modules declare; see its README). Runtime state (module
|
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
|
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
|
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
|
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
|
permissions the modules need — the microphone the LTC listener reads, and
|
||||||
local network access for ProdCom and ProPresenter. macOS grants those to the
|
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
|
- **Available modules** lists modules in the repo that aren't installed
|
||||||
here. **Install** downloads the module into the data directory's `modules/`
|
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
|
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
|
restart is needed. Tick several and press **Install selected** to fetch
|
||||||
"bundled"; ones you installed are "installed"; if both exist, the newer
|
them in one go. A packaged ProdDash (the app, the Windows bundle) ships
|
||||||
version runs.
|
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
|
- **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
|
checkout's bundled module (its folder belongs to the repo) it hides the
|
||||||
every dashboard instead; it reappears under Available modules and can be
|
module from every dashboard instead; it reappears under Available modules
|
||||||
brought back without downloading. Settings are kept either way.
|
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 updates**: when the repo has a newer version of an installed
|
||||||
module, its card shows **Update to vX**.
|
module, its card shows **Update to vX**.
|
||||||
- **Version requirements**: every module declares the ProdDash versions it
|
- **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
|
**Installation is exactly this:** in the admin page, open **Available
|
||||||
modules** and press **Install** — the module is downloaded from the ProdDash
|
modules** and press **Install** — the module is downloaded from the ProdDash
|
||||||
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
|
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
|
||||||
immediately. Developing locally? Drop the folder into `modules/` (bundled
|
immediately. A packaged ProdDash — the macOS app, the Windows bundle — ships
|
||||||
with the checkout) or into the data directory's `modules/` and restart. No
|
with **no modules at all**: every one of them arrives this way, and the
|
||||||
shell code changes, ever. Modules bundled with a checkout and modules
|
shell's own update never brings one along, so what is in git is exactly what
|
||||||
installed from the admin page live side by side; when both have the same id
|
runs on a booth. Developing locally? A git checkout runs whatever is in its
|
||||||
the newer version runs. **Uninstall** in the admin page removes an installed
|
own `modules/` folder (the repo's copies, "bundled"), or drop a folder into
|
||||||
module's files (a bundled one is hidden instead, since its folder belongs to
|
the data directory's `modules/` and restart. No shell code changes, ever. In
|
||||||
the app); its settings are kept for a later reinstall.
|
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
|
### 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.
|
against nodejs.org's published checksums either way.
|
||||||
|
|
||||||
The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon
|
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
|
is the app's icon. The app carries no modules: they are installed from the
|
||||||
(`<name>.swift` beside a module's files becomes `<name>`), so the Mac running
|
admin page's catalog into the data directory, and a module's native helper
|
||||||
ProdDash never needs a compiler.
|
ships prebuilt inside it (see the module guide), so the Mac running ProdDash
|
||||||
|
never needs a compiler.
|
||||||
|
|
||||||
## Signing
|
## Signing
|
||||||
|
|
||||||
|
|||||||
+5
-22
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/zsh
|
#!/bin/zsh
|
||||||
# Build ProdDash.app — the whole application: launcher, server and its own
|
# 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 build/ProdDash.app, self-contained
|
||||||
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
|
# ./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 \
|
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
||||||
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
|
"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"
|
mkdir -p "$PAYLOAD/config"
|
||||||
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
|
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
|
# Only the checked-in defaults: config/ also holds this machine's own
|
||||||
# modules.json and layouts when ProdDash has ever run from the checkout.
|
# modules.json and layouts when ProdDash has ever run from the checkout.
|
||||||
cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/"
|
cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/"
|
||||||
find "$PAYLOAD" -name '.DS_Store' -delete
|
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
|
fi
|
||||||
|
|
||||||
# ── signing ──────────────────────────────────────────────────────────────
|
# ── signing ──────────────────────────────────────────────────────────────
|
||||||
@@ -165,16 +158,6 @@ fi
|
|||||||
if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then
|
if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then
|
||||||
codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node"
|
codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node"
|
||||||
fi
|
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 "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP"
|
||||||
codesign --verify --deep --strict "$APP" && echo " signature verifies"
|
codesign --verify --deep --strict "$APP" && echo " signature verifies"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.9.2",
|
"version": "1.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ a.btn { text-decoration: none; }
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.catalog-main { flex: 1; min-width: 0; }
|
.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-title { display: flex; align-items: center; gap: 8px; }
|
||||||
.catalog-row .module-desc { margin: 4px 0 2px; }
|
.catalog-row .module-desc { margin: 4px 0 2px; }
|
||||||
.catalog-row .module-health { margin-top: 2px; }
|
.catalog-row .module-health { margin-top: 2px; }
|
||||||
|
|||||||
@@ -962,6 +962,13 @@ async function loadState() {
|
|||||||
for (const mod of state.modules || []) {
|
for (const mod of state.modules || []) {
|
||||||
modulesList.appendChild(buildModuleCard(mod));
|
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);
|
renderPin(state);
|
||||||
loadTheme();
|
loadTheme();
|
||||||
loadLayouts();
|
loadLayouts();
|
||||||
@@ -1163,6 +1170,7 @@ function renderAvailable(cat) {
|
|||||||
els.updateSlot.appendChild(btn);
|
els.updateSlot.appendChild(btn);
|
||||||
}
|
}
|
||||||
const available = cat.available || [];
|
const available = cat.available || [];
|
||||||
|
installSelectedBtn.hidden = available.filter((a) => a.compatible).length < 2;
|
||||||
if (!available.length) {
|
if (!available.length) {
|
||||||
const none = document.createElement('div');
|
const none = document.createElement('div');
|
||||||
none.className = 'layouts-empty';
|
none.className = 'layouts-empty';
|
||||||
@@ -1205,11 +1213,46 @@ function renderAvailable(cat) {
|
|||||||
btn.disabled = !item.compatible;
|
btn.disabled = !item.compatible;
|
||||||
btn.title = item.compatible ? '' : item.reason;
|
btn.title = item.compatible ? '' : item.reason;
|
||||||
btn.addEventListener('click', () => installFromCatalog(item, btn));
|
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);
|
row.append(main, btn);
|
||||||
availableList.appendChild(row);
|
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) {
|
async function installFromCatalog(item, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const label = btn.textContent;
|
const label = btn.textContent;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
<section id="available-section" hidden>
|
<section id="available-section" hidden>
|
||||||
<h1 class="admin-h1">Available modules</h1>
|
<h1 class="admin-h1">Available modules</h1>
|
||||||
<div class="catalog-toolbar">
|
<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>
|
<button class="btn small" id="catalog-refresh">Check again</button>
|
||||||
<span class="admin-status" id="catalog-status"></span>
|
<span class="admin-status" id="catalog-status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ function reconcileOfferedTiles() {
|
|||||||
}
|
}
|
||||||
if (changed) rebuildDividers();
|
if (changed) rebuildDividers();
|
||||||
scheduleOfferedRecheck();
|
scheduleOfferedRecheck();
|
||||||
|
refreshEmptyState(); // the registry may have gone from nothing to something
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A module's entry list can change without anyone saving admin config: PCO
|
/* A module's entry list can change without anyone saving admin config: PCO
|
||||||
@@ -676,6 +677,15 @@ function buildTile(tile) {
|
|||||||
|
|
||||||
function refreshEmptyState() {
|
function refreshEmptyState() {
|
||||||
emptyEl.hidden = tiles.length > 0;
|
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 ───────────────────────────────────────────────────── */
|
/* ── drag to move ───────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -277,6 +277,52 @@ function isUninstalled(id) {
|
|||||||
return moduleState(id).uninstalled === true;
|
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) {
|
function moduleState(id) {
|
||||||
const s = modulesConfig[id];
|
const s = modulesConfig[id];
|
||||||
return s && typeof s === 'object' ? s : {};
|
return s && typeof s === 'object' ? s : {};
|
||||||
@@ -1235,9 +1281,18 @@ const DATA_DIR_INSIDE_ROOT = (() => {
|
|||||||
return rel.split(path.sep).join('/') + '/';
|
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) {
|
function isKeptOnUpdate(rel) {
|
||||||
if (rel.startsWith('.git/')) return true;
|
if (rel.startsWith('.git/')) return true;
|
||||||
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) 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);
|
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1580,6 +1635,29 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
return;
|
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$/);
|
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
|
||||||
if (install && req.method === 'POST') {
|
if (install && req.method === 'POST') {
|
||||||
if (refuseAdminWrite(req, res)) return;
|
if (refuseAdminWrite(req, res)) return;
|
||||||
@@ -2074,6 +2152,7 @@ server.requestTimeout = 0;
|
|||||||
|
|
||||||
/* ── boot ───────────────────────────────────────────────────────────── */
|
/* ── boot ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
unbundleModules();
|
||||||
discoverModules();
|
discoverModules();
|
||||||
mountAllModules();
|
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