Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 12f6e22187 An update that goes wrong is no longer the end of it
Two things the audit found, both about the archive path every shipped .app
uses.

**Settings could be wiped.** The update kept config/modules.json and
config/layouts/ by name, but not config/proddash.json — which holds the admin
PIN, the theme and the forge pointer. On an install whose data directory IS
the app's own config/ (an unwritable Application Support, or PRODDASH_DATA_DIR
aimed inside the app), an update wrote the repo's shipped defaults over it:
the theme went, and admin silently unlocked. Nothing inside the data
directory is written now, wherever it happens to sit, and the two legacy
names stay listed for an install whose state never moved out.

**A bad update was unrecoverable.** No rollback, and a fresh .app would not
help: seeding only replaces an *older* copy, and a broken one carries a
higher version — so the fix was deleting a folder nothing told you about.
Three changes, in the order they catch things:

- The download must parse before anything is swapped in. `node --check`
  against the new server.js, and a failure is reported with the syntax error
  and leaves the running copy untouched.
- Past that, the copy being replaced is set aside as `app.previous`, and
  dropped once the new version has stayed up ninety seconds — so that folder
  existing means "the last update has not proven itself yet", and never
  anything older.
- The launcher, which already gives up after four stops in two minutes, now
  looks for that folder before it does and puts the old copy back. Once: if
  the old one will not run either, the update was not the problem.

