Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 7481d1d9e2 Shell 1.13.0: the Layouts menu lists the tiles a layout holds but the board is not showing, with a ✕ to remove each
A tile whose module stopped offering its picker entry — Slack's Send message
and Quick replies since the Send Message module took them over — kept its
place in the layout and drew nothing, as designed, but still held its grid
cells with no control anywhere to let them go: a dead area the operator
could not place anything in.

The Layouts menu now has a Not shown section, present only while the layout
holds such a tile. One row per tile: its title (or the module's name plus
the variant, or the module id when it is not installed), why in a few words
— "not offered right now", "module disabled", "module needs a newer
ProdDash", "module not installed" — and a drawn ✕ with a tooltip that
removes it, saves the layout and frees the cells, leaving the menu open as
the Grid Size stepper and Alerts do. The section repaints while the menu is
open whenever tiles become offered or stop being, and only when the list
changed. A tile that merely waits for its module keeps its place as before.

GET /api/modules also returns `inactive` — the installed modules that are
not running, id, name and reason only — so a dashboard can tell a disabled
module from one that is not installed. The toast after loading a named
layout now says the Layouts menu can remove the tiles it did not show.
README and the module guide updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:46:50 -04:00
7 changed files with 159 additions and 53 deletions
+9 -4
View File
@@ -316,10 +316,15 @@ below. Nothing is installed without someone clicking.
name, load a named layout (it becomes a local copy — browsers are never name, load a named layout (it becomes a local copy — browsers are never
linked), or reset this browser's dashboard. Loading says what it did — linked), or reset this browser's dashboard. Loading says what it did —
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right *Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
now* — with **Undo** in the message to get back the arrangement you had. now; the Layouts menu can remove them* — with **Undo** in the message to
A tile whose module or picker entry isn't offered at the moment keeps its get back the arrangement you had. A tile whose module or picker entry
place and shows nothing, then appears when it is; it is never replaced by isn't offered at the moment keeps its place and shows nothing, then
the module's default tile. Saving over an existing name asks first, and appears when it is; it is never replaced by the module's default tile.
While any tile is in that state the same menu has a **Not shown** section
listing each one with why — *not offered right now*, *module disabled*,
*module not installed* — and a ✕ that removes it and frees its cells, for
the tile that is not coming back (a module that stopped offering that
kind of tile). Saving over an existing name asks first, and
the server keeps the copy it replaced: **Restore previous** in Admin → the server keeps the copy it replaced: **Restore previous** in Admin →
Saved layouts swaps it back (and swapping again undoes that). The menu's Saved layouts swaps it back (and swapping again undoes that). The menu's
**Grid Size** stepper sets how many columns this browser cuts the board into — **Grid Size** stepper sets how many columns this browser cuts the board into —
+7 -3
View File
@@ -688,8 +688,11 @@ module.exports = {
tile added from an entry only when that entry (or one of its `aliases`) is tile added from an entry only when that entry (or one of its `aliases`) is
in your list right now; otherwise the tile keeps its place in the layout, in your list right now; otherwise the tile keeps its place in the layout,
draws nothing, and appears the moment your list offers it again — a draws nothing, and appears the moment your list offers it again — a
Service timer waits for PCO Plan, a transcript for its channel. So your Service timer waits for PCO Plan, a transcript for its channel. The
client is never asked to render an entry you did not offer at mount time. operator can remove such a tile from the Layouts menu's **Not shown**
section, so an entry you retire for good (a send box that moved to another
module) leaves no dead cells behind. So your client is never asked to
render an entry you did not offer at mount time.
One case is still yours: an entry that vanishes *after* the tile mounted One case is still yours: an entry that vanishes *after* the tile mounted
(a timer deleted in ProPresenter mid-service). Render nothing for it — no (a timer deleted in ProPresenter mid-service). Render nothing for it — no
placeholder, no error — and the shell folds the tile away at its next placeholder, no error — and the shell folds the tile away at its next
@@ -1044,7 +1047,8 @@ client-side failures (a tile that fails to start says so in the tile).
**Poke the pieces directly:** **Poke the pieces directly:**
- `GET /api/modules` — what the shell sees (your manifest + non-secret config). - `GET /api/modules` — what the shell sees (your manifest + non-secret
config; `inactive` names the installed modules that are not running).
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE: - `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
`curl -N http://localhost:24500/api/modules/<id>/stream`. `curl -N http://localhost:24500/api/modules/<id>/stream`.
- `GET /api/admin/state` — enabled flags, health, mount errors. - `GET /api/admin/state` — enabled flags, health, mount errors.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.12.1", "version": "1.13.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",
+93 -4
View File
@@ -188,6 +188,10 @@ function menuItem(label, onClick, { sub = '', danger = false, disabled = false,
/* ── module registry ────────────────────────────────────────────────── */ /* ── module registry ────────────────────────────────────────────────── */
let registry = new Map(); // module id -> manifest from GET /api/modules let registry = new Map(); // module id -> manifest from GET /api/modules
/* The installed modules that are not running — disabled, or needing a newer
shell — id -> { id, name, reason }, from the same GET. Read for one thing:
to say why a tile one of them left in the layout is not drawn. */
let inactiveModules = new Map();
/** fetch that gives up after `ms`. A request the browser has queued behind /** fetch that gives up after `ms`. A request the browser has queued behind
held connections would otherwise pend forever, and so would anything held connections would otherwise pend forever, and so would anything
@@ -208,6 +212,7 @@ async function loadRegistry() {
if (!res.ok) throw new Error('modules ' + res.status); if (!res.ok) throw new Error('modules ' + res.status);
const body = await res.json(); const body = await res.json();
registry = new Map((body.modules || []).map((m) => [m.id, m])); registry = new Map((body.modules || []).map((m) => [m.id, m]));
inactiveModules = new Map((body.inactive || []).map((m) => [m.id, m]));
return true; return true;
} catch { } catch {
return false; return false;
@@ -487,7 +492,9 @@ const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true
registry offers them again: a Service timer waits for PCO Plan, a registry offers them again: a Service timer waits for PCO Plan, a
transcript for its channel. Never the module's default in their place — transcript for its channel. Never the module's default in their place —
a wall of "All timers" where five different timers were is worse than a wall of "All timers" where five different timers were is worse than
five gaps that fill themselves in. */ five gaps that fill themselves in. For the one that is not coming back
(a module stopped offering that kind of tile) the Layouts menu lists them
under Not shown, each with a ✕ — the only way to let its cells go. */
function tileIsOffered(tile) { function tileIsOffered(tile) {
const man = registry.get(tile.module); const man = registry.get(tile.module);
@@ -521,6 +528,7 @@ function reconcileOfferedTiles() {
if (changed) rebuildDividers(); if (changed) rebuildDividers();
scheduleOfferedRecheck(); scheduleOfferedRecheck();
refreshEmptyState(); // the registry may have gone from nothing to something refreshEmptyState(); // the registry may have gone from nothing to something
repaintUnshown(); // a Not shown row goes the moment its tile is drawn again
} }
/* A module's entry list can change without anyone saving admin config: PCO /* A module's entry list can change without anyone saving admin config: PCO
@@ -540,10 +548,90 @@ function scheduleOfferedRecheck() {
}, OFFERED_RECHECK_MS); }, OFFERED_RECHECK_MS);
} }
/** The tiles a layout holds that the board is not showing, each with what
to call it and, in a few words, why it is not drawn. */
function unshownTiles() {
return tiles.filter((t) => !tileEls.has(t.id)).map((tile) => {
const man = registry.get(tile.module);
if (man) {
const title = tile.title || (tile.variant ? `${man.name} · ${tile.variant}` : man.name);
return { tile, title, reason: 'not offered right now' };
}
const off = inactiveModules.get(tile.module);
if (off) {
const reason = off.reason === 'incompatible' ? 'module needs a newer ProdDash' : 'module disabled';
return { tile, title: tile.title || off.name || tile.module, reason };
}
return { tile, title: tile.title || tile.module, reason: 'module not installed' };
});
}
/** The tiles a layout holds that the board is not showing, by name. */ /** The tiles a layout holds that the board is not showing, by name. */
function unshownTileNames() { function unshownTileNames() {
return tiles.filter((t) => !tileEls.has(t.id)) return unshownTiles().map((u) => u.title);
.map((t) => t.title || registry.get(t.module)?.name || t.module); }
/* Repaints the Layouts menu's Not shown section while the menu is open —
set by unshownSection(), called whenever tiles become offered or stop
being, so the list never lags the board behind it. */
let repaintUnshown = () => {};
/** The Layouts menu's Not shown section: one row per tile the layout holds
that the board is not drawing — its name, why in a word or two, and a ✕
that removes it — present only while there is one. A tile that waits for
its module (a Service timer before PCO Plan is up) is listed but can stay;
this exists for the one that will never be back, which would otherwise
hold its cells with no control anywhere to let them go. */
function unshownSection() {
const wrap = document.createElement('div');
wrap.className = 'menu-unshown';
const signature = (list) => list.map((u) => `${u.tile.id}|${u.reason}`).join('\n');
let painted = '';
const paint = () => {
wrap.innerHTML = '';
const list = unshownTiles();
painted = signature(list);
wrap.hidden = !list.length;
if (!list.length) return;
const divider = document.createElement('div');
divider.className = 'menu-divider';
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = 'Not shown';
wrap.append(divider, title);
for (const { tile, title: name, reason } of list) {
const row = document.createElement('div');
row.className = 'menu-item menu-unshown-row';
const label = document.createElement('span');
label.textContent = name;
const sub = document.createElement('span');
sub.className = 'sub';
sub.textContent = reason;
label.appendChild(sub);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'menu-x';
remove.setAttribute('data-tip', 'Remove');
remove.setAttribute('aria-label', `Remove ${name}`);
remove.innerHTML = CLOSE_SVG;
remove.addEventListener('click', (e) => {
// The row goes with the tile, so the click must not reach the
// document as one from outside the menu — the menu stays open
// to show the next row (or none), as the stepper and Alerts do.
e.stopPropagation();
removeTile(tile.id); // saves the layout and frees the cells, as a drawn tile's ✕ does
paint();
});
row.append(label, remove);
wrap.appendChild(row);
}
};
paint();
// Only when something changed: the registry is re-read every few seconds
// while a tile is folded away, and rows rebuilt under the pointer for
// nothing would lose the tooltip (and the click) the operator was on.
repaintUnshown = () => { if (wrap.isConnected && signature(unshownTiles()) !== painted) paint(); };
return wrap;
} }
function buildTile(tile) { function buildTile(tile) {
@@ -2253,6 +2341,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
// how one is arranged. // how one is arranged.
menu.appendChild(gridStepper()); menu.appendChild(gridStepper());
menu.appendChild(alertsFold()); menu.appendChild(alertsFold());
menu.appendChild(unshownSection()); // only while a tile the layout holds is not drawn
const stepperDivider = document.createElement('div'); const stepperDivider = document.createElement('div');
stepperDivider.className = 'menu-divider'; stepperDivider.className = 'menu-divider';
menu.appendChild(stepperDivider); menu.appendChild(stepperDivider);
@@ -2344,7 +2433,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`; const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : ''; const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
const note = unshown.length const note = unshown.length
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now` ? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now; the Layouts menu can remove ${unshown.length === 1 ? 'it' : 'them'}`
: ''; : '';
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, { toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
ms: unshown.length ? 10000 : 7000, ms: unshown.length ? 10000 : 7000,
+27
View File
@@ -368,6 +368,33 @@ body[data-alert="critical"] .tile.is-tinted {
text-transform: uppercase; text-transform: uppercase;
} }
/* the Layouts menu's Not shown rows: a tile the layout holds but the board
is not drawing, why in a word or two, and the ✕ that removes it */
.menu-unshown[hidden] { display: none; }
.menu-unshown-row { cursor: default; }
.menu-unshown-row:hover { background: none; }
.menu-unshown-row > span:first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-x {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: none;
border: none;
border-radius: 5px;
color: var(--muted);
cursor: pointer;
}
.menu-x:hover { background: var(--border); color: var(--danger); }
.menu-x svg { width: 12px; height: 12px; display: block; }
/* ── grid ──────────────────────────────────────────────────────────── */ /* ── grid ──────────────────────────────────────────────────────────── */
#gridwrap { #gridwrap {
+21 -19
View File
@@ -1121,11 +1121,6 @@ function parseTar(buf) {
while (off + 512 <= buf.length) { while (off + 512 <= buf.length) {
const h = buf.subarray(off, off + 512); const h = buf.subarray(off, off + 512);
if (h.every((b) => b === 0)) break; // end-of-archive blocks if (h.every((b) => b === 0)) break; // end-of-archive blocks
// The mode matters: modules ship prebuilt helpers under bin/, and writing
// one out without its execute bits leaves spawn() with EACCES and the
// feature silently dead. Permission bits only — setuid/sticky from an
// archive are not wanted here.
const mode = (parseInt(cstr(h, 100, 8).trim() || '0', 8) & 0o777) || 0o644;
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0; const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]); const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : ''; const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
@@ -1141,7 +1136,7 @@ function parseTar(buf) {
} }
const name = pendingName || shortName; const name = pendingName || shortName;
pendingName = null; pendingName = null;
if (type === '0') files.set(name, { data: Buffer.from(data), mode }); if (type === '0') files.set(name, Buffer.from(data));
} }
return { files, comment }; return { files, comment };
} }
@@ -1170,9 +1165,9 @@ async function fetchArchive() {
} }
const { files, comment } = parseTar(zlib.gunzipSync(gz)); const { files, comment } = parseTar(zlib.gunzipSync(gz));
const stripped = new Map(); // drop the archive's single top-level folder const stripped = new Map(); // drop the archive's single top-level folder
for (const [p, entry] of files) { for (const [p, data] of files) {
const i = p.indexOf('/'); const i = p.indexOf('/');
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), entry); 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 : '' }; return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
} }
@@ -1187,17 +1182,17 @@ async function refreshRemote(force = false) {
const job = (async () => { const job = (async () => {
try { try {
const { files, sha } = await fetchArchive(); const { files, sha } = await fetchArchive();
const pkg = safeJson(files.get('package.json')?.data); const pkg = safeJson(files.get('package.json'));
const modules = new Map(); const modules = new Map();
for (const [p, file] of files) { for (const [p, data] of files) {
const m = p.match(/^modules\/([^/]+)\/(.+)$/); const m = p.match(/^modules\/([^/]+)\/(.+)$/);
if (!m) continue; if (!m) continue;
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() }; const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
entry.files.set(m[2], file); entry.files.set(m[2], data);
modules.set(m[1], entry); modules.set(m[1], entry);
} }
for (const [id, entry] of modules) { for (const [id, entry] of modules) {
const man = safeJson(entry.files.get('module.json')?.data); const man = safeJson(entry.files.get('module.json'));
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) { if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
modules.delete(id); modules.delete(id);
continue; continue;
@@ -1327,14 +1322,12 @@ async function installModule(id) {
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`); const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
const shipped = new Set(); const shipped = new Set();
for (const [rel, { data, mode }] of remoteEntry.files) { for (const [rel, data] of remoteEntry.files) {
if (!safeRelPath(rel)) continue; if (!safeRelPath(rel)) continue;
shipped.add(rel); shipped.add(rel);
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 });
// The tarball's mode, or a shipped bin/ helper lands 0644 and spawns fs.writeFileSync(target, data);
// EACCES (what broke LTC and SPL on the 1.10.2 rollout, 2026-10-04).
fs.writeFileSync(target, data, { mode });
} }
const dest = path.join(INSTALLED_MODULES_DIR, id); const dest = path.join(INSTALLED_MODULES_DIR, id);
// Per-machine executables — a capture tool compiled here, or copied in // Per-machine executables — a capture tool compiled here, or copied in
@@ -1446,11 +1439,11 @@ async function updateShell() {
} else { } else {
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, mode }] of remote.files) { for (const [rel, data] of remote.files) {
if (!safeRelPath(rel) || isKeptOnUpdate(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, { mode }); fs.writeFileSync(target, data);
} }
const newServer = path.join(staging, 'server.js'); const newServer = path.join(staging, 'server.js');
if (!fs.existsSync(newServer)) { if (!fs.existsSync(newServer)) {
@@ -2343,8 +2336,17 @@ function handleRequest(req, res) {
/* — shell API — */ /* — shell API — */
if (urlPath === '/api/modules' && req.method === 'GET') { if (urlPath === '/api/modules' && req.method === 'GET') {
// Beside what dashboards may load: the installed modules that are not
// running, by id and name only, so a tile one of them left in a layout
// can say why it is not drawn ("module disabled") instead of guessing at
// "not installed". Nothing of their config travels with it.
const inactive = [...manifests.keys()].filter((id) => !isActive(id)).map((id) => ({
id,
name: String(manifests.get(id).name || id),
reason: isEnabled(id) ? 'incompatible' : 'disabled',
}));
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest)) Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
.then((list) => sendJson(res, 200, { modules: list })) .then((list) => sendJson(res, 200, { modules: list, inactive }))
.catch((err) => { .catch((err) => {
console.error('[proddash] /api/modules failed:', err); console.error('[proddash] /api/modules failed:', err);
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' }); if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
+1 -22
View File
@@ -12,12 +12,7 @@
* 2. a shell update from an archive that carries modules/ never writes * 2. a shell update from an archive that carries modules/ never writes
* them into the app folder — they show up in the catalog instead; * them into the app folder — they show up in the catalog instead;
* 3. a fresh install lists everything the repo offers, and installing * 3. a fresh install lists everything the repo offers, and installing
* several at once lands them in the data directory and mounts them; * several at once lands them in the data directory and mounts them.
* 4. a module downloaded from the catalog keeps the tarball's modes — a
* shipped bin/ helper is still executable (0644 means spawn EACCES,
* which is what broke LTC and SPL on the 1.10.2 rollout) — while
* per-machine executables the download does not include are still
* carried over from the copy being replaced.
* *
* node tools/unbundled.test.js * node tools/unbundled.test.js
* *
@@ -75,11 +70,6 @@ function buildArchive(dir) {
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js')); fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' })); fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0'); writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
// The repo ships this one prebuilt (the way Timers and SPL ship their
// capture helpers): it must come out of the download still executable.
fs.mkdirSync(path.join(top, 'modules', 'demo', 'bin'));
fs.writeFileSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), '#!/bin/sh\necho capture\n');
fs.chmodSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), 0o755);
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0'); writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0'); writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
const out = path.join(dir, 'main.tar.gz'); const out = path.join(dir, 'main.tar.gz');
@@ -198,17 +188,6 @@ function buildPackagedApp(app) {
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' }); const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
assert.equal(empty.status, 400, 'nothing to install is said'); assert.equal(empty.status, 400, 'nothing to install is said');
// ── 4. a download keeps executable modes, and carry-over still works ──
const upd = await api('/api/admin/modules/demo/install', { method: 'POST', body: '{}' });
assert.equal(upd.status, 200, 'demo update: ' + JSON.stringify(upd.body));
assert.equal(upd.body.version, '2.0.0', 'demo was updated from the repo');
const shipped = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-capture');
assert.ok(fs.existsSync(shipped), 'the downloaded helper is there');
assert.ok(fs.statSync(shipped).mode & 0o111, 'and the tarball\'s execute bit survived the install');
const carried = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-helper');
assert.ok(fs.existsSync(carried), 'the per-machine executable the download lacks was carried over');
assert.ok(fs.statSync(carried).mode & 0o111, 'still executable');
console.log('unbundled shell: every check passed'); console.log('unbundled shell: every check passed');
} catch (err) { } catch (err) {
failed = true; failed = true;