Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7690ae1fad | ||
|
|
1c8cd63cd0 | ||
|
|
af6322d079 | ||
|
|
168f717398 | ||
|
|
67bcd3640a | ||
|
|
6dabd9d8a1 | ||
|
|
095fe66118 | ||
|
|
a3e1282354 | ||
|
|
6417c3aa54 | ||
|
|
c1d9fa0527 |
@@ -44,7 +44,7 @@ title-bar controls). The rules that matter most:
|
|||||||
- `prodcom-listener/` — live ProdCom transcript web app. Zero-dependency Node
|
- `prodcom-listener/` — live ProdCom transcript web app. Zero-dependency Node
|
||||||
`server.js` that serves `public/` and proxies `/prodcom/*` (including the SSE
|
`server.js` that serves `public/` and proxies `/prodcom/*` (including the SSE
|
||||||
transcript stream) to the ProdCom API at `config.json → prodcomUrl`
|
transcript stream) to the ProdCom API at `config.json → prodcomUrl`
|
||||||
(default `http://10.3.11.152:24480`). Good reference for: SSE handling,
|
(set per site; each campus runs its own). Good reference for: SSE handling,
|
||||||
reconnect logic, channel colors/filtering, PWA install.
|
reconnect logic, channel colors/filtering, PWA install.
|
||||||
- `band-lineup-display/` — backstage Electron app (stage lineup photos with
|
- `band-lineup-display/` — backstage Electron app (stage lineup photos with
|
||||||
mic/pack labels) plus ProPresenter Now/Next:
|
mic/pack labels) plus ProPresenter Now/Next:
|
||||||
|
|||||||
@@ -99,11 +99,34 @@ with this machine's shell settings — the same keys as the checked-in
|
|||||||
| Key | Meaning |
|
| Key | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
|
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
|
||||||
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
|
| `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. |
|
||||||
|
| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. |
|
||||||
|
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
|
||||||
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||||
|
|
||||||
|
**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
|
||||||
|
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
|
||||||
|
overrides the PIN and always gets you in.
|
||||||
|
|
||||||
|
Only `/admin` and the admin API are behind the PIN. Dashboards stay open, so a
|
||||||
|
wall display needs no PIN, and it is unrelated to a module's own PIN (Slack's
|
||||||
|
4–8 digit send PIN, say), which guards that module's actions only.
|
||||||
|
|
||||||
|
**Updating from your own git server.** ProdDash fetches one `tar.gz` of the
|
||||||
|
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
|
||||||
|
base URL and `repo` at the owner/repo there:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "forge": "http://10.3.8.90:3000", "repo": "michael/ProdDash", "branch": "main" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitea spells an archive URL differently from GitHub, which is the only thing
|
||||||
|
that changes; the tarball itself is identical, commit hash included, so update
|
||||||
|
detection and module installs behave exactly as before. Make the repository
|
||||||
|
readable without signing in, or ProdDash cannot fetch it.
|
||||||
|
|
||||||
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
||||||
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
|
`PRODDASH_FORGE`, and `PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
|
||||||
is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
|
is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
|
||||||
in-repo location). If the default directory can't be created, the server says
|
in-repo location). If the default directory can't be created, the server says
|
||||||
so and falls back to the app's `config/` folder.
|
so and falls back to the app's `config/` folder.
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ modules/<id>/
|
|||||||
"minSize": { "w": 2, "h": 2 },
|
"minSize": { "w": 2, "h": 2 },
|
||||||
"defaultSize": { "w": 4, "h": 3 },
|
"defaultSize": { "w": 4, "h": 3 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "http://10.3.11.152:24480" },
|
"prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "" },
|
||||||
"apiKey": { "type": "password", "label": "API key (if PSK auth enabled)", "default": "" }
|
"apiKey": { "type": "password", "label": "API key (if PSK auth enabled)", "default": "" }
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"port": 24500,
|
"port": 24500,
|
||||||
"adminPasscode": "",
|
"adminPasscode": "",
|
||||||
|
"forge": "github",
|
||||||
"repo": "michaelmcgary2008/ProdDash",
|
"repo": "michaelmcgary2008/ProdDash",
|
||||||
"branch": "main"
|
"branch": "main"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript",
|
"description": "Live ProdCom transcript",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -26,9 +26,10 @@
|
|||||||
"type": "endpoint",
|
"type": "endpoint",
|
||||||
"label": "ProdCom server",
|
"label": "ProdCom server",
|
||||||
"default": {
|
"default": {
|
||||||
"host": "10.3.11.152",
|
"host": "",
|
||||||
"port": 24480
|
"port": 24480
|
||||||
}
|
},
|
||||||
|
"help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus's comms."
|
||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ module.exports = {
|
|||||||
streams,
|
streams,
|
||||||
health() {
|
health() {
|
||||||
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
|
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
|
||||||
|
// Nothing to connect to is a setting that is missing, not a link that
|
||||||
|
// is coming up — it reads red, like the other modules say it.
|
||||||
|
if (!target) return { status: 'error', message: status.message };
|
||||||
return { status: status.state === 'error' ? 'error' : 'connecting', message: status.message };
|
return { status: status.state === 'error' ? 'error' : 'connecting', message: status.message };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Edit [config.json](config.json):
|
|||||||
|
|
||||||
| Key | Meaning |
|
| Key | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `prodcomUrl` | Where the ProdCom API lives (default `http://10.3.11.152:24480`) |
|
| `prodcomUrl` | Where the ProdCom API lives — set it per site, e.g. `http://<prodcom-host>:24480` |
|
||||||
| `port` | Port this listener serves on (default `24481`) |
|
| `port` | Port this listener serves on (default `24481`) |
|
||||||
| `apiKey` | Only needed if PSK auth is enabled in ProdCom settings |
|
| `apiKey` | Only needed if PSK auth is enabled in ProdCom settings |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"prodcomUrl": "http://10.3.11.152:24480",
|
"prodcomUrl": "",
|
||||||
"port": 24481,
|
"port": 24481,
|
||||||
"apiKey": ""
|
"apiKey": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,15 @@ try {
|
|||||||
console.warn('config.json not found or invalid, using defaults/env vars');
|
console.warn('config.json not found or invalid, using defaults/env vars');
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || 'http://10.3.11.152:24480';
|
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || '';
|
||||||
const PORT = Number(process.env.PORT || cfg.port || 24481);
|
const PORT = Number(process.env.PORT || cfg.port || 24481);
|
||||||
const API_KEY = process.env.PRODCOM_API_KEY || cfg.apiKey || '';
|
const API_KEY = process.env.PRODCOM_API_KEY || cfg.apiKey || '';
|
||||||
|
|
||||||
|
if (!PRODCOM_URL) {
|
||||||
|
console.error('No ProdCom API configured. Set prodcomUrl in config.json (or the PRODCOM_URL');
|
||||||
|
console.error('environment variable) to this site\'s ProdCom, e.g. http://<prodcom-host>:24480');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
const target = new URL(PRODCOM_URL);
|
const target = new URL(PRODCOM_URL);
|
||||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||||
|
|
||||||
|
|||||||
@@ -512,3 +512,49 @@ a.btn { text-decoration: none; }
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
#theme-status { margin-top: 4px; }
|
#theme-status { margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ── the admin PIN ── */
|
||||||
|
|
||||||
|
/* Digits, evenly spaced, so six of them read as a PIN rather than a word. */
|
||||||
|
.pin-input {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.35em;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 9em;
|
||||||
|
}
|
||||||
|
.pin-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.pin-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.pin-field[hidden] { display: none; }
|
||||||
|
.pin-field input {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.pin-field input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.pin-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.pin-note { margin-top: 12px; }
|
||||||
|
.pin-note code {
|
||||||
|
font-size: 0.92em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|||||||
+92
-2
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
Everything here is server-wide: module enable/disable and admin config
|
Everything here is server-wide: module enable/disable and admin config
|
||||||
(written to config/modules.json, module re-inited on save), plus named
|
(written to config/modules.json, module re-inited on save), plus named
|
||||||
layout management. Guarded by the shared passcode when one is set. */
|
layout management. Behind the admin PIN when one is set — a shell setting of
|
||||||
|
its own, nothing to do with a module's PIN (Slack's send PIN, say). */
|
||||||
|
|
||||||
|
const securitySection = document.getElementById('security-section');
|
||||||
const loginCard = document.getElementById('login-card');
|
const loginCard = document.getElementById('login-card');
|
||||||
const loginStatus = document.getElementById('login-status');
|
const loginStatus = document.getElementById('login-status');
|
||||||
const passcodeInput = document.getElementById('passcode');
|
const passcodeInput = document.getElementById('passcode');
|
||||||
@@ -62,20 +64,30 @@ function showLogin() {
|
|||||||
layoutsSection.hidden = true;
|
layoutsSection.hidden = true;
|
||||||
updateCard.hidden = true;
|
updateCard.hidden = true;
|
||||||
themeSection.hidden = true;
|
themeSection.hidden = true;
|
||||||
|
securitySection.hidden = true;
|
||||||
|
passcodeInput.value = '';
|
||||||
passcodeInput.focus();
|
passcodeInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let loggingIn = false;
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
|
if (loggingIn) return;
|
||||||
|
loggingIn = true;
|
||||||
loginStatus.textContent = '';
|
loginStatus.textContent = '';
|
||||||
loginStatus.className = 'admin-status';
|
loginStatus.className = 'admin-status';
|
||||||
try {
|
try {
|
||||||
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: passcodeInput.value }) });
|
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ pin: passcodeInput.value }) });
|
||||||
passcodeInput.value = '';
|
passcodeInput.value = '';
|
||||||
loginCard.hidden = true;
|
loginCard.hidden = true;
|
||||||
await loadState();
|
await loadState();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
passcodeInput.value = '';
|
||||||
loginStatus.textContent = e.message;
|
loginStatus.textContent = e.message;
|
||||||
loginStatus.className = 'admin-status error';
|
loginStatus.className = 'admin-status error';
|
||||||
|
passcodeInput.focus();
|
||||||
|
} finally {
|
||||||
|
loggingIn = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +95,83 @@ document.getElementById('login-btn').addEventListener('click', login);
|
|||||||
passcodeInput.addEventListener('keydown', (e) => {
|
passcodeInput.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') login();
|
if (e.key === 'Enter') login();
|
||||||
});
|
});
|
||||||
|
// Six digits is the whole PIN, so there is nothing left to press.
|
||||||
|
passcodeInput.addEventListener('input', () => {
|
||||||
|
passcodeInput.value = passcodeInput.value.replace(/\D/g, '').slice(0, 6);
|
||||||
|
if (passcodeInput.value.length === 6) login();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── the admin PIN ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const pinCurrentField = document.getElementById('pin-current-field');
|
||||||
|
const pinCurrent = document.getElementById('pin-current');
|
||||||
|
const pinNew = document.getElementById('pin-new');
|
||||||
|
const pinRepeat = document.getElementById('pin-repeat');
|
||||||
|
const pinSaveBtn = document.getElementById('pin-save');
|
||||||
|
const pinClearBtn = document.getElementById('pin-clear');
|
||||||
|
const pinStatus = document.getElementById('pin-status');
|
||||||
|
const pinStateEl = document.getElementById('pin-state');
|
||||||
|
|
||||||
|
for (const input of [pinCurrent, pinNew, pinRepeat]) {
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
input.value = input.value.replace(/\D/g, '').slice(0, 6);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint the section for "no PIN yet" or "one is set". */
|
||||||
|
function renderPin(state) {
|
||||||
|
const set = Boolean(state.pinSet);
|
||||||
|
const fromEnv = Boolean(state.pinFromEnv);
|
||||||
|
securitySection.hidden = false;
|
||||||
|
pinCurrentField.hidden = !set;
|
||||||
|
pinClearBtn.hidden = !set;
|
||||||
|
document.getElementById('pin-new-label').textContent = set ? 'New PIN' : 'PIN';
|
||||||
|
pinSaveBtn.textContent = set ? 'Change PIN' : 'Set PIN';
|
||||||
|
pinStateEl.textContent = fromEnv
|
||||||
|
? 'A passcode is set in this server’s environment (PRODDASH_PASSCODE) and takes precedence. Anything set here applies once that is removed.'
|
||||||
|
: set
|
||||||
|
? 'Admin is locked. This page asks for the PIN, and so does every admin request.'
|
||||||
|
: 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.';
|
||||||
|
if (state.dataDir) document.getElementById('pin-file').textContent = state.dataDir + '/proddash.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pinMessage(text, bad = false) {
|
||||||
|
pinStatus.textContent = text;
|
||||||
|
pinStatus.className = 'admin-status' + (bad ? ' error' : ' ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePin(clearing = false) {
|
||||||
|
pinStatus.textContent = '';
|
||||||
|
pinStatus.className = 'admin-status';
|
||||||
|
const current = pinCurrent.value;
|
||||||
|
const next = clearing ? '' : pinNew.value;
|
||||||
|
if (!clearing) {
|
||||||
|
if (!/^\d{6}$/.test(next)) return pinMessage('The PIN must be exactly 6 digits.', true);
|
||||||
|
if (next !== pinRepeat.value) return pinMessage('The two PINs are not the same.', true);
|
||||||
|
}
|
||||||
|
if (!pinCurrentField.hidden && !/^\d{6}$/.test(current)) {
|
||||||
|
return pinMessage('Enter the current PIN to change it.', true);
|
||||||
|
}
|
||||||
|
pinSaveBtn.disabled = true;
|
||||||
|
pinClearBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const body = await api('/api/admin/pin', { method: 'POST', body: JSON.stringify({ pin: next, current }) });
|
||||||
|
for (const i of [pinCurrent, pinNew, pinRepeat]) i.value = '';
|
||||||
|
pinMessage(body.pinSet ? 'PIN saved. It is asked for from the next visit.' : 'PIN removed — admin is open again.');
|
||||||
|
await loadState();
|
||||||
|
} catch (e) {
|
||||||
|
pinMessage(e.message, true);
|
||||||
|
} finally {
|
||||||
|
pinSaveBtn.disabled = false;
|
||||||
|
pinClearBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pinSaveBtn.addEventListener('click', () => savePin(false));
|
||||||
|
pinClearBtn.addEventListener('click', () => {
|
||||||
|
if (confirm('Turn the admin PIN off? Anyone on the network could then change this server’s settings.')) savePin(true);
|
||||||
|
});
|
||||||
|
pinRepeat.addEventListener('keydown', (e) => { if (e.key === 'Enter') savePin(false); });
|
||||||
|
|
||||||
/* ── module cards ───────────────────────────────────────────────────── */
|
/* ── module cards ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -856,6 +945,7 @@ async function loadState() {
|
|||||||
for (const mod of state.modules || []) {
|
for (const mod of state.modules || []) {
|
||||||
modulesList.appendChild(buildModuleCard(mod));
|
modulesList.appendChild(buildModuleCard(mod));
|
||||||
}
|
}
|
||||||
|
renderPin(state);
|
||||||
loadTheme();
|
loadTheme();
|
||||||
loadLayouts();
|
loadLayouts();
|
||||||
loadCatalog(false);
|
loadCatalog(false);
|
||||||
|
|||||||
+36
-2
@@ -18,10 +18,11 @@
|
|||||||
|
|
||||||
<main id="admin-main">
|
<main id="admin-main">
|
||||||
<section class="admin-card" id="login-card" hidden>
|
<section class="admin-card" id="login-card" hidden>
|
||||||
<h2>Admin passcode</h2>
|
<h2>Admin PIN</h2>
|
||||||
<p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p>
|
<p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p>
|
||||||
<div class="login-row">
|
<div class="login-row">
|
||||||
<input type="password" id="passcode" placeholder="Passcode" autocomplete="current-password">
|
<input type="password" id="passcode" class="pin-input" inputmode="numeric" autocomplete="off"
|
||||||
|
maxlength="6" placeholder="••••••" aria-label="Admin PIN">
|
||||||
<button class="btn primary" id="login-btn">Unlock</button>
|
<button class="btn primary" id="login-btn">Unlock</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-status" id="login-status"></div>
|
<div class="admin-status" id="login-status"></div>
|
||||||
@@ -65,6 +66,39 @@
|
|||||||
<h1 class="admin-h1">Saved layouts</h1>
|
<h1 class="admin-h1">Saved layouts</h1>
|
||||||
<div id="layouts-list"></div>
|
<div id="layouts-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="security-section" hidden>
|
||||||
|
<h1 class="admin-h1">Admin PIN</h1>
|
||||||
|
<p class="admin-copy" id="pin-state"></p>
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="pin-form">
|
||||||
|
<label class="pin-field" id="pin-current-field" hidden>
|
||||||
|
<span>Current PIN</span>
|
||||||
|
<input type="password" id="pin-current" class="pin-input" inputmode="numeric"
|
||||||
|
autocomplete="off" maxlength="6" placeholder="••••••">
|
||||||
|
</label>
|
||||||
|
<label class="pin-field">
|
||||||
|
<span id="pin-new-label">New PIN</span>
|
||||||
|
<input type="password" id="pin-new" class="pin-input" inputmode="numeric"
|
||||||
|
autocomplete="off" maxlength="6" placeholder="6 digits">
|
||||||
|
</label>
|
||||||
|
<label class="pin-field">
|
||||||
|
<span>Repeat</span>
|
||||||
|
<input type="password" id="pin-repeat" class="pin-input" inputmode="numeric"
|
||||||
|
autocomplete="off" maxlength="6" placeholder="6 digits">
|
||||||
|
</label>
|
||||||
|
<div class="pin-actions">
|
||||||
|
<button class="btn primary" id="pin-save">Set PIN</button>
|
||||||
|
<button class="btn" id="pin-clear" hidden>Turn off</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="admin-copy pin-note">Only this page and the admin API ask for it — dashboards stay
|
||||||
|
open, and it is nothing to do with a module's own PIN, such as Slack's send PIN. Forgotten?
|
||||||
|
Remove <code>adminPin</code> from <code id="pin-file">proddash.json</code> in the settings
|
||||||
|
folder and restart.</p>
|
||||||
|
<div class="admin-status" id="pin-status"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="toast" hidden></div>
|
<div id="toast" hidden></div>
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
* Configure via config/proddash.json (defaults, in the repo), an optional
|
* Configure via config/proddash.json (defaults, in the repo), an optional
|
||||||
* proddash.json in the data directory (this machine's overrides), or env vars:
|
* proddash.json in the data directory (this machine's overrides), or env vars:
|
||||||
* PORT (default: value in proddash.json, else 24500)
|
* PORT (default: value in proddash.json, else 24500)
|
||||||
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
* PRODDASH_PASSCODE (a passcode that overrides the stored admin PIN —
|
||||||
|
* also the way back in if the PIN is ever forgotten)
|
||||||
|
* PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
|
||||||
|
* URL such as http://10.3.8.90:3000)
|
||||||
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -765,14 +768,52 @@ function parseRepoSetting(value, fallbackBranch) {
|
|||||||
|
|
||||||
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|
||||||
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main');
|
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which server the code comes from. GitHub unless `forge` names another —
|
||||||
|
* give it a Gitea (or Forgejo) base URL, "http://10.3.8.90:3000", and updates
|
||||||
|
* and module installs come from there instead. The two hosts differ only in
|
||||||
|
* how an archive URL is spelled: the tarball itself is the same, pax header
|
||||||
|
* and commit sha included, so everything downstream is untouched.
|
||||||
|
*/
|
||||||
|
function parseForge(value) {
|
||||||
|
const s = String(value || '').trim().replace(/\/+$/, '');
|
||||||
|
if (!s || s.toLowerCase() === 'github') {
|
||||||
|
return { kind: 'github', base: 'https://github.com', archiveBase: 'https://codeload.github.com' };
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//i.test(s)) return null;
|
||||||
|
// A GitHub URL still means codeload for archives.
|
||||||
|
if (/^https?:\/\/(www\.)?github\.com$/i.test(s)) {
|
||||||
|
return { kind: 'github', base: s, archiveBase: 'https://codeload.github.com' };
|
||||||
|
}
|
||||||
|
return { kind: 'gitea', base: s, archiveBase: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORGE = parseForge(process.env.PRODDASH_FORGE || shellConfig.forge) || parseForge('github');
|
||||||
/** Where branch archives come from; tests point this at a local mock. */
|
/** 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 ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || FORGE.archiveBase).replace(/\/+$/, '');
|
||||||
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
||||||
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
|
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
|
||||||
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
|
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
|
||||||
|
|
||||||
function tarballUrl() {
|
function tarballUrl() {
|
||||||
return `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${encodeURIComponent(REPO.branch)}`;
|
const branch = encodeURIComponent(REPO.branch);
|
||||||
|
return FORGE.kind === 'gitea'
|
||||||
|
? `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/archive/${branch}.tar.gz`
|
||||||
|
: `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${branch}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page a human should read to see what changed between two commits. */
|
||||||
|
function forgeCompareUrl(from, to) {
|
||||||
|
return `${FORGE.base}/${REPO.owner}/${REPO.repo}/compare/${from}...${to}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The commit list for the branch we follow. */
|
||||||
|
function forgeCommitsUrl() {
|
||||||
|
const branch = encodeURIComponent(REPO.branch);
|
||||||
|
return FORGE.kind === 'gitea'
|
||||||
|
? `${FORGE.base}/${REPO.owner}/${REPO.repo}/commits/branch/${branch}`
|
||||||
|
: `${FORGE.base}/${REPO.owner}/${REPO.repo}/commits/${branch}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function httpError(status, message) {
|
function httpError(status, message) {
|
||||||
@@ -929,11 +970,11 @@ function shellStatus() {
|
|||||||
else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`;
|
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';
|
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 {
|
return {
|
||||||
version: SHELL_VERSION,
|
version: SHELL_VERSION,
|
||||||
repo: `${REPO.owner}/${REPO.repo}`,
|
repo: `${REPO.owner}/${REPO.repo}`,
|
||||||
branch: REPO.branch,
|
branch: REPO.branch,
|
||||||
|
forge: FORGE.base,
|
||||||
local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty },
|
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 },
|
remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error },
|
||||||
updateAvailable: versionBehind || commitsBehind,
|
updateAvailable: versionBehind || commitsBehind,
|
||||||
@@ -941,8 +982,8 @@ function shellStatus() {
|
|||||||
method: local.isGit ? 'git' : 'archive',
|
method: local.isGit ? 'git' : 'archive',
|
||||||
updateBlocker: blocker,
|
updateBlocker: blocker,
|
||||||
changesUrl: local.sha && remote.sha
|
changesUrl: local.sha && remote.sha
|
||||||
? `${gh}/compare/${local.sha.slice(0, 12)}...${remote.sha.slice(0, 12)}`
|
? forgeCompareUrl(local.sha.slice(0, 12), remote.sha.slice(0, 12))
|
||||||
: `${gh}/commits/${REPO.branch}`,
|
: forgeCommitsUrl(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1130,12 +1171,109 @@ function restartServer(reason) {
|
|||||||
|
|
||||||
/* ── admin API ──────────────────────────────────────────────────────── */
|
/* ── admin API ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Admin is behind a 6-digit PIN, set in the admin page and kept in this
|
||||||
|
machine's proddash.json as a scrypt hash — never in the clear, because that
|
||||||
|
file gets copied between machines. A dashboard needs no PIN; only /admin
|
||||||
|
and the admin API do. Two ways back in if it is ever forgotten: set
|
||||||
|
PRODDASH_PASSCODE in the environment, or delete "adminPin" from
|
||||||
|
proddash.json in the data directory and restart. `adminPasscode` (the older
|
||||||
|
free-text setting) still works so an existing install keeps running. */
|
||||||
|
const PIN_RE = /^\d{6}$/;
|
||||||
|
const PIN_MAX_WRONG = 5;
|
||||||
|
const PIN_LOCKOUT_MS = 30000;
|
||||||
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
||||||
/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */
|
/** Session token: regenerating it every boot is fine — admins just re-enter the PIN. */
|
||||||
const adminToken = crypto.randomBytes(24).toString('hex');
|
const adminToken = crypto.randomBytes(24).toString('hex');
|
||||||
|
/** The stored PIN, as "scrypt$<salt>$<hash>"; '' when admin is open. */
|
||||||
|
let adminPinHash = String(shellConfig.adminPin || '');
|
||||||
|
const pinAttempts = new Map(); // ip → { count, lockedUntil }
|
||||||
|
|
||||||
|
function hashPin(pin, salt = crypto.randomBytes(16).toString('hex')) {
|
||||||
|
return `scrypt$${salt}$${crypto.scryptSync(String(pin), salt, 32).toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time check of a PIN against the stored hash. */
|
||||||
|
function pinMatches(given, stored) {
|
||||||
|
const parts = String(stored || '').split('$');
|
||||||
|
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
|
||||||
|
const expected = Buffer.from(parts[2], 'hex');
|
||||||
|
const actual = crypto.scryptSync(String(given), parts[1], expected.length);
|
||||||
|
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is a credential required at all? */
|
||||||
|
function adminLocked() {
|
||||||
|
return Boolean(ADMIN_PASSCODE || adminPinHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `given` is the credential in force (the env passcode, or the PIN). */
|
||||||
|
function credentialOk(given) {
|
||||||
|
const s = String(given ?? '');
|
||||||
|
if (!s) return false;
|
||||||
|
if (ADMIN_PASSCODE) {
|
||||||
|
const a = Buffer.from(s);
|
||||||
|
const b = Buffer.from(ADMIN_PASSCODE);
|
||||||
|
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
|
||||||
|
}
|
||||||
|
return adminPinHash ? pinMatches(s, adminPinHash) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientIp(req) {
|
||||||
|
return String(req.socket?.remoteAddress || 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds of lockout left for this address, or 0. */
|
||||||
|
function lockoutLeft(ip) {
|
||||||
|
const a = pinAttempts.get(ip);
|
||||||
|
if (!a || !a.lockedUntil) return 0;
|
||||||
|
const left = a.lockedUntil - Date.now();
|
||||||
|
if (left <= 0) {
|
||||||
|
pinAttempts.delete(ip);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.ceil(left / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteWrongPin(ip) {
|
||||||
|
const a = pinAttempts.get(ip) || { count: 0, lockedUntil: 0 };
|
||||||
|
a.count += 1;
|
||||||
|
if (a.count >= PIN_MAX_WRONG) {
|
||||||
|
a.count = 0;
|
||||||
|
a.lockedUntil = Date.now() + PIN_LOCKOUT_MS;
|
||||||
|
console.warn(`[proddash] ${PIN_MAX_WRONG} wrong admin PINs from ${ip} — locked out for ${PIN_LOCKOUT_MS / 1000} s`);
|
||||||
|
}
|
||||||
|
pinAttempts.set(ip, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new PIN (6 digits), or '' to open admin up again. Keeps every other
|
||||||
|
* key in this machine's proddash.json. Throws an httpError the route reports.
|
||||||
|
*/
|
||||||
|
function setAdminPin(pin) {
|
||||||
|
const next = String(pin ?? '');
|
||||||
|
if (next && !PIN_RE.test(next)) throw httpError(400, 'The PIN must be exactly 6 digits.');
|
||||||
|
const file = path.join(DATA_DIR, 'proddash.json');
|
||||||
|
let existing = {};
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
existing = readJson(file, null);
|
||||||
|
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
||||||
|
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the PIN.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hash = next ? hashPin(next) : '';
|
||||||
|
const out = { ...existing };
|
||||||
|
if (hash) out.adminPin = hash;
|
||||||
|
else delete out.adminPin;
|
||||||
|
writeJson(file, out);
|
||||||
|
adminPinHash = hash;
|
||||||
|
shellConfig.adminPin = hash;
|
||||||
|
pinAttempts.clear();
|
||||||
|
console.log(`[proddash] admin PIN ${hash ? 'set' : 'removed — admin is open again'}`);
|
||||||
|
return { pinSet: Boolean(hash) };
|
||||||
|
}
|
||||||
|
|
||||||
function isAuthed(req) {
|
function isAuthed(req) {
|
||||||
if (!ADMIN_PASSCODE) return true;
|
if (!adminLocked()) return true;
|
||||||
const cookies = String(req.headers.cookie || '');
|
const cookies = String(req.headers.cookie || '');
|
||||||
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
|
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
|
||||||
}
|
}
|
||||||
@@ -1143,7 +1281,7 @@ function isAuthed(req) {
|
|||||||
/** Guard a state-changing admin route. Returns true when the request was refused. */
|
/** Guard a state-changing admin route. Returns true when the request was refused. */
|
||||||
function refuseAdminWrite(req, res) {
|
function refuseAdminWrite(req, res) {
|
||||||
if (!isAuthed(req)) {
|
if (!isAuthed(req)) {
|
||||||
sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
|
sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Requiring application/json forces a CORS preflight that a hostile page
|
// Requiring application/json forces a CORS preflight that a hostile page
|
||||||
@@ -1228,10 +1366,19 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
} catch {
|
} catch {
|
||||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||||
}
|
}
|
||||||
if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false });
|
if (!adminLocked()) return sendJson(res, 200, { ok: true, authRequired: false });
|
||||||
if (String(body.passcode || '') !== ADMIN_PASSCODE) {
|
const ip = clientIp(req);
|
||||||
return sendJson(res, 403, { error: 'Wrong passcode.' });
|
const left = lockoutLeft(ip);
|
||||||
|
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
|
||||||
|
// `passcode` is what the older admin page sent; `pin` is what this one does.
|
||||||
|
if (!credentialOk(body.pin ?? body.passcode)) {
|
||||||
|
noteWrongPin(ip);
|
||||||
|
const now = lockoutLeft(ip);
|
||||||
|
return sendJson(res, 403, now
|
||||||
|
? { error: `Too many wrong PINs — try again in ${now} s.`, retryAfter: now }
|
||||||
|
: { error: 'Wrong PIN.' });
|
||||||
}
|
}
|
||||||
|
pinAttempts.delete(ip);
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
|
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
|
||||||
@@ -1240,12 +1387,50 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
return res.end(JSON.stringify({ ok: true }));
|
return res.end(JSON.stringify({ ok: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (urlPath === '/api/admin/pin' && req.method === 'POST') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = JSON.parse((await readBody(req)) || '{}');
|
||||||
|
} catch {
|
||||||
|
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||||
|
}
|
||||||
|
// Changing a credential re-asserts it, so a browser left open on the
|
||||||
|
// admin page can't be used to take the machine over.
|
||||||
|
if (adminPinHash) {
|
||||||
|
const ip = clientIp(req);
|
||||||
|
const left = lockoutLeft(ip);
|
||||||
|
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
|
||||||
|
if (!pinMatches(String(body.current ?? ''), adminPinHash)) {
|
||||||
|
noteWrongPin(ip);
|
||||||
|
return sendJson(res, 403, { error: 'That is not the current PIN.' });
|
||||||
|
}
|
||||||
|
pinAttempts.delete(ip);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = setAdminPin(body.pin);
|
||||||
|
// Whoever just set it has proved they are allowed, so hand them the
|
||||||
|
// session rather than bouncing them to the unlock card they just built.
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
return res.end(JSON.stringify(result));
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, err.status || 500, { error: err.message || 'Could not save the PIN.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (urlPath === '/api/admin/state' && req.method === 'GET') {
|
if (urlPath === '/api/admin/state' && req.method === 'GET') {
|
||||||
if (!isAuthed(req)) {
|
if (!isAuthed(req)) {
|
||||||
return sendJson(res, 401, { authRequired: true, authed: false });
|
return sendJson(res, 401, { authRequired: true, authed: false });
|
||||||
}
|
}
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
authRequired: Boolean(ADMIN_PASSCODE),
|
authRequired: adminLocked(),
|
||||||
|
// The env passcode can't be changed from here; the page says so.
|
||||||
|
pinSet: Boolean(adminPinHash),
|
||||||
|
pinFromEnv: Boolean(ADMIN_PASSCODE),
|
||||||
authed: true,
|
authed: true,
|
||||||
version: SHELL_VERSION,
|
version: SHELL_VERSION,
|
||||||
dataDir: DATA_DIR,
|
dataDir: DATA_DIR,
|
||||||
@@ -1426,7 +1611,7 @@ async function handleLayoutsApi(req, res, urlPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!m[2] && req.method === 'DELETE') {
|
if (!m[2] && req.method === 'DELETE') {
|
||||||
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
|
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(layoutPath(name));
|
fs.unlinkSync(layoutPath(name));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1693,7 +1878,7 @@ server.on('listening', () => {
|
|||||||
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).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);
|
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch + ' (' + FORGE.base + ')');
|
||||||
});
|
});
|
||||||
|
|
||||||
// A restart hands the port over from the exiting ProdDash: wait for it.
|
// A restart hands the port over from the exiting ProdDash: wait for it.
|
||||||
|
|||||||
Reference in New Issue
Block a user