From b0defbd56b34d0157cb697412d1852cdc109695b Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 27 Sep 2026 10:41:41 -0400 Subject: [PATCH] Shell 1.9.2: a module update keeps per-machine executables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AB updated Timers from the catalog and LTC died: the update replaces the module folder wholesale, which deleted the compiled ltc-capture binary — and AB's Xcode CLT cannot rebuild it (the SwiftBridging modulemap defect; the module's new self-build correctly runs there and fails with exactly that error). The binary on AB has always been compiled elsewhere and shipped, so 'update' was guaranteed to break LTC on that machine. installModule now carries executables (any execute bit) the download does not include over from the copy being replaced — or from the app's bundled copy on a first install — preserving path and mode. Executables only, on purpose: those are the gitignored per-machine build artifacts (ltc-capture, spl-capture), while stray text files from an old version — code an update deliberately removed — must not be resurrected. The Timers module's staleness check still notices a carried binary older than its updated source and rebuilds behind it where the toolchain works. Verified end-to-end against the real Gitea catalog: an installed 1.4.9 copy holding a fake executable and a stray .js file, updated to 1.5.0 — the executable survives with its mode and content, the stray file is gone, and the log names what was kept. Co-Authored-By: Claude Fable 5 --- package.json | 2 +- server.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) 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. */