Install and update from the admin page: repo catalog, module version requirements, shell updates

ProdDash now has a version (package.json, 1.1.0) and modules declare the
shell versions they need ("proddash": ">=1.1.0" in the manifest). A module
whose requirement this shell doesn't meet is listed in the admin page but
never loaded, and cannot be installed from the catalog.

Modules come from two places: bundled with the checkout (modules/) and
installed from the admin page into the data directory (<data dir>/modules/).
Same id in both → the newer version runs; a downloaded copy the bundled one
has overtaken is removed. The admin page's new "Available modules" section
lists modules in the ProdDash repo that aren't installed here — one small
tar.gz of the branch is fetched (at most every few minutes, only while the
admin page is open or for the periodic update check) and read with a
dependency-free tar reader (ustar, GNU long names, pax headers; the pax
global comment carries the commit sha). Install downloads the module folder
into the data directory and mounts it immediately; a module card offers
"Update to vX" when the repo has a newer version. Uninstall removes an
installed module's files; a bundled module is hidden instead (its folder
belongs to the app) and can be restored from Available modules without a
download. Settings are kept across uninstall/reinstall.

Shell updates: the server compares the repo's package.json version and
commit sha with its own (git rev-parse when the app folder is a checkout),
shows a banner in the admin page, and "Update now" brings the app folder up
to date — git pull --ff-only for a checkout that is on the repo branch with
no local changes (file modes ignored), otherwise the archive is unpacked over
the folder (runtime state and .git untouched). The server then exits with
code 75 and starts its own replacement unless PRODDASH_LAUNCHER is set; the
replacement waits for the port to free up (EADDRINUSE retry). The page polls
until the new version answers. PRODDASH_REPO (owner/repo#branch),
PRODDASH_ARCHIVE_BASE and PRODDASH_NO_REMOTE_CHECK configure/disable it.

Docs: MODULE-GUIDE gets the rules every module follows (all settings in the
admin page, dependencies shipped inside the module, everything runs inside
the module's server entry — no helper programs, declare the ProdDash
requirement and bump the version), the proddash/version manifest fields and
the new installation story; README gets "Installing modules and updating";
CLAUDE.md summarises the rules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
Claude
2026-09-09 21:12:46 +00:00
parent a3a68ebd73
commit 2daf8897a1
13 changed files with 1015 additions and 30 deletions
+10 -2
View File
@@ -16,8 +16,16 @@ contract** (manifest, client factory + moduleApi, optional server entry,
title-bar controls). The rules that matter most: title-bar controls). The rules that matter most:
- A module is one self-contained folder in `modules/` — **never modify the - A module is one self-contained folder in `modules/` — **never modify the
shell** (`server.js`, `public/`) to add one. Installation = drop the folder, shell** (`server.js`, `public/`) to add one. Installation = the admin page's
restart, configure in `/admin`. Available modules (downloads from the repo into the data directory), or
drop the folder and restart; configure in `/admin`.
- **All settings in the admin page** (`configSchema` / `instanceSchema`) —
never a separate setup page, hand-edited file or env var. **Dependencies
ship inside the module folder** (zero deps is the norm). **Everything the
module does runs inside its server entry** — no helper scripts, companion
programs or daemons the operator starts by hand; a listener or decoder is
started in `init()` and stopped in `stop()`. Declare `"proddash": ">=x.y.z"`
and bump the module's `version` on every change.
- Browsers never call upstream services directly; the module's server routes - Browsers never call upstream services directly; the module's server routes
proxy everything (no CORS, secrets stay server-side). proxy everything (no CORS, secrets stay server-side).
- Style with the shell's CSS variables only, scoped under - Style with the shell's CSS variables only, scoped under
+45 -2
View File
@@ -65,8 +65,9 @@ the app folder from `main` and start the new version with everything still
configured. The admin page shows the exact path in use, and the server prints configured. The admin page shows the exact path in use, and the server prints
it at startup (`Settings : …`). it at startup (`Settings : …`).
The directory holds `modules.json` (module config) and `layouts/` (named The directory holds `modules.json` (module config), `layouts/` (named
layouts), both written by the app itself. It may also hold a `proddash.json` layouts) and `modules/` (modules installed from the admin page), all written
by the app itself. It may also hold a `proddash.json`
with this machine's shell settings — the same keys as the checked-in with this machine's shell settings — the same keys as the checked-in
[config/proddash.json](config/proddash.json), which supplies the defaults: [config/proddash.json](config/proddash.json), which supplies the defaults:
@@ -86,6 +87,47 @@ so and falls back to the app's `config/` folder.
directory, so nothing has to be re-entered. The old files are left in place and directory, so nothing has to be re-entered. The old files are left in place and
ignored from then on. ignored from then on.
## Installing modules and updating
Everything below happens in the **admin page**; ProdDash talks to its GitHub
repository (`michaelmcgary2008/ProdDash`, branch `main` — change with
`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable,
`owner/repo#branch`). It downloads one small archive of the branch, at most
every few minutes, only while the admin page is open or for the update check
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.
- **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.
- **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
needs (`"proddash": ">=1.1.0"` in its manifest). A module that needs a
newer shell is listed but not loaded, and cannot be installed until
ProdDash is updated.
- **ProdDash updates**: the admin page checks the repo shortly after start
and every six hours, and shows a banner when `main` has a newer version
(or new commits). **Update now** brings the app folder up to date and
restarts the server; dashboards reconnect on their own. A git checkout is
updated with `git pull --ff-only` (only when it is on the repo branch with
no local changes — otherwise the banner says why and you update with git).
A plain folder (no `.git`) has the repo archive unpacked over it; files
removed upstream are left behind but harmless. Settings and layouts are
never touched. **What changed** opens the commit comparison on GitHub.
- **Restarting**: after an update the server exits with code 75 and starts
its own replacement, which waits for the port to free up. If you supervise
ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
just exits and your launcher restarts it on exit code 75.
`PRODDASH_NO_REMOTE_CHECK=1` turns off the periodic check (the admin page
still checks when opened).
## Using the dashboard ## Using the dashboard
- **+** — add any enabled module; multiple tiles of the same module are - **+** — add any enabled module; multiple tiles of the same module are
@@ -172,6 +214,7 @@ ProdDash/
│ ├── clock/ │ ├── clock/
│ ├── prodcom-transcript/ │ ├── prodcom-transcript/
│ └── propresenter-now-next/ (carries its own copy of propresenter-core) │ └── propresenter-now-next/ (carries its own copy of propresenter-core)
├── package.json name + version (what the update check compares)
├── config/ proddash.json defaults (runtime state lives in the ├── config/ proddash.json defaults (runtime state lives in the
│ per-machine data directory — see "Configure") │ per-machine data directory — see "Configure")
├── docs/MODULE-GUIDE.md how to build and install a module ├── docs/MODULE-GUIDE.md how to build and install a module
+3 -1
View File
@@ -1,4 +1,6 @@
{ {
"port": 24500, "port": 24500,
"adminPasscode": "" "adminPasscode": "",
"repo": "michaelmcgary2008/ProdDash",
"branch": "main"
} }
+39 -4
View File
@@ -9,6 +9,31 @@ their own dashboards, move and resize them, and give each tile its own
per-tile settings. Your module renders inside the tile and talks to the per-tile settings. Your module renders inside the tile and talks to the
outside world only through the small API the shell hands it. outside world only through the small API the shell hands it.
## Rules every module follows
These are not style preferences — the admin page, the installer and the
people running the booth depend on them.
1. **All settings live in the ProdDash admin page.** Server-wide settings go
in `configSchema`, per-tile settings in `instanceSchema`. Never a separate
setup page, wizard, config file the operator edits by hand, or environment
variable.
2. **Dependencies ship inside the module folder.** Zero dependencies is the
norm (the shell has none). Anything you genuinely need is vendored into
the module — or fetched by your own `init()` on first start, with the
admin health line saying what is happening. No `npm install`, no
installer, no "run this first".
3. **Everything the module does runs inside the module.** No helper scripts,
companion programs, daemons or services the operator has to start next to
ProdDash. If your feature needs a listener, a decoder or a worker, your
server entry owns it: start it in `init()`, stop it in `stop()`, report
it in `health()`. A child process the module itself spawns and supervises
is fine; a program someone launches by hand is not.
4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the
manifest) and **bump your own `version`** whenever you change the module.
The admin page refuses to install a module whose requirement the running
shell doesn't meet, and it offers updates by comparing versions.
## The shape of a module ## The shape of a module
A module is one folder dropped into `modules/`: A module is one folder dropped into `modules/`:
@@ -21,9 +46,16 @@ modules/<id>/
└── ... assets (css, images…) served statically at /modules/<id>/ └── ... assets (css, images…) served statically at /modules/<id>/
``` ```
**Installation is exactly this:** copy the folder into `modules/`, restart **Installation is exactly this:** in the admin page, open **Available
ProdDash (`node server.js`), then configure and enable it at `/admin`. No modules** and press **Install** — the module is downloaded from the ProdDash
shell code changes, ever. 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.
## module.json ## module.json
@@ -32,6 +64,7 @@ shell code changes, ever.
"id": "my-module", "id": "my-module",
"name": "My Module", "name": "My Module",
"version": "1.0.0", "version": "1.0.0",
"proddash": ">=1.1.0",
"description": "One line shown in the Add-tile picker and the admin page", "description": "One line shown in the Add-tile picker and the admin page",
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
@@ -52,7 +85,9 @@ shell code changes, ever.
| --- | --- | --- | | --- | --- | --- |
| `id` | yes | Must equal the folder name. Lowercase, dashes. | | `id` | yes | Must equal the folder name. Lowercase, dashes. |
| `name` | yes | Human name shown on the tile header and in menus. | | `name` | yes | Human name shown on the tile header and in menus. |
| `version`, `description` | no | Shown in the admin page / picker. | | `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. |
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
| `description` | no | Shown in the admin page / picker. |
| `client` | yes | Client entry file, loaded as an ES module. | | `client` | yes | Client entry file, loaded as an ES module. |
| `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. |
| `style` | no | A stylesheet the shell injects once per module. | | `style` | no | A stylesheet the shell injects once per module. |
+1
View File
@@ -2,6 +2,7 @@
"id": "clock", "id": "clock",
"name": "Clock", "name": "Clock",
"version": "1.0.0", "version": "1.0.0",
"proddash": ">=1.1.0",
"description": "Big booth clock — the smallest possible ProdDash module", "description": "Big booth clock — the smallest possible ProdDash module",
"client": "client.js", "client": "client.js",
"style": "style.css", "style": "style.css",
+1
View File
@@ -2,6 +2,7 @@
"id": "prodcom-transcript", "id": "prodcom-transcript",
"name": "ProdCom", "name": "ProdCom",
"version": "1.0.0", "version": "1.0.0",
"proddash": ">=1.1.0",
"description": "Live ProdCom transcript stream", "description": "Live ProdCom transcript stream",
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
@@ -2,6 +2,7 @@
"id": "propresenter-now-next", "id": "propresenter-now-next",
"name": "ProPresenter", "name": "ProPresenter",
"version": "1.0.0", "version": "1.0.0",
"proddash": ">=1.1.0",
"description": "What is live in ProPresenter and what comes next", "description": "What is live in ProPresenter and what comes next",
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
+1
View File
@@ -2,6 +2,7 @@
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.0.0", "version": "1.0.0",
"proddash": ">=1.1.0",
"description": "Live ProPresenter timers and LTC timecode", "description": "Live ProPresenter timers and LTC timecode",
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
+9
View File
@@ -0,0 +1,9 @@
{
"name": "proddash",
"version": "1.1.0",
"private": true,
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
"engines": { "node": ">=18" },
"scripts": { "start": "node server.js" }
}
+65
View File
@@ -170,3 +170,68 @@ body { overflow: auto; }
.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; } .layout-row .name { flex: 1; font-size: 13px; font-weight: 600; }
.layout-row .meta { color: var(--muted); font-size: 11px; } .layout-row .meta { color: var(--muted); font-size: 11px; }
.layouts-empty { color: var(--muted); font-size: 13px; } .layouts-empty { color: var(--muted); font-size: 13px; }
/* ── version, update banner, catalog ───────────────────────────────── */
.admin-version {
font-size: 11px;
color: var(--muted);
margin-left: 4px;
vertical-align: 2px;
}
.update-card {
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
background: color-mix(in srgb, var(--warn) 7%, var(--panel));
margin-top: 18px;
}
.update-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.update-head strong { font-size: 14px; }
.update-head .spacer { flex: 1; }
.update-card .admin-copy { margin: 8px 0 0; }
.btn.small { padding: 4px 9px; font-size: 12px; }
.btn.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 14%, var(--bg)); }
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
a.btn { text-decoration: none; }
.module-source {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
}
.module-update-slot:empty { display: none; }
.catalog-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.catalog-toolbar .admin-status { margin-top: 0; }
.catalog-row {
display: flex;
align-items: flex-start;
gap: 12px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 10px;
}
.catalog-main { flex: 1; min-width: 0; }
.catalog-title { display: flex; align-items: center; gap: 8px; }
.catalog-row .module-desc { margin: 4px 0 2px; }
.catalog-row .module-health { margin-top: 2px; }
+249 -6
View File
@@ -12,6 +12,19 @@ const modulesList = document.getElementById('modules-list');
const layoutsSection = document.getElementById('layouts-section'); const layoutsSection = document.getElementById('layouts-section');
const layoutsList = document.getElementById('layouts-list'); const layoutsList = document.getElementById('layouts-list');
const toastEl = document.getElementById('toast'); const toastEl = document.getElementById('toast');
const availableSection = document.getElementById('available-section');
const availableList = document.getElementById('available-list');
const catalogStatus = document.getElementById('catalog-status');
const updateCard = document.getElementById('update-card');
const updateTitle = document.getElementById('update-title');
const updateCopy = document.getElementById('update-copy');
const updateStatus = document.getElementById('update-status');
const updateBtn = document.getElementById('update-btn');
const updateChanges = document.getElementById('update-changes');
const shellVersionEl = document.getElementById('shell-version');
/** The shell version the page loaded with — the update flow watches it change. */
let shellVersion = '';
let toastTimer = null; let toastTimer = null;
function toast(msg, isError = false) { function toast(msg, isError = false) {
@@ -42,7 +55,9 @@ async function api(path, opts = {}) {
function showLogin() { function showLogin() {
loginCard.hidden = false; loginCard.hidden = false;
modulesSection.hidden = true; modulesSection.hidden = true;
availableSection.hidden = true;
layoutsSection.hidden = true; layoutsSection.hidden = true;
updateCard.hidden = true;
passcodeInput.focus(); passcodeInput.focus();
} }
@@ -170,9 +185,43 @@ function buildModuleCard(mod) {
version.className = 'module-version'; version.className = 'module-version';
version.textContent = mod.version ? 'v' + mod.version : ''; version.textContent = mod.version ? 'v' + mod.version : '';
const source = document.createElement('span');
source.className = 'module-source';
source.textContent = mod.source === 'installed' ? 'installed' : 'bundled';
source.title = mod.source === 'installed'
? 'Installed from the repo into the data directory'
: 'Ships with this copy of ProdDash';
const spacer = document.createElement('span'); const spacer = document.createElement('span');
spacer.className = 'spacer'; spacer.className = 'spacer';
// filled by the catalog when the repo has a newer version
const updateSlot = document.createElement('span');
updateSlot.className = 'module-update-slot';
const uninstall = document.createElement('button');
uninstall.className = 'btn small danger';
uninstall.textContent = 'Uninstall';
uninstall.title = mod.source === 'installed'
? 'Remove this module\'s files from the data directory'
: 'Hide this bundled module from every dashboard (its folder stays with the app)';
uninstall.addEventListener('click', async () => {
const what = mod.source === 'installed'
? `Uninstall ${mod.name}? Its files are removed from the data directory. Its settings are kept in case you install it again.`
: `Uninstall ${mod.name}? It disappears from every dashboard and the picker. It ships with ProdDash, so it can be brought back from "Available modules" any time.`;
if (!window.confirm(what)) return;
uninstall.disabled = true;
try {
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}`, { method: 'DELETE' });
toast(`${mod.name} uninstalled`);
await loadState();
} catch (e) {
uninstall.disabled = false;
if (e.authRequired) showLogin();
else toast(e.message, true);
}
});
const toggleLabel = document.createElement('label'); const toggleLabel = document.createElement('label');
toggleLabel.className = 'switch'; toggleLabel.className = 'switch';
const toggle = document.createElement('input'); const toggle = document.createElement('input');
@@ -184,7 +233,7 @@ function buildModuleCard(mod) {
toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled'; toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
toggleLabel.append(toggle, track, toggleText); toggleLabel.append(toggle, track, toggleText);
head.append(dot, name, version, spacer, toggleLabel); head.append(dot, name, version, source, spacer, updateSlot, toggleLabel, uninstall);
card.appendChild(head); card.appendChild(head);
const desc = document.createElement('div'); const desc = document.createElement('div');
@@ -199,11 +248,13 @@ function buildModuleCard(mod) {
const mountError = document.createElement('div'); const mountError = document.createElement('div');
mountError.className = 'module-mount-error'; mountError.className = 'module-mount-error';
mountError.hidden = !mod.mountError; mountError.hidden = !mod.mountError && !mod.incompatible;
mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; mountError.textContent = mod.incompatible
? `Not loaded — ${mod.incompatible}. Update ProdDash from the banner above.`
: (mod.mountError ? `Failed to start: ${mod.mountError}` : '');
card.appendChild(mountError); card.appendChild(mountError);
liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError }); liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError, updateSlot });
toggle.addEventListener('change', async () => { toggle.addEventListener('change', async () => {
try { try {
@@ -289,8 +340,10 @@ async function refreshStatus() {
els.dot.className = 'dot ' + healthClass(mod); els.dot.className = 'dot ' + healthClass(mod);
els.healthEl.textContent = healthText(mod); els.healthEl.textContent = healthText(mod);
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
els.mountError.hidden = !mod.mountError; if (!mod.incompatible) {
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; els.mountError.hidden = !mod.mountError;
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
}
if (document.activeElement !== els.toggle) { if (document.activeElement !== els.toggle) {
els.toggle.checked = mod.enabled; els.toggle.checked = mod.enabled;
els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled'; els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
@@ -382,6 +435,9 @@ async function loadState() {
} }
loginCard.hidden = true; loginCard.hidden = true;
modulesSection.hidden = false; modulesSection.hidden = false;
availableSection.hidden = false;
shellVersion = String(state.version || '');
shellVersionEl.textContent = shellVersion ? 'v' + shellVersion : '';
const storageNote = document.getElementById('storage-note'); const storageNote = document.getElementById('storage-note');
if (state.dataDir) { if (state.dataDir) {
document.getElementById('storage-path').textContent = state.dataDir; document.getElementById('storage-path').textContent = state.dataDir;
@@ -393,8 +449,195 @@ async function loadState() {
modulesList.appendChild(buildModuleCard(mod)); modulesList.appendChild(buildModuleCard(mod));
} }
loadLayouts(); loadLayouts();
loadCatalog(false);
} }
/* ── the repo: available modules, module updates, shell update ──────── */
function fmtTime(ms) {
return ms ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
}
async function loadCatalog(refresh) {
catalogStatus.className = 'admin-status';
catalogStatus.textContent = refresh ? 'Checking the repo…' : 'Checking…';
let cat;
try {
cat = await api('/api/admin/catalog' + (refresh ? '?refresh=1' : ''));
} catch (e) {
if (e.authRequired) return showLogin();
catalogStatus.className = 'admin-status error';
catalogStatus.textContent = e.message;
return;
}
renderShell(cat.shell || {});
renderAvailable(cat);
}
function renderShell(shell) {
const remote = shell.remote || {};
if (remote.error) {
catalogStatus.className = 'admin-status error';
catalogStatus.textContent = `Couldn't reach ${shell.repo}: ${remote.error}`;
} else {
catalogStatus.className = 'admin-status';
catalogStatus.textContent = `${shell.repo}@${shell.branch}` +
(remote.version ? ` · latest ProdDash ${remote.version}` : '') +
(remote.checkedAt ? ` · checked ${fmtTime(remote.checkedAt)}` : '');
}
if (!shell.updateAvailable) {
updateCard.hidden = true;
return;
}
updateCard.hidden = false;
updateTitle.textContent = shell.versionBehind
? `ProdDash ${remote.version} is available`
: `New commits on ${shell.branch}`;
const where = shell.local?.isGit
? `a git checkout of ${shell.local.branch} at ${String(shell.local.sha || '').slice(0, 7)}; updating runs git pull`
: 'not a git checkout; updating unpacks the repo archive over the app folder';
updateCopy.textContent = `You're running ProdDash ${shell.version} (${where}). Settings and layouts live outside the app folder and are not touched.`;
updateChanges.href = shell.changesUrl || '#';
updateBtn.disabled = Boolean(shell.updateBlocker) || updateBtn.dataset.busy === '1';
if (shell.updateBlocker) {
updateStatus.className = 'admin-status error';
updateStatus.textContent = `Can't update from here: ${shell.updateBlocker}.`;
} else if (updateBtn.dataset.busy !== '1') {
updateStatus.className = 'admin-status';
updateStatus.textContent = 'Updating restarts ProdDash; open dashboards reconnect on their own.';
}
}
function renderAvailable(cat) {
availableList.innerHTML = '';
const updates = cat.updates || [];
// newer versions of installed modules → a button on their card
for (const els of liveEls.values()) els.updateSlot.innerHTML = '';
for (const up of updates) {
const els = liveEls.get(up.id);
if (!els) continue;
const btn = document.createElement('button');
btn.className = 'btn small primary';
btn.textContent = `Update to v${up.version}`;
btn.disabled = !up.compatible;
btn.title = up.compatible ? `Download v${up.version} from the repo` : up.reason;
btn.addEventListener('click', () => installFromCatalog(up, btn));
els.updateSlot.appendChild(btn);
}
const available = cat.available || [];
if (!available.length) {
const none = document.createElement('div');
none.className = 'layouts-empty';
none.textContent = cat.shell?.remote?.error
? 'The repo could not be reached, and nothing bundled is waiting to be installed.'
: 'Everything the repo offers is installed.';
availableList.appendChild(none);
return;
}
for (const item of available) {
const row = document.createElement('div');
row.className = 'catalog-row';
const main = document.createElement('div');
main.className = 'catalog-main';
const title = document.createElement('div');
title.className = 'catalog-title';
const name = document.createElement('span');
name.className = 'module-name';
name.textContent = item.name;
const version = document.createElement('span');
version.className = 'module-version';
version.textContent = item.version ? 'v' + item.version : '';
const source = document.createElement('span');
source.className = 'module-source';
source.textContent = item.source === 'bundled' ? 'bundled' : 'repo';
source.title = item.source === 'bundled' ? 'Ships with this copy of ProdDash — restoring it downloads nothing' : 'Downloaded from the repo';
title.append(name, version, source);
const desc = document.createElement('div');
desc.className = 'module-desc';
desc.textContent = item.description || '';
const req = document.createElement('div');
req.className = 'module-health' + (item.compatible ? '' : ' error');
req.textContent = item.compatible
? (item.requires ? `Requires ProdDash ${item.requires}` : '')
: item.reason;
main.append(title, desc, req);
const btn = document.createElement('button');
btn.className = 'btn small primary';
btn.textContent = 'Install';
btn.disabled = !item.compatible;
btn.title = item.compatible ? '' : item.reason;
btn.addEventListener('click', () => installFromCatalog(item, btn));
row.append(main, btn);
availableList.appendChild(row);
}
}
async function installFromCatalog(item, btn) {
btn.disabled = true;
const label = btn.textContent;
btn.textContent = 'Installing…';
try {
const r = await api(`/api/admin/modules/${encodeURIComponent(item.id)}/install`, { method: 'POST', body: '{}' });
toast(`${item.name} ${r.downloaded ? 'installed' : 'restored'}${r.version ? ' (v' + r.version + ')' : ''}`);
await loadState();
} catch (e) {
btn.disabled = false;
btn.textContent = label;
if (e.authRequired) showLogin();
else toast(e.message, true);
}
}
document.getElementById('catalog-refresh').addEventListener('click', () => loadCatalog(true));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** After the update the server exits and comes back; wait for it. */
async function waitForRestart(before) {
const started = Date.now();
let sawDown = false;
while (Date.now() - started < 90000) {
await sleep(1500);
try {
const s = await api('/api/admin/state');
if (sawDown || s.version !== before) {
toast(`ProdDash ${s.version} is running`);
await sleep(600);
location.reload();
return;
}
} catch (e) {
if (e.status === 401) { location.reload(); return; } // back up, passcode wanted again
sawDown = true;
}
}
updateStatus.className = 'admin-status error';
updateStatus.textContent = 'ProdDash did not come back on its own — start it again on the server, then reload this page.';
updateBtn.dataset.busy = '';
updateBtn.disabled = false;
}
updateBtn.addEventListener('click', async () => {
if (!window.confirm('Update ProdDash now? The server restarts and every open dashboard reconnects a few seconds later.')) return;
updateBtn.dataset.busy = '1';
updateBtn.disabled = true;
updateStatus.className = 'admin-status';
updateStatus.textContent = 'Updating…';
const before = shellVersion;
try {
const r = await api('/api/admin/update', { method: 'POST', body: '{}' });
updateStatus.textContent = `Updated ${r.from} → ${r.to || 'latest commit'} via ${r.method}. Restarting…`;
} catch (e) {
updateStatus.className = 'admin-status error';
updateStatus.textContent = e.message;
updateBtn.dataset.busy = '';
updateBtn.disabled = false;
if (e.authRequired) showLogin();
return;
}
await waitForRestart(before);
});
setInterval(() => { setInterval(() => {
if (modulesSection.hidden) return; if (modulesSection.hidden) return;
refreshStatus(); refreshStatus();
+25 -1
View File
@@ -11,7 +11,7 @@
</head> </head>
<body> <body>
<header id="topbar"> <header id="topbar">
<span id="brand">Prod<span class="accent">Dash</span> <span class="admin-tag">Admin</span></span> <span id="brand">Prod<span class="accent">Dash</span> <span class="admin-tag">Admin</span> <span class="admin-version" id="shell-version"></span></span>
<span class="spacer"></span> <span class="spacer"></span>
<a class="tb-btn" href="/">← Dashboard</a> <a class="tb-btn" href="/">← Dashboard</a>
</header> </header>
@@ -27,6 +27,18 @@
<div class="admin-status" id="login-status"></div> <div class="admin-status" id="login-status"></div>
</section> </section>
<section class="admin-card update-card" id="update-card" hidden>
<div class="update-head">
<span class="dot connecting"></span>
<strong id="update-title">Update available</strong>
<span class="spacer"></span>
<a class="btn small" id="update-changes" href="#" target="_blank" rel="noopener">What changed</a>
<button class="btn small primary" id="update-btn">Update now</button>
</div>
<p class="admin-copy" id="update-copy"></p>
<div class="admin-status" id="update-status"></div>
</section>
<section id="modules-section" hidden> <section id="modules-section" hidden>
<h1 class="admin-h1">Modules</h1> <h1 class="admin-h1">Modules</h1>
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the <p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
@@ -37,6 +49,18 @@
<div id="modules-list"></div> <div id="modules-list"></div>
</section> </section>
<section id="available-section" hidden>
<h1 class="admin-h1">Available modules</h1>
<p class="admin-copy">Modules in the ProdDash repo that aren't installed here. Installing downloads the module
into the server's data directory — no restart, nothing written into the app folder. A module that needs a
newer ProdDash can't be installed until the shell is updated.</p>
<div class="catalog-toolbar">
<button class="btn small" id="catalog-refresh">Check again</button>
<span class="admin-status" id="catalog-status"></span>
</div>
<div id="available-list"></div>
</section>
<section id="layouts-section" hidden> <section id="layouts-section" hidden>
<h1 class="admin-h1">Named layouts</h1> <h1 class="admin-h1">Named layouts</h1>
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one <p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
+566 -14
View File
@@ -22,10 +22,12 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const crypto = require('crypto'); const crypto = require('crypto');
const zlib = require('zlib');
const { spawn, execFileSync } = require('child_process');
const ROOT = __dirname; const ROOT = __dirname;
const PUBLIC_DIR = path.join(ROOT, 'public'); const PUBLIC_DIR = path.join(ROOT, 'public');
const MODULES_DIR = path.join(ROOT, 'modules'); const BUNDLED_MODULES_DIR = path.join(ROOT, 'modules'); // modules that ship with the checkout
/** Checked-in defaults (proddash.json) — and where runtime state used to live. */ /** Checked-in defaults (proddash.json) — and where runtime state used to live. */
const REPO_CONFIG_DIR = path.join(ROOT, 'config'); const REPO_CONFIG_DIR = path.join(ROOT, 'config');
@@ -73,6 +75,7 @@ function resolveDataDir() {
const DATA_DIR = resolveDataDir(); const DATA_DIR = resolveDataDir();
const MODULES_CONFIG_PATH = path.join(DATA_DIR, 'modules.json'); const MODULES_CONFIG_PATH = path.join(DATA_DIR, 'modules.json');
const LAYOUTS_DIR = path.join(DATA_DIR, 'layouts'); const LAYOUTS_DIR = path.join(DATA_DIR, 'layouts');
const INSTALLED_MODULES_DIR = path.join(DATA_DIR, 'modules'); // modules installed from the admin page
/* ── config files ───────────────────────────────────────────────────── */ /* ── config files ───────────────────────────────────────────────────── */
@@ -139,6 +142,59 @@ const shellConfig = {
}; };
const PORT = Number(process.env.PORT || shellConfig.port || 24500); const PORT = Number(process.env.PORT || shellConfig.port || 24500);
/* ── shell version & version ranges ─────────────────────────────────── */
/** The shell's own version, from package.json. Modules declare the range
they need in their manifest ("proddash": ">=1.1.0"). */
const SHELL_VERSION = String(readJson(path.join(ROOT, 'package.json'), {}).version || '0.0.0');
function parseVersion(v) {
const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)(?:\.(\d+))?/);
return m ? [Number(m[1]), Number(m[2]), Number(m[3] || 0)] : null;
}
/** -1 / 0 / 1 like a comparator; unparseable versions sort lowest. */
function compareVersions(a, b) {
const pa = parseVersion(a) || [-1, 0, 0];
const pb = parseVersion(b) || [-1, 0, 0];
for (let i = 0; i < 3; i += 1) {
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
}
return 0;
}
/**
* Does `version` satisfy `range`? Ranges are deliberately small: "" or "*"
* (anything), "1.2.0" (exact), ">=1.2.0", ">1.2.0", "<=…", "<…",
* "^1.2.0" (same major, at least that), "~1.2.0" (same minor, at least
* that), and space-separated combinations such as ">=1.1.0 <2.0.0".
*/
function satisfiesVersion(version, range) {
const r = String(range || '').trim();
if (!r || r === '*') return true;
const v = parseVersion(version);
if (!v) return false;
return r.split(/[\s,]+/).filter(Boolean).every((part) => {
const m = part.match(/^(>=|<=|>|<|=|\^|~)?v?(\d+)\.(\d+)(?:\.(\d+))?$/);
if (!m) return false;
const t = [Number(m[2]), Number(m[3]), Number(m[4] || 0)];
const c = compareVersions(v.join('.'), t.join('.'));
switch (m[1] || '=') {
case '>=': return c >= 0;
case '>': return c > 0;
case '<=': return c <= 0;
case '<': return c < 0;
case '^': return v[0] === t[0] && c >= 0;
case '~': return v[0] === t[0] && v[1] === t[1] && c >= 0;
default: return c === 0;
}
});
}
function requirementNote(range) {
return `Requires ProdDash ${range} — this is ${SHELL_VERSION}`;
}
/** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */ /** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */
let modulesConfig = readJson(MODULES_CONFIG_PATH, {}); let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
@@ -147,18 +203,19 @@ let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
/** @type {Map<string, object>} module id -> manifest (with .dir added) */ /** @type {Map<string, object>} module id -> manifest (with .dir added) */
const manifests = new Map(); const manifests = new Map();
function discoverModules() { /** Read every valid manifest in one modules folder. */
manifests.clear(); function readManifests(baseDir, source) {
const out = [];
let dirs = []; let dirs = [];
try { try {
dirs = fs.readdirSync(MODULES_DIR, { withFileTypes: true }) dirs = fs.readdirSync(baseDir, { withFileTypes: true })
.filter((d) => d.isDirectory()) .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
.map((d) => d.name); .map((d) => d.name);
} catch { } catch {
return; // no modules/ folder yet — the shell still runs return out; // folder missing — fine
} }
for (const dir of dirs) { for (const dir of dirs) {
const manifestPath = path.join(MODULES_DIR, dir, 'module.json'); const manifestPath = path.join(baseDir, dir, 'module.json');
const man = readJson(manifestPath, null); const man = readJson(manifestPath, null);
if (!man || typeof man !== 'object') { if (!man || typeof man !== 'object') {
if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`); if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`);
@@ -173,9 +230,48 @@ function discoverModules() {
console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`); console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`);
continue; continue;
} }
man.dir = path.join(MODULES_DIR, dir); man.dir = path.join(baseDir, dir);
manifests.set(dir, man); man.source = source;
man.requires = String(man.proddash || '').trim();
// A module built for a newer shell is listed (so admin can explain) but never loaded.
man.incompatible = satisfiesVersion(SHELL_VERSION, man.requires) ? '' : requirementNote(man.requires);
out.push(man);
} }
return out;
}
/**
* Modules come from two places: the ones bundled with this checkout
* (modules/) and the ones installed from the admin page (the data
* directory's modules/). Same id in both → the newer version runs; a
* downloaded copy the bundled one has since overtaken is removed. A bundled
* module the admin page "uninstalled" stays on disk (it belongs to the app
* folder) but is left out here.
*/
function discoverModules() {
manifests.clear();
const bundled = new Map(readManifests(BUNDLED_MODULES_DIR, 'bundled').map((m) => [m.id, m]));
const installed = new Map(readManifests(INSTALLED_MODULES_DIR, 'installed').map((m) => [m.id, m]));
for (const [id, man] of bundled) {
if (isUninstalled(id)) continue;
const copy = installed.get(id);
if (copy && compareVersions(copy.version, man.version) >= 0) continue; // the copy wins, below
if (copy) {
console.log(`[proddash] ${id}: bundled v${man.version} supersedes the installed v${copy.version} — removing that copy`);
try { fs.rmSync(copy.dir, { recursive: true, force: true }); } catch { /* it just stays unused */ }
installed.delete(id);
}
manifests.set(id, man);
}
for (const [id, man] of installed) {
if (manifests.has(id)) continue;
man.bundledVersion = String(bundled.get(id)?.version || '');
manifests.set(id, man);
}
}
function isUninstalled(id) {
return moduleState(id).uninstalled === true;
} }
function moduleState(id) { function moduleState(id) {
@@ -187,6 +283,12 @@ function isEnabled(id) {
return moduleState(id).enabled !== false; // newly dropped modules default to enabled return moduleState(id).enabled !== false; // newly dropped modules default to enabled
} }
/** Enabled and runnable on this shell version — what dashboards may load. */
function isActive(id) {
const man = manifests.get(id);
return Boolean(man) && isEnabled(id) && !man.incompatible;
}
/** Admin config for a module: schema defaults overlaid with stored values. */ /** Admin config for a module: schema defaults overlaid with stored values. */
function effectiveConfig(id) { function effectiveConfig(id) {
const man = manifests.get(id); const man = manifests.get(id);
@@ -260,6 +362,10 @@ function mountModule(id) {
if (!man) return; if (!man) return;
const entry = { routes: [], handle: null, error: '' }; const entry = { routes: [], handle: null, error: '' };
mounted.set(id, entry); mounted.set(id, entry);
if (man.incompatible) {
entry.error = man.incompatible;
return;
}
if (!man.server) return; if (!man.server) return;
const log = makeLog(id); const log = makeLog(id);
try { try {
@@ -434,6 +540,385 @@ setInterval(() => {
} }
}, 25000).unref(); }, 25000).unref();
/* ── the repo: module catalog and shell updates ──────────────────────── */
/* The admin page installs modules from, and updates the shell against, the
ProdDash repository on GitHub — one small tar.gz of the branch, fetched at
most every few minutes, parsed here with no dependencies. Nothing is
contacted unless someone opens the admin page or the periodic update
check runs; every failure is recorded and shown, never thrown at a page. */
function parseRepoSetting(value, fallbackBranch) {
const m = String(value || '').trim().match(/^([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#([\w./-]+))?$/);
if (!m) return null;
return { owner: m[1], repo: m[2], branch: m[3] || fallbackBranch || 'main' };
}
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main');
/** Where branch archives come from; tests point this at a local mock. */
const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || 'https://codeload.github.com').replace(/\/+$/, '');
const REMOTE_TTL_MS = 10 * 60 * 1000;
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
function tarballUrl() {
return `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${encodeURIComponent(REPO.branch)}`;
}
function httpError(status, message) {
const err = new Error(message);
err.status = status;
return err;
}
function safeJson(buf) {
try { return buf ? JSON.parse(buf.toString('utf8')) : null; } catch { return null; }
}
/** A relative path that can only land inside the folder it is joined to. */
function safeRelPath(p) {
if (typeof p !== 'string' || !p || p.startsWith('/') || p.includes('\\') || p.includes('\0')) return false;
return p.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
}
/* — a minimal tar reader: regular files, GNU long names, pax headers — */
function cstr(buf, start, len) {
const s = buf.subarray(start, start + len);
const nul = s.indexOf(0);
return s.subarray(0, nul === -1 ? len : nul).toString('utf8');
}
/** pax records look like "27 path=some/long/name\n" (length counts itself). */
function parsePaxRecords(buf) {
const out = {};
let pos = 0;
while (pos < buf.length) {
const sp = buf.indexOf(0x20, pos);
if (sp === -1) break;
const len = parseInt(buf.subarray(pos, sp).toString(), 10);
if (!Number.isFinite(len) || len <= 0) break;
const rec = buf.subarray(sp + 1, pos + len - 1).toString('utf8');
const eq = rec.indexOf('=');
if (eq > 0) out[rec.slice(0, eq)] = rec.slice(eq + 1);
pos += len;
}
return out;
}
function parseTar(buf) {
const files = new Map();
let comment = '';
let pendingName = null;
let off = 0;
while (off + 512 <= buf.length) {
const h = buf.subarray(off, off + 512);
if (h.every((b) => b === 0)) break; // end-of-archive blocks
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
const shortName = prefix ? `${prefix}/${cstr(h, 0, 100)}` : cstr(h, 0, 100);
const data = buf.subarray(off + 512, off + 512 + size);
off += 512 + Math.ceil(size / 512) * 512;
if (type === 'L') { pendingName = cstr(data, 0, size); continue; } // GNU long name
if (type === 'g' || type === 'x') { // pax headers
const rec = parsePaxRecords(data);
if (rec.comment && !comment) comment = String(rec.comment).trim(); // GitHub: the commit sha
if (type === 'x' && rec.path) pendingName = rec.path;
continue;
}
const name = pendingName || shortName;
pendingName = null;
if (type === '0') files.set(name, Buffer.from(data));
}
return { files, comment };
}
/** Download the branch archive → { files: Map<relative path, Buffer>, sha }. */
async function fetchArchive() {
const res = await fetch(tarballUrl(), {
redirect: 'follow',
signal: AbortSignal.timeout(30000),
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${REPO.owner}/${REPO.repo}@${REPO.branch}`);
const gz = Buffer.from(await res.arrayBuffer());
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
const { files, comment } = parseTar(zlib.gunzipSync(gz));
const stripped = new Map(); // drop the archive's single top-level folder
for (const [p, data] of files) {
const i = p.indexOf('/');
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data);
}
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
}
/** What we last learned about the repo. */
let remote = { checkedAt: 0, checking: null, error: '', sha: '', version: '', modules: new Map(), files: null };
async function refreshRemote(force = false) {
const fresh = remote.checkedAt && Date.now() - remote.checkedAt < REMOTE_TTL_MS;
if (!force && fresh && !remote.error) return remote;
if (remote.checking) return remote.checking;
const job = (async () => {
try {
const { files, sha } = await fetchArchive();
const pkg = safeJson(files.get('package.json'));
const modules = new Map();
for (const [p, data] of files) {
const m = p.match(/^modules\/([^/]+)\/(.+)$/);
if (!m) continue;
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
entry.files.set(m[2], data);
modules.set(m[1], entry);
}
for (const [id, entry] of modules) {
const man = safeJson(entry.files.get('module.json'));
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
modules.delete(id);
continue;
}
entry.manifest = { ...man, id };
}
remote = { checkedAt: Date.now(), checking: null, error: '', sha, version: String(pkg?.version || ''), modules, files };
} catch (err) {
remote = { ...remote, checkedAt: Date.now(), checking: null, error: err?.message || String(err) };
}
return remote;
})();
remote.checking = job;
return job;
}
/* — this checkout — */
function git(args, timeout = 15000) {
// fileMode=false: a chmod (e.g. making the launcher executable) is not a local change
return execFileSync('git', ['-c', 'core.fileMode=false', ...args], { cwd: ROOT, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
}
function localGitInfo() {
if (!fs.existsSync(path.join(ROOT, '.git'))) return { isGit: false, sha: '', branch: '', dirty: false, error: '' };
try {
const porcelain = git(['status', '--porcelain']).split('\n').filter((l) => l && !l.startsWith('??'));
return { isGit: true, sha: git(['rev-parse', 'HEAD']), branch: git(['rev-parse', '--abbrev-ref', 'HEAD']), dirty: porcelain.length > 0, error: '' };
} catch (err) {
return { isGit: true, sha: '', branch: '', dirty: false, error: (err?.message || String(err)).split('\n')[0] };
}
}
function shellStatus() {
const local = localGitInfo();
const versionCmp = remote.version ? compareVersions(remote.version, SHELL_VERSION) : 0;
const versionBehind = versionCmp > 0;
const sameCommit = !local.sha || !remote.sha || local.sha.startsWith(remote.sha) || remote.sha.startsWith(local.sha);
const commitsBehind = !sameCommit && versionCmp >= 0;
let blocker = '';
if (local.isGit) {
if (local.error) blocker = `git is not usable here (${local.error})`;
else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`;
else if (local.dirty) blocker = 'this checkout has local changes — commit or discard them first';
}
const gh = `https://github.com/${REPO.owner}/${REPO.repo}`;
return {
version: SHELL_VERSION,
repo: `${REPO.owner}/${REPO.repo}`,
branch: REPO.branch,
local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty },
remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error },
updateAvailable: versionBehind || commitsBehind,
versionBehind,
method: local.isGit ? 'git' : 'archive',
updateBlocker: blocker,
changesUrl: local.sha && remote.sha
? `${gh}/compare/${local.sha.slice(0, 12)}...${remote.sha.slice(0, 12)}`
: `${gh}/commits/${REPO.branch}`,
};
}
/* — the catalog: what could be installed or updated — */
function catalogView() {
const available = [];
const updates = [];
const hiddenBundled = readManifests(BUNDLED_MODULES_DIR, 'bundled').filter((m) => isUninstalled(m.id));
const seen = new Set();
for (const [id, entry] of remote.modules) {
const man = entry.manifest;
seen.add(id);
const requires = String(man.proddash || '').trim();
const compatible = satisfiesVersion(SHELL_VERSION, requires);
const base = {
id,
name: String(man.name || id),
version: String(man.version || ''),
description: String(man.description || ''),
requires,
compatible,
reason: compatible ? '' : requirementNote(requires),
};
const installed = manifests.get(id);
if (installed) {
if (compareVersions(man.version, installed.version) > 0) {
updates.push({ ...base, installedVersion: String(installed.version || ''), source: 'repo' });
}
} else {
const hidden = hiddenBundled.find((m) => m.id === id);
const useBundled = hidden && compareVersions(hidden.version, man.version) >= 0;
available.push({ ...base, source: useBundled ? 'bundled' : 'repo' });
}
}
for (const m of hiddenBundled) {
if (seen.has(m.id)) continue;
available.push({
id: m.id, name: String(m.name || m.id), version: String(m.version || ''), description: String(m.description || ''),
requires: m.requires, compatible: !m.incompatible, reason: m.incompatible, source: 'bundled',
});
}
const byName = (a, b) => a.name.localeCompare(b.name);
return { available: available.sort(byName), updates: updates.sort(byName) };
}
/** Install (or update) a module from the repo, or bring back a bundled one. */
async function installModule(id) {
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw httpError(400, 'Bad module id.');
await refreshRemote(false);
const remoteEntry = remote.modules.get(id) || null;
const bundledMan = readManifests(BUNDLED_MODULES_DIR, 'bundled').find((m) => m.id === id) || null;
const installedMan = readManifests(INSTALLED_MODULES_DIR, 'installed').find((m) => m.id === id) || null;
if (!remoteEntry && !bundledMan && !installedMan) {
throw httpError(404, remote.error
? `"${id}" is not here and the repo could not be reached: ${remote.error}`
: `"${id}" is not in ${REPO.owner}/${REPO.repo}.`);
}
// End up with the newest copy: what the repo offers vs what is already here.
const haveVersion = installedMan?.version || bundledMan?.version || '';
const download = Boolean(remoteEntry) && (!(bundledMan || installedMan) || compareVersions(remoteEntry.manifest.version, haveVersion) > 0);
const man = download ? remoteEntry.manifest : (installedMan || bundledMan);
const requires = String(man.proddash || '').trim();
if (!satisfiesVersion(SHELL_VERSION, requires)) {
throw httpError(409, `${man.name || id} ${requirementNote(requires).replace('Requires', 'requires')}. Update ProdDash first.`);
}
if (download) {
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
fs.rmSync(staging, { recursive: true, force: true });
for (const [rel, data] of remoteEntry.files) {
if (!safeRelPath(rel)) continue;
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data);
}
const dest = path.join(INSTALLED_MODULES_DIR, id);
unmountModule(id);
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(staging, dest);
}
const state = { ...moduleState(id) };
delete state.uninstalled;
modulesConfig[id] = state;
writeJson(MODULES_CONFIG_PATH, modulesConfig);
discoverModules();
remountModule(id);
broadcastShellEvent('modules-changed', { id });
const now = manifests.get(id);
console.log(`[proddash] ${download ? 'installed' : 'restored'} module "${id}" v${now?.version || '?'} (${now?.source || 'unknown'})`);
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
}
/** Remove a module: an installed copy is deleted; a bundled one is switched
off and hidden (its folder belongs to the app). Settings are kept so a
later reinstall picks them up again. */
function uninstallModule(id) {
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw httpError(400, 'Bad module id.');
const installedDir = path.join(INSTALLED_MODULES_DIR, id);
const hadInstalled = fs.existsSync(path.join(installedDir, 'module.json'));
const hasBundled = fs.existsSync(path.join(BUNDLED_MODULES_DIR, id, 'module.json'));
if (!manifests.has(id) && !hadInstalled && !hasBundled) throw httpError(404, `No module "${id}" is installed.`);
unmountModule(id);
if (hadInstalled) fs.rmSync(installedDir, { recursive: true, force: true });
modulesConfig[id] = { ...moduleState(id), uninstalled: true };
writeJson(MODULES_CONFIG_PATH, modulesConfig);
discoverModules();
broadcastShellEvent('modules-changed', { id });
console.log(`[proddash] uninstalled module "${id}"${hadInstalled ? ' (files removed)' : ''}${hasBundled ? ' (bundled copy hidden)' : ''}`);
return { id, removedFiles: hadInstalled, bundled: hasBundled };
}
/* — updating the shell itself — */
function copyTree(src, dst) {
fs.mkdirSync(dst, { recursive: true });
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, e.name);
const d = path.join(dst, e.name);
if (e.isDirectory()) copyTree(s, d);
else fs.copyFileSync(s, d);
}
}
/**
* Bring the app folder up to the repo's branch. A git checkout gets
* `git pull --ff-only`; anything else has the archive unpacked over it
* (runtime state and .git are never touched). The caller restarts.
*/
async function updateShell() {
await refreshRemote(true);
if (remote.error) throw httpError(502, `Couldn't reach ${REPO.owner}/${REPO.repo}: ${remote.error}`);
const status = shellStatus();
if (status.updateBlocker) throw httpError(409, `Can't update from here: ${status.updateBlocker}.`);
if (!status.updateAvailable) throw httpError(409, 'Already up to date.');
if (status.local.isGit) {
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
} else {
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
const staging = path.join(ROOT, '.proddash-update');
fs.rmSync(staging, { recursive: true, force: true });
for (const [rel, data] of remote.files) {
if (!safeRelPath(rel) || keep(rel)) continue;
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data);
}
if (!fs.existsSync(path.join(staging, 'server.js'))) {
fs.rmSync(staging, { recursive: true, force: true });
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
}
copyTree(staging, ROOT);
fs.rmSync(staging, { recursive: true, force: true });
}
return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
}
/**
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
* own replacement, which waits for this port to free up.
*/
function restartServer(reason) {
console.log(`[proddash] ${reason} — restarting ProdDash`);
for (const id of [...mounted.keys()]) unmountModule(id);
let done = false;
const finish = () => {
if (done) return;
done = true;
if (!process.env.PRODDASH_LAUNCHER) {
try {
const child = spawn(process.execPath, process.argv.slice(1), {
cwd: process.cwd(), env: process.env, detached: true, stdio: 'inherit',
});
child.unref();
} catch (err) {
console.error('[proddash] could not start the new ProdDash — start it by hand:', err?.message || err);
}
}
process.exit(75);
};
server.close(finish);
setTimeout(finish, 1500).unref();
}
/* ── admin API ──────────────────────────────────────────────────────── */ /* ── admin API ──────────────────────────────────────────────────────── */
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || ''); const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
@@ -483,6 +968,9 @@ function adminModuleView(id) {
version: String(man.version || ''), version: String(man.version || ''),
description: String(man.description || ''), description: String(man.description || ''),
hasServer: Boolean(man.server), hasServer: Boolean(man.server),
source: man.source || 'bundled',
requires: man.requires || '',
incompatible: man.incompatible || '',
enabled: isEnabled(id), enabled: isEnabled(id),
configSchema: man.configSchema || {}, configSchema: man.configSchema || {},
config: values, config: values,
@@ -546,11 +1034,51 @@ async function handleAdminApi(req, res, urlPath) {
return sendJson(res, 200, { return sendJson(res, 200, {
authRequired: Boolean(ADMIN_PASSCODE), authRequired: Boolean(ADMIN_PASSCODE),
authed: true, authed: true,
version: SHELL_VERSION,
dataDir: DATA_DIR, dataDir: DATA_DIR,
modules: [...manifests.keys()].sort().map(adminModuleView), modules: [...manifests.keys()].sort().map(adminModuleView),
}); });
} }
if (urlPath === '/api/admin/catalog' && req.method === 'GET') {
if (!isAuthed(req)) return sendJson(res, 401, { authRequired: true, authed: false });
const refresh = new URL(req.url, 'http://proddash.invalid').searchParams.get('refresh') === '1';
await refreshRemote(refresh);
return sendJson(res, 200, { shell: shellStatus(), ...catalogView() });
}
if (urlPath === '/api/admin/update' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
try {
const result = await updateShell();
sendJson(res, 200, { ok: true, ...result, restarting: true });
setTimeout(() => restartServer(`updated ${result.from} → ${result.to || 'latest'} via ${result.method}`), 400);
} catch (err) {
sendJson(res, err.status || 500, { error: err.message || String(err) });
}
return;
}
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
if (install && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
try {
return sendJson(res, 200, { ok: true, ...(await installModule(decodeURIComponent(install[1]))) });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
const remove = urlPath.match(/^\/api\/admin\/modules\/([^/]+)$/);
if (remove && req.method === 'DELETE') {
if (refuseAdminWrite(req, res)) return;
try {
return sendJson(res, 200, { ok: true, ...uninstallModule(decodeURIComponent(remove[1])) });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/); const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/);
if (m && req.method === 'PUT') { if (m && req.method === 'PUT') {
const id = decodeURIComponent(m[1]); const id = decodeURIComponent(m[1]);
@@ -710,6 +1238,7 @@ async function handleLayoutsApi(req, res, urlPath) {
function dispatchModuleApi(req, res, id, subPath, search) { function dispatchModuleApi(req, res, id, subPath, search) {
if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` }); if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` });
if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` }); if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` });
if (manifests.get(id).incompatible) return sendJson(res, 409, { error: manifests.get(id).incompatible });
const entry = mounted.get(id); const entry = mounted.get(id);
if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` }); if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` });
if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` }); if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` });
@@ -812,7 +1341,7 @@ function handleRequest(req, res) {
/* — shell API — */ /* — shell API — */
if (urlPath === '/api/modules' && req.method === 'GET') { if (urlPath === '/api/modules' && req.method === 'GET') {
Promise.all([...manifests.keys()].filter(isEnabled).map(clientManifest)) Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
.then((list) => sendJson(res, 200, { modules: list })) .then((list) => sendJson(res, 200, { modules: list }))
.catch((err) => { .catch((err) => {
console.error('[proddash] /api/modules failed:', err); console.error('[proddash] /api/modules failed:', err);
@@ -879,7 +1408,7 @@ function handleRequest(req, res) {
if (assetMatch) { if (assetMatch) {
const id = decodeURIComponent(assetMatch[1]); const id = decodeURIComponent(assetMatch[1]);
const man = manifests.get(id); const man = manifests.get(id);
if (!man || !isEnabled(id)) { if (!man || !isActive(id)) {
res.writeHead(404, { 'Content-Type': 'text/plain' }); res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('Not found'); return res.end('Not found');
} }
@@ -910,8 +1439,8 @@ server.requestTimeout = 0;
discoverModules(); discoverModules();
mountAllModules(); mountAllModules();
server.listen(PORT, '0.0.0.0', () => { server.on('listening', () => {
console.log('ProdDash running'); console.log('ProdDash ' + SHELL_VERSION);
console.log(' Local : http://localhost:' + PORT); console.log(' Local : http://localhost:' + PORT);
for (const ifaces of Object.values(os.networkInterfaces())) { for (const ifaces of Object.values(os.networkInterfaces())) {
for (const iface of ifaces || []) { for (const iface of ifaces || []) {
@@ -922,11 +1451,34 @@ server.listen(PORT, '0.0.0.0', () => {
} }
const ids = [...manifests.keys()]; const ids = [...manifests.keys()];
console.log(ids.length console.log(ids.length
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ') ? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).join(', ')
: ' Modules : none installed'); : ' Modules : none installed');
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : '')); console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch);
}); });
// A restart hands the port over from the exiting ProdDash: wait for it.
let listenTries = 0;
server.on('error', (err) => {
if (err.code === 'EADDRINUSE' && listenTries < 30) {
listenTries += 1;
if (listenTries === 1) console.log(`[proddash] port ${PORT} is busy — waiting for it (a previous ProdDash may still be shutting down)`);
setTimeout(() => server.listen(PORT, '0.0.0.0'), 500);
return;
}
console.error('[proddash] cannot start:', err.message || err);
process.exit(1);
});
server.listen(PORT, '0.0.0.0');
// Update check: shortly after boot, then every few hours (the admin page
// shows the result; nothing is installed without someone clicking).
if (!process.env.PRODDASH_NO_REMOTE_CHECK) {
setTimeout(() => refreshRemote(false).catch(() => {}), 20000).unref();
setInterval(() => refreshRemote(true).catch(() => {}), REMOTE_CHECK_MS).unref();
}
for (const signal of ['SIGINT', 'SIGTERM']) { for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => { process.on(signal, () => {
for (const id of [...mounted.keys()]) unmountModule(id); for (const id of [...mounted.keys()]) unmountModule(id);