plugins.js 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. "use strict";
  2. /**
  3. * Auto-install the bundled dsh plugins (voice suite) into the web profile.
  4. *
  5. * Bundled at build time under <app>/plugins/ together with a manifest.json
  6. * ({ "<pkg>": { id, name } } — the loader patch entry each plugin needs).
  7. * At boot we compare with what the profile already carries
  8. * (~/.dsh/profiles/web) and install/upgrade:
  9. * - copy each plugin package (deps included) into <profile>/node_modules/
  10. * - append its insert entry to the profile's cordis.patch.yml
  11. * New installs require a service restart to take effect — the caller decides
  12. * whether to restart right away or just notify.
  13. */
  14. const fs = require("node:fs");
  15. const os = require("node:os");
  16. const path = require("node:path");
  17. /** Bundled plugin sources (build-time); absent in plain `npm start`. */
  18. function bundledPluginsRoot() {
  19. return path.join(__dirname, "plugins");
  20. }
  21. /** The web profile directory (DSH_HOME-aware). */
  22. function webProfileDir() {
  23. return path.join(process.env.DSH_HOME || path.join(os.homedir(), ".dsh"), "profiles", "web");
  24. }
  25. function readJson(file) {
  26. try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
  27. }
  28. /** Copy a directory tree (files + symlinks materialized as files). */
  29. function copyTree(src, dest) {
  30. fs.mkdirSync(dest, { recursive: true });
  31. for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
  32. const from = path.join(src, entry.name);
  33. const to = path.join(dest, entry.name);
  34. if (entry.isDirectory()) copyTree(from, to);
  35. else fs.copyFileSync(from, to);
  36. }
  37. }
  38. function patchFile(profileDir) {
  39. return path.join(profileDir, "cordis.patch.yml");
  40. }
  41. /** True when the patch yaml already carries an insert entry for this id. */
  42. function patchHasEntry(patchText, id) {
  43. return patchText !== null && patchText.includes(`id: ${id}`);
  44. }
  45. /**
  46. * Install/upgrade bundled plugins into the profile (idempotent).
  47. * @returns {{changed: string[], skipped: string[]}} — "changed" entries need
  48. * a service restart to take effect.
  49. */
  50. function syncPlugins({ log = () => {} } = {}) {
  51. const bundledRoot = bundledPluginsRoot();
  52. const result = { changed: [], skipped: [] };
  53. const manifest = readJson(path.join(bundledRoot, "manifest.json"));
  54. if (!manifest) return result; // nothing bundled (dev mode) — fine
  55. const profileDir = webProfileDir();
  56. const nodeModules = path.join(profileDir, "node_modules");
  57. try { fs.mkdirSync(nodeModules, { recursive: true }); } catch (err) {
  58. log(`[plugins] cannot create profile node_modules: ${err.message}`);
  59. return result;
  60. }
  61. const patchText = safeReadPatch(profileDir);
  62. let patchAppend = "";
  63. for (const [pkgName, entry] of Object.entries(manifest)) {
  64. const src = path.join(bundledRoot, pkgName);
  65. const pkg = readJson(path.join(src, "package.json"));
  66. if (!pkg || pkg.name !== pkgName || !pkg.version) continue;
  67. const dest = path.join(nodeModules, pkgName);
  68. // A symlinked install is a developer-managed layout (DSWorkSpace) —
  69. // never overwrite it; its source is the live code anyway.
  70. try {
  71. if (fs.lstatSync(dest).isSymbolicLink()) { result.skipped.push(pkgName); continue; }
  72. } catch { /* not installed yet — proceed */ }
  73. const installed = readJson(path.join(dest, "package.json"));
  74. const needsInstall = !installed || installed.version !== pkg.version;
  75. const needsPatch = !entry.dep && !patchHasEntry(patchText, entry.id);
  76. if (!needsInstall && !needsPatch) { result.skipped.push(pkgName); continue; }
  77. log(`[plugins] installing ${pkgName}@${pkg.version}${installed ? ` (was ${installed.version})` : ""}`);
  78. try {
  79. if (installed) fs.rmSync(dest, { recursive: true, force: true });
  80. copyTree(src, dest);
  81. } catch (err) {
  82. log(`[plugins] copy failed for ${pkgName}: ${err.message}`);
  83. continue;
  84. }
  85. if (needsPatch) {
  86. patchAppend += ` - id: ${entry.id}\n name: ${entry.name}\n config: {}\n`;
  87. }
  88. result.changed.push(pkgName);
  89. }
  90. if (patchAppend) {
  91. try {
  92. const file = patchFile(profileDir);
  93. let text = safeReadPatch(profileDir);
  94. if (text === null || text.trim() === "") {
  95. 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`;
  96. } else if (!/- insert:/.test(text)) {
  97. text = `${text.replace(/\s*$/, "\n")}- insert:\n`;
  98. }
  99. fs.writeFileSync(file, text.replace(/\n*$/, "\n") + patchAppend);
  100. } catch (err) {
  101. log(`[plugins] patch update failed: ${err.message}`);
  102. }
  103. }
  104. return result;
  105. }
  106. function safeReadPatch(profileDir) {
  107. try { return fs.readFileSync(patchFile(profileDir), "utf8"); } catch { return null; }
  108. }
  109. module.exports = { syncPlugins, webProfileDir };