Shell 1.9.2: a module update keeps per-machine executables #29

Merged
michael merged 1 commits from claude/shell-keep-executables into main 2026-09-27 10:49:10 -04:00
2 changed files with 47 additions and 1 deletions
+1 -1
View File
@@ -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",
+46
View File
@@ -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. */