"use strict"; /** * Auto-install the bundled dsh plugins (voice suite) into the web profile. * * Bundled at build time under /plugins/ together with a manifest.json * ({ "": { id, name } } — the loader patch entry each plugin needs). * At boot we compare with what the profile already carries * (~/.dsh/profiles/web) and install/upgrade: * - copy each plugin package (deps included) into /node_modules/ * - append its insert entry to the profile's cordis.patch.yml * New installs require a service restart to take effect — the caller decides * whether to restart right away or just notify. */ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); /** Bundled plugin sources (build-time); absent in plain `npm start`. */ function bundledPluginsRoot() { return path.join(__dirname, "plugins"); } /** The web profile directory (DSH_HOME-aware). */ function webProfileDir() { return path.join(process.env.DSH_HOME || path.join(os.homedir(), ".dsh"), "profiles", "web"); } function readJson(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } } /** Copy a directory tree (files + symlinks materialized as files). */ function copyTree(src, dest) { fs.mkdirSync(dest, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const from = path.join(src, entry.name); const to = path.join(dest, entry.name); if (entry.isDirectory()) copyTree(from, to); else fs.copyFileSync(from, to); } } function patchFile(profileDir) { return path.join(profileDir, "cordis.patch.yml"); } /** True when the patch yaml already carries an insert entry for this id. */ function patchHasEntry(patchText, id) { return patchText !== null && patchText.includes(`id: ${id}`); } /** * Install/upgrade bundled plugins into the profile (idempotent). * @returns {{changed: string[], skipped: string[]}} — "changed" entries need * a service restart to take effect. */ function syncPlugins({ log = () => {} } = {}) { const bundledRoot = bundledPluginsRoot(); const result = { changed: [], skipped: [] }; const manifest = readJson(path.join(bundledRoot, "manifest.json")); if (!manifest) return result; // nothing bundled (dev mode) — fine const profileDir = webProfileDir(); const nodeModules = path.join(profileDir, "node_modules"); try { fs.mkdirSync(nodeModules, { recursive: true }); } catch (err) { log(`[plugins] cannot create profile node_modules: ${err.message}`); return result; } const patchText = safeReadPatch(profileDir); let patchAppend = ""; for (const [pkgName, entry] of Object.entries(manifest)) { const src = path.join(bundledRoot, pkgName); const pkg = readJson(path.join(src, "package.json")); if (!pkg || pkg.name !== pkgName || !pkg.version) continue; const dest = path.join(nodeModules, pkgName); // A symlinked install is a developer-managed layout (DSWorkSpace) — // never overwrite it; its source is the live code anyway. try { if (fs.lstatSync(dest).isSymbolicLink()) { result.skipped.push(pkgName); continue; } } catch { /* not installed yet — proceed */ } const installed = readJson(path.join(dest, "package.json")); const needsInstall = !installed || installed.version !== pkg.version; const needsPatch = !entry.dep && !patchHasEntry(patchText, entry.id); if (!needsInstall && !needsPatch) { result.skipped.push(pkgName); continue; } log(`[plugins] installing ${pkgName}@${pkg.version}${installed ? ` (was ${installed.version})` : ""}`); try { if (installed) fs.rmSync(dest, { recursive: true, force: true }); copyTree(src, dest); } catch (err) { log(`[plugins] copy failed for ${pkgName}: ${err.message}`); continue; } if (needsPatch) { patchAppend += ` - id: ${entry.id}\n name: ${entry.name}\n config: {}\n`; } result.changed.push(pkgName); } if (patchAppend) { try { const file = patchFile(profileDir); let text = safeReadPatch(profileDir); if (text === null || text.trim() === "") { text = `# Your patch layer for this dsh profile, applied after every bundle layer:\n# a top-level YAML array of loader patch entries.\n- insert:\n`; } else if (!/- insert:/.test(text)) { text = `${text.replace(/\s*$/, "\n")}- insert:\n`; } fs.writeFileSync(file, text.replace(/\n*$/, "\n") + patchAppend); } catch (err) { log(`[plugins] patch update failed: ${err.message}`); } } return result; } function safeReadPatch(profileDir) { try { return fs.readFileSync(patchFile(profileDir), "utf8"); } catch { return null; } } module.exports = { syncPlugins, webProfileDir };