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:
@@ -16,8 +16,16 @@ contract** (manifest, client factory + moduleApi, optional server entry,
|
||||
title-bar controls). The rules that matter most:
|
||||
|
||||
- A module is one self-contained folder in `modules/` — **never modify the
|
||||
shell** (`server.js`, `public/`) to add one. Installation = drop the folder,
|
||||
restart, configure in `/admin`.
|
||||
shell** (`server.js`, `public/`) to add one. Installation = the admin page's
|
||||
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
|
||||
proxy everything (no CORS, secrets stay server-side).
|
||||
- Style with the shell's CSS variables only, scoped under
|
||||
|
||||
@@ -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
|
||||
it at startup (`Settings : …`).
|
||||
|
||||
The directory holds `modules.json` (module config) and `layouts/` (named
|
||||
layouts), both written by the app itself. It may also hold a `proddash.json`
|
||||
The directory holds `modules.json` (module config), `layouts/` (named
|
||||
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
|
||||
[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
|
||||
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
|
||||
|
||||
- **+** — add any enabled module; multiple tiles of the same module are
|
||||
@@ -172,6 +214,7 @@ ProdDash/
|
||||
│ ├── clock/
|
||||
│ ├── prodcom-transcript/
|
||||
│ └── 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
|
||||
│ per-machine data directory — see "Configure")
|
||||
├── docs/MODULE-GUIDE.md how to build and install a module
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"port": 24500,
|
||||
"adminPasscode": ""
|
||||
"adminPasscode": "",
|
||||
"repo": "michaelmcgary2008/ProdDash",
|
||||
"branch": "main"
|
||||
}
|
||||
|
||||
+39
-4
@@ -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
|
||||
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
|
||||
|
||||
A module is one folder dropped into `modules/`:
|
||||
@@ -21,9 +46,16 @@ modules/<id>/
|
||||
└── ... assets (css, images…) served statically at /modules/<id>/
|
||||
```
|
||||
|
||||
**Installation is exactly this:** copy the folder into `modules/`, restart
|
||||
ProdDash (`node server.js`), then configure and enable it at `/admin`. No
|
||||
shell code changes, ever.
|
||||
**Installation is exactly this:** in the admin page, open **Available
|
||||
modules** and press **Install** — the module is downloaded from the ProdDash
|
||||
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
|
||||
immediately. Developing locally? Drop the folder into `modules/` (bundled
|
||||
with the checkout) or into the data directory's `modules/` and restart. No
|
||||
shell code changes, ever. Modules bundled with a checkout and modules
|
||||
installed from the admin page live side by side; when both have the same id
|
||||
the newer version runs. **Uninstall** in the admin page removes an installed
|
||||
module's files (a bundled one is hidden instead, since its folder belongs to
|
||||
the app); its settings are kept for a later reinstall.
|
||||
|
||||
## module.json
|
||||
|
||||
@@ -32,6 +64,7 @@ shell code changes, ever.
|
||||
"id": "my-module",
|
||||
"name": "My Module",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "One line shown in the Add-tile picker and the admin page",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
@@ -52,7 +85,9 @@ shell code changes, ever.
|
||||
| --- | --- | --- |
|
||||
| `id` | yes | Must equal the folder name. Lowercase, dashes. |
|
||||
| `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. |
|
||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||
| `style` | no | A stylesheet the shell injects once per module. |
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "clock",
|
||||
"name": "Clock",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Big booth clock — the smallest possible ProdDash module",
|
||||
"client": "client.js",
|
||||
"style": "style.css",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Live ProdCom transcript stream",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "propresenter-now-next",
|
||||
"name": "ProPresenter",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "What is live in ProPresenter and what comes next",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "propresenter-timers",
|
||||
"name": "Timers",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Live ProPresenter timers and LTC timecode",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -170,3 +170,68 @@ body { overflow: auto; }
|
||||
.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; }
|
||||
.layout-row .meta { color: var(--muted); font-size: 11px; }
|
||||
.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; }
|
||||
|
||||
+247
-4
@@ -12,6 +12,19 @@ const modulesList = document.getElementById('modules-list');
|
||||
const layoutsSection = document.getElementById('layouts-section');
|
||||
const layoutsList = document.getElementById('layouts-list');
|
||||
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;
|
||||
function toast(msg, isError = false) {
|
||||
@@ -42,7 +55,9 @@ async function api(path, opts = {}) {
|
||||
function showLogin() {
|
||||
loginCard.hidden = false;
|
||||
modulesSection.hidden = true;
|
||||
availableSection.hidden = true;
|
||||
layoutsSection.hidden = true;
|
||||
updateCard.hidden = true;
|
||||
passcodeInput.focus();
|
||||
}
|
||||
|
||||
@@ -170,9 +185,43 @@ function buildModuleCard(mod) {
|
||||
version.className = 'module-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');
|
||||
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');
|
||||
toggleLabel.className = 'switch';
|
||||
const toggle = document.createElement('input');
|
||||
@@ -184,7 +233,7 @@ function buildModuleCard(mod) {
|
||||
toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
||||
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);
|
||||
|
||||
const desc = document.createElement('div');
|
||||
@@ -199,11 +248,13 @@ function buildModuleCard(mod) {
|
||||
|
||||
const mountError = document.createElement('div');
|
||||
mountError.className = 'module-mount-error';
|
||||
mountError.hidden = !mod.mountError;
|
||||
mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
||||
mountError.hidden = !mod.mountError && !mod.incompatible;
|
||||
mountError.textContent = mod.incompatible
|
||||
? `Not loaded — ${mod.incompatible}. Update ProdDash from the banner above.`
|
||||
: (mod.mountError ? `Failed to start: ${mod.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 () => {
|
||||
try {
|
||||
@@ -289,8 +340,10 @@ async function refreshStatus() {
|
||||
els.dot.className = 'dot ' + healthClass(mod);
|
||||
els.healthEl.textContent = healthText(mod);
|
||||
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
||||
if (!mod.incompatible) {
|
||||
els.mountError.hidden = !mod.mountError;
|
||||
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
||||
}
|
||||
if (document.activeElement !== els.toggle) {
|
||||
els.toggle.checked = mod.enabled;
|
||||
els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
||||
@@ -382,6 +435,9 @@ async function loadState() {
|
||||
}
|
||||
loginCard.hidden = true;
|
||||
modulesSection.hidden = false;
|
||||
availableSection.hidden = false;
|
||||
shellVersion = String(state.version || '');
|
||||
shellVersionEl.textContent = shellVersion ? 'v' + shellVersion : '';
|
||||
const storageNote = document.getElementById('storage-note');
|
||||
if (state.dataDir) {
|
||||
document.getElementById('storage-path').textContent = state.dataDir;
|
||||
@@ -393,8 +449,195 @@ async function loadState() {
|
||||
modulesList.appendChild(buildModuleCard(mod));
|
||||
}
|
||||
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(() => {
|
||||
if (modulesSection.hidden) return;
|
||||
refreshStatus();
|
||||
|
||||
+25
-1
@@ -11,7 +11,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<a class="tb-btn" href="/">← Dashboard</a>
|
||||
</header>
|
||||
@@ -27,6 +27,18 @@
|
||||
<div class="admin-status" id="login-status"></div>
|
||||
</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>
|
||||
<h1 class="admin-h1">Modules</h1>
|
||||
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
|
||||
@@ -37,6 +49,18 @@
|
||||
<div id="modules-list"></div>
|
||||
</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>
|
||||
<h1 class="admin-h1">Named layouts</h1>
|
||||
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
|
||||
|
||||
@@ -22,10 +22,12 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const zlib = require('zlib');
|
||||
const { spawn, execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = __dirname;
|
||||
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. */
|
||||
const REPO_CONFIG_DIR = path.join(ROOT, 'config');
|
||||
|
||||
@@ -73,6 +75,7 @@ function resolveDataDir() {
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const MODULES_CONFIG_PATH = path.join(DATA_DIR, 'modules.json');
|
||||
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 ───────────────────────────────────────────────────── */
|
||||
|
||||
@@ -139,6 +142,59 @@ const shellConfig = {
|
||||
};
|
||||
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: {…} } } */
|
||||
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) */
|
||||
const manifests = new Map();
|
||||
|
||||
function discoverModules() {
|
||||
manifests.clear();
|
||||
/** Read every valid manifest in one modules folder. */
|
||||
function readManifests(baseDir, source) {
|
||||
const out = [];
|
||||
let dirs = [];
|
||||
try {
|
||||
dirs = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
dirs = fs.readdirSync(baseDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
||||
.map((d) => d.name);
|
||||
} catch {
|
||||
return; // no modules/ folder yet — the shell still runs
|
||||
return out; // folder missing — fine
|
||||
}
|
||||
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);
|
||||
if (!man || typeof man !== 'object') {
|
||||
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`);
|
||||
continue;
|
||||
}
|
||||
man.dir = path.join(MODULES_DIR, dir);
|
||||
manifests.set(dir, man);
|
||||
man.dir = path.join(baseDir, dir);
|
||||
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) {
|
||||
@@ -187,6 +283,12 @@ function isEnabled(id) {
|
||||
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. */
|
||||
function effectiveConfig(id) {
|
||||
const man = manifests.get(id);
|
||||
@@ -260,6 +362,10 @@ function mountModule(id) {
|
||||
if (!man) return;
|
||||
const entry = { routes: [], handle: null, error: '' };
|
||||
mounted.set(id, entry);
|
||||
if (man.incompatible) {
|
||||
entry.error = man.incompatible;
|
||||
return;
|
||||
}
|
||||
if (!man.server) return;
|
||||
const log = makeLog(id);
|
||||
try {
|
||||
@@ -434,6 +540,385 @@ setInterval(() => {
|
||||
}
|
||||
}, 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 ──────────────────────────────────────────────────────── */
|
||||
|
||||
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
||||
@@ -483,6 +968,9 @@ function adminModuleView(id) {
|
||||
version: String(man.version || ''),
|
||||
description: String(man.description || ''),
|
||||
hasServer: Boolean(man.server),
|
||||
source: man.source || 'bundled',
|
||||
requires: man.requires || '',
|
||||
incompatible: man.incompatible || '',
|
||||
enabled: isEnabled(id),
|
||||
configSchema: man.configSchema || {},
|
||||
config: values,
|
||||
@@ -546,11 +1034,51 @@ async function handleAdminApi(req, res, urlPath) {
|
||||
return sendJson(res, 200, {
|
||||
authRequired: Boolean(ADMIN_PASSCODE),
|
||||
authed: true,
|
||||
version: SHELL_VERSION,
|
||||
dataDir: DATA_DIR,
|
||||
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)$/);
|
||||
if (m && req.method === 'PUT') {
|
||||
const id = decodeURIComponent(m[1]);
|
||||
@@ -710,6 +1238,7 @@ async function handleLayoutsApi(req, res, urlPath) {
|
||||
function dispatchModuleApi(req, res, id, subPath, search) {
|
||||
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 (manifests.get(id).incompatible) return sendJson(res, 409, { error: manifests.get(id).incompatible });
|
||||
const entry = mounted.get(id);
|
||||
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}` });
|
||||
@@ -812,7 +1341,7 @@ function handleRequest(req, res) {
|
||||
|
||||
/* — shell API — */
|
||||
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 }))
|
||||
.catch((err) => {
|
||||
console.error('[proddash] /api/modules failed:', err);
|
||||
@@ -879,7 +1408,7 @@ function handleRequest(req, res) {
|
||||
if (assetMatch) {
|
||||
const id = decodeURIComponent(assetMatch[1]);
|
||||
const man = manifests.get(id);
|
||||
if (!man || !isEnabled(id)) {
|
||||
if (!man || !isActive(id)) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
return res.end('Not found');
|
||||
}
|
||||
@@ -910,8 +1439,8 @@ server.requestTimeout = 0;
|
||||
discoverModules();
|
||||
mountAllModules();
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log('ProdDash running');
|
||||
server.on('listening', () => {
|
||||
console.log('ProdDash ' + SHELL_VERSION);
|
||||
console.log(' Local : http://localhost:' + PORT);
|
||||
for (const ifaces of Object.values(os.networkInterfaces())) {
|
||||
for (const iface of ifaces || []) {
|
||||
@@ -922,11 +1451,34 @@ server.listen(PORT, '0.0.0.0', () => {
|
||||
}
|
||||
const ids = [...manifests.keys()];
|
||||
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');
|
||||
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']) {
|
||||
process.on(signal, () => {
|
||||
for (const id of [...mounted.keys()]) unmountModule(id);
|
||||
|
||||
Reference in New Issue
Block a user