diff --git a/package.json b/package.json index d863544..c07ca1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.9.1", + "version": "1.9.2", "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 c16d7cd..b03a2c6 100644 --- a/server.js +++ b/server.js @@ -1071,13 +1071,22 @@ async function installModule(id) { if (download) { 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) { if (!safeRelPath(rel)) continue; + shipped.add(rel); const target = path.join(staging, rel); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, data); } const dest = path.join(INSTALLED_MODULES_DIR, id); + // Per-machine executables — a capture tool compiled here, or copied in + // by hand on a machine whose toolchain can't build it — are gitignored, + // so the repo never ships them. Replacing the folder must not destroy + // them: carry executables the download doesn't include over from the + // copy being replaced (or from the app's bundled copy on a first + // install), or "update" quietly kills a working module. + carryOverExecutables([dest, path.join(BUNDLED_MODULES_DIR, id)], staging, shipped); unmountModule(id); fs.rmSync(dest, { recursive: true, force: true }); fs.renameSync(staging, dest); @@ -1094,6 +1103,43 @@ async function installModule(id) { return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download }; } +/** + * Copy executable files (any execute bit) that `shipped` does not include + * from the first existing source dir into `staging`, keeping their relative + * path and mode. Executables only, on purpose: they are the per-machine + * build artifacts (gitignored, so never in a download), while stray text + * files from an old version — code an update deliberately removed — must + * NOT be resurrected. + */ +function carryOverExecutables(sourceDirs, staging, shipped) { + const from = sourceDirs.find((d) => { + try { return fs.statSync(d).isDirectory(); } catch { return false; } + }); + if (!from) return; + const walk = (rel) => { + for (const e of fs.readdirSync(path.join(from, rel), { withFileTypes: true })) { + const relPath = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) { + walk(relPath); + continue; + } + if (!e.isFile() || shipped.has(relPath) || !safeRelPath(relPath)) continue; + try { + const stat = fs.statSync(path.join(from, relPath)); + if (!(stat.mode & 0o111)) continue; + const target = path.join(staging, relPath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(path.join(from, relPath), target); + fs.chmodSync(target, stat.mode); + console.log(`[proddash] module update kept per-machine executable "${relPath}"`); + } catch (err) { + console.error(`[proddash] could not carry "${relPath}" across the update:`, err?.message || err); + } + } + }; + walk(''); +} + /** Remove a module: an installed copy is deleted; a bundled one is switched off and hidden (its folder belongs to the app). Settings are kept so a later reinstall picks them up again. */