The launcher half only reaches a machine with a new .app; the server half
travels by the normal update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:16:46 -04:00
michael 5025e12b71 Merge pull request 'Rules for a clean interface, and the modules brought to them' (#9) from claude/module-rules into main 2026-09-17 15:52:59 -04:00
michael 3009b39453 Merge pull request 'Twelve themes, and the Theme section folds away' (#8) from claude/themes into main 2026-09-17 15:52:24 -04:00
michael 41f64f3a13 Merge pull request 'Admin PIN: the fields say enough' (#7) from claude/admin-pin-trim into main 2026-09-17 15:51:34 -04:00
Waters ChurchandClaude Opus 5 f72170b4e1 The folding section reads as a heading first
Theme's title was coming out at the normal weight — <summary> does not
inherit an <h1>'s boldness, so sitting it under .admin-h1 got the size,
colour and spacing but not the type. It now matches the other section
headings exactly.

The caret moved to the right of the words. A marker in front pushes the
heading out of line with every other one on the page; behind it, the section
still reads as "Theme" first and the caret only says there is more.

Written as the pattern rather than as Theme's own styling: any section that
folds is a <details class="admin-fold"> headed by a
<summary class="admin-h1 admin-fold-title">, and the whole row is the target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:22:11 -04:00
Waters ChurchandClaude Opus 5 ea1eac1c28 Twelve themes, and the Theme section folds away
Seven more palettes: Midnight (indigo, violet), Moss (forest floor, lime),
Plum (aubergine, purple), Sepia (warm brown with a cool teal — Ember's
arrangement reversed), Blackout (true black for a dark room, where any glow
off the screen carries), Paper (the warm light one, next to Daylight's cool
white) and Iron, which has no colour of its own: its accent is near-white, so
the only hues on screen are a module's own and the red and amber that mean
something.

Each holds the line the first five set. Text on panel lands between 14.8:1
and 19.8:1, muted between 5.8:1 and 7.4:1, and every accent clears its own
theme's danger red and warning amber by more than Ember already does — the
narrowest of the new ones is Moss at 54 degrees of hue, against Ember's 18.

The Theme section now starts folded, wearing the same disclosure triangle as
a module's config groups. A building picks its palette once and then wants
the rest of the page, which was getting further down the screen with every
swatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:16:47 -04:00
Waters ChurchandClaude Opus 5 ea8be2ac76 Admin PIN: the fields say enough
The paragraph under the PIN fields explained what the PIN covers and how to
clear it by hand — and only someone already past the PIN could read it, so
the recovery note was never where it was needed. Gone, with the dead style
and the element lookup that hung off it.

The live line under the heading stays: it is the only thing that says
whether admin is locked, and the only warning that a passcode in the
server's environment outranks whatever is set here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:05:53 -04:00
9 changed files with 315 additions and 33 deletions
+33 -8
View File
@@ -102,7 +102,7 @@ with this machine's shell settings — the same keys as the checked-in
| `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. | | `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`. | | `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`. | | `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`, `daylight`, `midnight`, `moss`, `plum`, `sepia`, `blackout`, `paper` or `iron`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
**Locked out?** Delete `adminPin` from the data directory's `proddash.json` **Locked out?** Delete `adminPin` from the data directory's `proddash.json`
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
@@ -140,12 +140,26 @@ ignored from then on.
The whole dashboard — every tile, on every browser — draws with one palette, The whole dashboard — every tile, on every browser — draws with one palette,
chosen in the admin page's **Theme** section and switched live: pick a swatch chosen in the admin page's **Theme** section and switched live: pick a swatch
and open dashboards change without a reload. Five presets ship: **Booth** and open dashboards change without a reload. The section starts folded —
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite** a building picks its palette once — and holds twelve presets:
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
and **Daylight**, a light palette for a front-of-house desk in daytime. Each | | |
keeps the status colours apart — the accent is never near the danger red or |---|---|
the warning amber — and muted text readable in a dark booth. The choice is | **Booth** | the default dark, mint accent |
| **Harbor** | deep navy, cyan |
| **Graphite** | neutral charcoal, electric blue |
| **Ember** | warm broadcast black, orange |
| **Daylight** | a cool light palette, for a front-of-house desk in daytime |
| **Midnight** | indigo night, violet |
| **Moss** | forest floor, lime |
| **Plum** | aubergine, purple |
| **Sepia** | warm brown room, cool teal — Ember's arrangement reversed |
| **Blackout** | true black, cyan, for a dark room where any glow carries |
| **Paper** | the warm light one, indigo accent |
| **Iron** | no colour of its own: a near-white accent, so the only hues on screen are a module's own and the red and amber that mean something |
Each keeps the status colours apart — the accent is never near the danger red
or the warning amber — and muted text readable in a dark booth. The choice is
stored as `theme` in the data directory's `proddash.json` (table above), so stored as `theme` in the data directory's `proddash.json` (table above), so
it holds across restarts and updates; a browser remembers the last theme it it holds across restarts and updates; a browser remembers the last theme it
saw and applies it before the server answers, so nothing flashes. Modules saw and applies it before the server answers, so nothing flashes. Modules
@@ -186,7 +200,18 @@ below. Nothing is installed without someone clicking.
no local changes — otherwise the banner says why and you update with git). 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 A plain folder (no `.git`) has the repo archive unpacked over it; files
removed upstream are left behind but harmless. Settings and layouts are removed upstream are left behind but harmless. Settings and layouts are
never touched. **What changed** opens the commit comparison on GitHub. never touched — nothing inside the data directory is written, even on an
install where that directory *is* the app's own `config/`. **What changed**
opens the commit comparison on the forge.
- **If an update goes wrong**: the download has to parse before anything is
swapped in, so a truncated or mangled `server.js` is refused with the
reason and the running copy is left exactly as it was. Past that, the copy
being replaced is set aside as `app.previous` next to the app folder, and
dropped once the new version has stayed up for ninety seconds — so that
folder existing means "the last update has not proven itself yet". The
macOS app watches for a version that will not stay up (four stops in two
minutes) and puts the old one back by itself, once, with a notification.
Running some other way, the way back is a rename: `mv app.previous app`.
- **Restarting**: after an update the server exits with code 75 and starts - **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 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 ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
@@ -103,6 +103,45 @@ enum ProdDash {
} }
} }
/**
Go back to the copy ProdDash set aside before its last update.
A version that will not stay up is the one state this app cannot talk its
way out of: the admin page went down with it, and installing a fresh
`.app` does nothing, because seeding only replaces an *older* copy and a
broken one carries a higher version. So ProdDash writes `app.previous`
before it overwrites itself and drops it again once the new version has
stayed up — meaning that folder exists only while an update is still
unproven. This puts it back.
Returns the version restored, or nil when there is nothing to go back to.
*/
@discardableResult
static func restorePrevious(root: URL, log: (String) -> Void) -> String? {
let fm = FileManager.default
let previous = URL(fileURLWithPath: root.path + ".previous")
guard isRoot(previous) else { return nil }
let version = shellVersion(root: previous)
let named = version.isEmpty ? "(unknown version)" : version
let broken = URL(fileURLWithPath: root.path + ".broken")
do {
try? fm.removeItem(at: broken)
if fm.fileExists(atPath: root.path) { try fm.moveItem(at: root, to: broken) }
try fm.moveItem(at: previous, to: root)
makeWritable(root)
try? fm.removeItem(at: broken)
log("rolled ProdDash back to \(named) — the copy it replaced")
return named
} catch {
log("could not roll back to the previous copy: \(error.localizedDescription)")
// Leave something runnable behind rather than nothing at all.
if !fm.fileExists(atPath: root.path), fm.fileExists(atPath: broken.path) {
try? fm.moveItem(at: broken, to: root)
}
return nil
}
}
/// Files copied out of a bundle can arrive read-only, and this copy is /// Files copied out of a bundle can arrive read-only, and this copy is
/// exactly where ProdDash writes its own updates. /// exactly where ProdDash writes its own updates.
private static func makeWritable(_ root: URL) { private static func makeWritable(_ root: URL) {
+19 -1
View File
@@ -65,6 +65,8 @@ final class ServerController: ObservableObject {
private var restartAfterExit = false private var restartAfterExit = false
private var startedAt: Date? private var startedAt: Date?
private var crashTimes: [Date] = [] private var crashTimes: [Date] = []
/// One roll-back per bad update: a second would just swap copies forever.
private var rolledBack = false
private var pendingRestart: DispatchWorkItem? private var pendingRestart: DispatchWorkItem?
private var healthTimer: Timer? private var healthTimer: Timer?
private var healthMisses = 0 private var healthMisses = 0
@@ -299,7 +301,7 @@ final class ServerController: ObservableObject {
child = nil child = nil
healthy = false healthy = false
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0 let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate if ranFor > 60 { crashTimes.removeAll(); rolledBack = false } // a long healthy run clears the slate
if restartAfterExit { if restartAfterExit {
restartAfterExit = false restartAfterExit = false
@@ -328,6 +330,22 @@ final class ServerController: ObservableObject {
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 } crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
guard settings.restartOnCrash, crashTimes.count < 4 else { guard settings.restartOnCrash, crashTimes.count < 4 else {
// Before giving up: if an update is still unproven, the copy it
// replaced is sitting right there. Going back beats sitting dark,
// and only once — if the old one will not run either, the problem
// is not the update.
if settings.restartOnCrash, !rolledBack, let root = rootURL,
let restored = ProdDash.restorePrevious(root: root, log: { [log] in log.launcher($0) }) {
rolledBack = true
crashTimes.removeAll()
state = .stopped
message = "Rolled back to ProdDash \(restored) — starting…"
Notifier.shared.post(title: "ProdDash rolled back",
body: "The new version would not stay up. Back on \(restored).")
refreshEnvironment()
start()
return
}
let why = settings.restartOnCrash let why = settings.restartOnCrash
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped." ? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
: "ProdDash \(how)." : "ProdDash \(how)."
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.6.0", "version": "1.7.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+30 -8
View File
@@ -451,6 +451,36 @@ a.btn { text-decoration: none; }
/* one swatch per preset: a miniature dashboard (page, a tile on it, the /* one swatch per preset: a miniature dashboard (page, a tile on it, the
accent as a status dot) and the name; the current one wears the accent */ accent as a status dot) and the name; the current one wears the accent */
/* ── a collapsible admin section ─────────────────────────────────────
The pattern for any section of this page that folds. The markup is a
<details class="admin-fold"> holding the section's contents, headed by a
<summary class="admin-h1 admin-fold-title">: the title keeps the same
type as every other section heading — a <summary> would otherwise come
out at the normal weight — and the caret follows the words, so a folded
section still reads as a heading first. The whole row is the hit target. */
details.admin-fold > summary.admin-fold-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
font-weight: 700; /* matches h1.admin-h1; summary defaults to normal */
user-select: none;
}
details.admin-fold > summary.admin-fold-title::-webkit-details-marker { display: none; }
/* after the title, not before it — the heading reads first, the caret says
there is more behind it */
details.admin-fold > summary.admin-fold-title::after {
content: "";
width: 0; height: 0;
border-left: 6px solid var(--muted);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
transition: transform 0.15s;
}
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
.theme-swatches { .theme-swatches {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
@@ -550,11 +580,3 @@ a.btn { text-decoration: none; }
gap: 8px; gap: 8px;
margin-left: auto; 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);
}
-1
View File
@@ -132,7 +132,6 @@ function renderPin(state) {
: set : set
? 'Admin is locked. This page asks for the PIN, and so does every admin request.' ? '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.'; : '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) { function pinMessage(text, bad = false) {
+5 -7
View File
@@ -41,9 +41,11 @@
</section> </section>
<section id="theme-section" hidden> <section id="theme-section" hidden>
<h1 class="admin-h1">Theme</h1> <details class="admin-fold">
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div> <summary class="admin-h1 admin-fold-title">Theme</summary>
<div class="admin-status" id="theme-status"></div> <div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
<div class="admin-status" id="theme-status"></div>
</details>
</section> </section>
<section id="modules-section" hidden> <section id="modules-section" hidden>
@@ -92,10 +94,6 @@
<button class="btn" id="pin-clear" hidden>Turn off</button> <button class="btn" id="pin-clear" hidden>Turn off</button>
</div> </div>
</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 class="admin-status" id="pin-status"></div>
</div> </div>
</section> </section>
+101 -2
View File
@@ -941,8 +941,10 @@ body.fullscreen #grid { padding-bottom: 0; }
every shell element follows, and a colour a tile is configured with (set every shell element follows, and a colour a tile is configured with (set
on the tile itself) still wins. The :root block at the top of this file is on the tile itself) still wins. The :root block at the top of this file is
"booth", the default; the list of ids and names lives in server.js "booth", the default; the list of ids and names lives in server.js
(GET /api/theme). All four are booth-grade: high contrast, the accent (GET /api/theme). Every one is booth-grade: high contrast, the accent
nowhere near the danger red or the warning amber, muted still readable. */ nowhere near the danger red or the warning amber, muted still readable.
Each sets --accent-text too when the default (a near-black green, for
booth's mint) would look wrong against its own accent. */
/* Harbor — deep navy, cyan accent */ /* Harbor — deep navy, cyan accent */
html[data-theme="harbor"] { html[data-theme="harbor"] {
@@ -993,3 +995,100 @@ html[data-theme="daylight"] {
--danger: #d1242f; --danger: #d1242f;
--warn: #b26a00; --warn: #b26a00;
} }
/* Midnight — indigo night, violet accent */
html[data-theme="midnight"] {
--bg: #0a0b18;
--panel: #14162b;
--border: #272a4a;
--text: #e8e9fb;
--muted: #9a9cc8;
--accent: #9d8cff;
--accent-text: #12103a;
--danger: #ff5a72;
--warn: #f5b93a;
}
/* Moss — forest floor, lime accent (warn pushed orange so the two greens
and the amber never read as one another) */
html[data-theme="moss"] {
--bg: #08120c;
--panel: #101e15;
--border: #1f3728;
--text: #e9f4ea;
--muted: #8fb09a;
--accent: #8fdb3f;
--accent-text: #15250a;
--danger: #ff5a52;
--warn: #ff9f1c;
}
/* Plum — aubergine, purple accent */
html[data-theme="plum"] {
--bg: #110a17;
--panel: #1d1228;
--border: #332042;
--text: #f2e9f8;
--muted: #b195c2;
--accent: #c084fc;
--accent-text: #260a37;
--danger: #ff4d4f;
--warn: #f7b53b;
}
/* Sepia — warm brown room, cool teal accent: the opposite arrangement to
Ember, which is warm throughout */
html[data-theme="sepia"] {
--bg: #12100b;
--panel: #1d1913;
--border: #33291d;
--text: #f4ede0;
--muted: #b3a48b;
--accent: #5eead4;
--accent-text: #06231e;
--danger: #ff5a52;
--warn: #ffc247;
}
/* Blackout — true black for a dark room, where any glow off the screen
carries; everything else is as bright as it can be against it */
html[data-theme="blackout"] {
--bg: #000000;
--panel: #0a0a0a;
--border: #1f1f1f;
--text: #ffffff;
--muted: #9e9e9e;
--accent: #00e5ff;
--accent-text: #00212a;
--danger: #ff3b30;
--warn: #ffcc00;
}
/* Paper — the warm light one, next to Daylight's cool white. Danger and warn
darken, as they must on a light background, and the indigo accent takes
white text. */
html[data-theme="paper"] {
--bg: #f5f1e8;
--panel: #fffdf8;
--border: #ddd5c4;
--text: #1c1a15;
--muted: #6b6455;
--accent: #4f46e5;
--accent-text: #ffffff;
--danger: #c8102e;
--warn: #9a6700;
}
/* Iron — no colour of its own: the accent is near-white, so the only hues on
screen are a module's own and the red/amber that mean something */
html[data-theme="iron"] {
--bg: #0c0c0d;
--panel: #161617;
--border: #2b2b2d;
--text: #f2f2f3;
--muted: #9a9a9d;
--accent: #d8d8dc;
--accent-text: #131314;
--danger: #ff4d4f;
--warn: #ffb224;
}
+87 -5
View File
@@ -712,6 +712,13 @@ const THEMES = [
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } }, { id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } }, { id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } }, { id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
{ id: 'midnight', name: 'Midnight', colors: { bg: '#0a0b18', panel: '#14162b', accent: '#9d8cff' } },
{ id: 'moss', name: 'Moss', colors: { bg: '#08120c', panel: '#101e15', accent: '#8fdb3f' } },
{ id: 'plum', name: 'Plum', colors: { bg: '#110a17', panel: '#1d1228', accent: '#c084fc' } },
{ id: 'sepia', name: 'Sepia', colors: { bg: '#12100b', panel: '#1d1913', accent: '#5eead4' } },
{ id: 'blackout', name: 'Blackout', colors: { bg: '#000000', panel: '#0a0a0a', accent: '#00e5ff' } },
{ id: 'paper', name: 'Paper', colors: { bg: '#f5f1e8', panel: '#fffdf8', accent: '#4f46e5' } },
{ id: 'iron', name: 'Iron', colors: { bg: '#0c0c0d', panel: '#161617', accent: '#d8d8dc' } },
]; ];
const DEFAULT_THEME = THEMES[0].id; const DEFAULT_THEME = THEMES[0].id;
@@ -1097,12 +1104,13 @@ function uninstallModule(id) {
/* — updating the shell itself — */ /* — updating the shell itself — */
function copyTree(src, dst) { function copyTree(src, dst, include = null) {
fs.mkdirSync(dst, { recursive: true }); fs.mkdirSync(dst, { recursive: true });
for (const e of fs.readdirSync(src, { withFileTypes: true })) { for (const e of fs.readdirSync(src, { withFileTypes: true })) {
if (include && !include(e.name)) continue;
const s = path.join(src, e.name); const s = path.join(src, e.name);
const d = path.join(dst, e.name); const d = path.join(dst, e.name);
if (e.isDirectory()) copyTree(s, d); if (e.isDirectory()) copyTree(s, d, include);
else fs.copyFileSync(s, d); else fs.copyFileSync(s, d);
} }
} }
@@ -1122,25 +1130,83 @@ async function updateShell() {
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000); const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | ')); console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
} else { } else {
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
const staging = path.join(ROOT, '.proddash-update'); const staging = path.join(ROOT, '.proddash-update');
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
for (const [rel, data] of remote.files) { for (const [rel, data] of remote.files) {
if (!safeRelPath(rel) || keep(rel)) continue; if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
const target = path.join(staging, rel); const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true }); fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data); fs.writeFileSync(target, data);
} }
if (!fs.existsSync(path.join(staging, 'server.js'))) { const newServer = path.join(staging, 'server.js');
if (!fs.existsSync(newServer)) {
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).'); throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
} }
// Nothing is swapped in until the new server at least parses. A file that
// arrived truncated or mangled would otherwise be found only by a process
// that cannot start — the one state this app cannot talk its way out of.
try {
execFileSync(process.execPath, ['--check', newServer], { timeout: 30000, stdio: ['ignore', 'ignore', 'pipe'] });
} catch (err) {
fs.rmSync(staging, { recursive: true, force: true });
// node --check leads with the staging path and a caret diagram; the
// line that names the fault is the one worth repeating.
const lines = String(err?.stderr || err?.message || err).split('\n').map((l) => l.trim());
const why = lines.find((l) => /^[A-Za-z]*Error\b/.test(l)) || 'it does not parse';
throw httpError(502, `The downloaded ProdDash will not run — nothing was changed (${why}).`);
}
keepPreviousCopy();
copyTree(staging, ROOT); copyTree(staging, ROOT);
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
} }
return { from: SHELL_VERSION, to: remote.version || '', method: status.method }; return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
} }
/* ── what an update must not touch ───────────────────────────────────
Anything inside the data directory. Normally it sits well outside the app
folder and none of this applies, but when it cannot (an unwritable
Application Support, or PRODDASH_DATA_DIR aimed inside the app) the data
directory IS the app's config/ — and an update would then write the repo's
shipped defaults over this machine's own proddash.json, taking the admin
PIN, the theme and the forge with it. The two legacy names stay listed for
an install whose state never moved out. */
const DATA_DIR_INSIDE_ROOT = (() => {
const rel = path.relative(ROOT, DATA_DIR);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
return rel.split(path.sep).join('/') + '/';
})();
function isKeptOnUpdate(rel) {
if (rel.startsWith('.git/')) return true;
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
}
/* ── the way back ────────────────────────────────────────────────────
The copy being replaced is set aside first, so there is always one known
version to return to. The launcher restores it if the new one will not
stay up (see ServerController); by hand it is a rename. It is replaced by
the next update, never accumulated. */
function previousCopyPath() {
return `${ROOT}.previous`;
}
function keepPreviousCopy() {
const previous = previousCopyPath();
try {
fs.rmSync(previous, { recursive: true, force: true });
copyTree(ROOT, previous, (rel) => rel !== '.proddash-update' && rel !== '.git');
fs.writeFileSync(path.join(previous, '.proddash-version'), SHELL_VERSION);
console.log(`[proddash] kept ${SHELL_VERSION} at ${previous} in case the new one will not run`);
} catch (err) {
// Worth saying, not worth refusing the update over.
console.warn('[proddash] could not set the current copy aside:', err?.message || err);
}
}
/** /**
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us * 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 * again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
@@ -1911,6 +1977,22 @@ server.on('error', (err) => {
server.listen(PORT, '0.0.0.0'); server.listen(PORT, '0.0.0.0');
/* An update proves itself by staying up. Once this version has, the copy it
replaced is dropped — so `app.previous` existing always means "the last
update has not proven itself yet", which is exactly what the launcher
should roll back to and nothing more. */
const UPDATE_PROVEN_AFTER_MS = 90000;
setTimeout(() => {
const previous = previousCopyPath();
if (!fs.existsSync(previous)) return;
try {
fs.rmSync(previous, { recursive: true, force: true });
console.log(`[proddash] ${SHELL_VERSION} has stayed up — dropped the copy it replaced`);
} catch (err) {
console.warn('[proddash] could not drop the previous copy:', err?.message || err);
}
}, UPDATE_PROVEN_AFTER_MS).unref();
// Update check: shortly after boot, then every few hours (the admin page // Update check: shortly after boot, then every few hours (the admin page
// shows the result; nothing is installed without someone clicking). // shows the result; nothing is installed without someone clicking).
if (!process.env.PRODDASH_NO_REMOTE_CHECK) { if (!process.env.PRODDASH_NO_REMOTE_CHECK) {