diff --git a/package.json b/package.json index 2dd686c..0630900 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.12.0", + "version": "1.12.1", "private": true, "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/server.js b/server.js index cb8a838..5289a7d 100644 --- a/server.js +++ b/server.js @@ -1121,6 +1121,11 @@ function parseTar(buf) { while (off + 512 <= buf.length) { const h = buf.subarray(off, off + 512); 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 type = h[156] === 0 ? '0' : String.fromCharCode(h[156]); const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : ''; @@ -1136,7 +1141,7 @@ function parseTar(buf) { } const name = pendingName || shortName; pendingName = null; - if (type === '0') files.set(name, Buffer.from(data)); + if (type === '0') files.set(name, { data: Buffer.from(data), mode }); } return { files, comment }; } @@ -1165,9 +1170,9 @@ async function fetchArchive() { } const { files, comment } = parseTar(zlib.gunzipSync(gz)); const stripped = new Map(); // drop the archive's single top-level folder - for (const [p, data] of files) { + for (const [p, entry] of files) { const i = p.indexOf('/'); - if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data); + if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), entry); } return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' }; } @@ -1182,17 +1187,17 @@ async function refreshRemote(force = false) { const job = (async () => { try { const { files, sha } = await fetchArchive(); - const pkg = safeJson(files.get('package.json')); + const pkg = safeJson(files.get('package.json')?.data); const modules = new Map(); - for (const [p, data] of files) { + for (const [p, file] of files) { const m = p.match(/^modules\/([^/]+)\/(.+)$/); if (!m) continue; const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() }; - entry.files.set(m[2], data); + entry.files.set(m[2], file); modules.set(m[1], entry); } for (const [id, entry] of modules) { - const man = safeJson(entry.files.get('module.json')); + const man = safeJson(entry.files.get('module.json')?.data); if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) { modules.delete(id); continue; @@ -1322,12 +1327,14 @@ async function installModule(id) { const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`); fs.rmSync(staging, { recursive: true, force: true }); const shipped = new Set(); - for (const [rel, data] of remoteEntry.files) { + for (const [rel, { data, mode }] of remoteEntry.files) { if (!safeRelPath(rel)) continue; shipped.add(rel); const target = path.join(staging, rel); fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, data); + // The tarball's mode, or a shipped bin/ helper lands 0644 and spawns + // 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); // Per-machine executables — a capture tool compiled here, or copied in @@ -1439,11 +1446,11 @@ async function updateShell() { } else { const staging = path.join(ROOT, '.proddash-update'); fs.rmSync(staging, { recursive: true, force: true }); - for (const [rel, data] of remote.files) { + for (const [rel, { data, mode }] of remote.files) { if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue; const target = path.join(staging, rel); fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, data); + fs.writeFileSync(target, data, { mode }); } const newServer = path.join(staging, 'server.js'); if (!fs.existsSync(newServer)) { diff --git a/tools/unbundled.test.js b/tools/unbundled.test.js index 7c17bd3..6f9b5d9 100644 --- a/tools/unbundled.test.js +++ b/tools/unbundled.test.js @@ -12,7 +12,12 @@ * 2. a shell update from an archive that carries modules/ never writes * them into the app folder — they show up in the catalog instead; * 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 * @@ -70,6 +75,11 @@ function buildArchive(dir) { 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' })); 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', 'third'), 'third', '1.0.0'); const out = path.join(dir, 'main.tar.gz'); @@ -188,6 +198,17 @@ function buildPackagedApp(app) { const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' }); 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'); } catch (err) { failed = true;