@nexrall/code-core 1.4.67 → 1.4.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parsePluginDependencies = parsePluginDependencies;
36
37
  exports.searchRegistry = searchRegistry;
37
38
  exports.getRegistryPlugin = getRegistryPlugin;
38
39
  exports.reportInstall = reportInstall;
@@ -44,14 +45,46 @@ exports.installPlugin = installPlugin;
44
45
  exports.readReceipt = readReceipt;
45
46
  exports.removePlugin = removePlugin;
46
47
  exports.updatePlugin = updatePlugin;
48
+ exports.planPluginPrune = planPluginPrune;
49
+ exports.prunePlugins = prunePlugins;
47
50
  const fs = __importStar(require("fs"));
48
51
  const path = __importStar(require("path"));
49
52
  const os = __importStar(require("os"));
50
53
  const child_process_1 = require("child_process");
51
54
  const client_1 = require("../api/client");
52
55
  const index_1 = require("./index");
56
+ const data_1 = require("./data");
53
57
  const sources_1 = require("./sources");
54
58
  const frontmatter_1 = require("../util/frontmatter");
59
+ /** Read a manifest's `dependencies` list (either spelling), dropping malformed entries. */
60
+ function parsePluginDependencies(dir) {
61
+ const file = (0, index_1.resolvePluginFile)(dir, 'manifest');
62
+ if (!file)
63
+ return [];
64
+ try {
65
+ const j = JSON.parse(fs.readFileSync(file, 'utf-8'));
66
+ if (!Array.isArray(j.dependencies))
67
+ return [];
68
+ const out = [];
69
+ const seen = new Set();
70
+ for (const raw of j.dependencies) {
71
+ const obj = raw && typeof raw === 'object' ? raw : null;
72
+ const name = typeof raw === 'string' ? raw : typeof obj?.name === 'string' ? obj.name : '';
73
+ const trimmed = name.trim();
74
+ // Same rule as an install name: a dependency can never escape the plugins
75
+ // directory, and a repeat entry adds nothing.
76
+ if (!/^[\w.-]+$/.test(trimmed) || seen.has(trimmed))
77
+ continue;
78
+ seen.add(trimmed);
79
+ const version = typeof obj?.version === 'string' ? obj.version : undefined;
80
+ out.push(version ? { name: trimmed, version } : { name: trimmed });
81
+ }
82
+ return out;
83
+ }
84
+ catch {
85
+ return []; // unreadable/malformed manifest — an install will report the real problem
86
+ }
87
+ }
55
88
  const RECEIPT_FILE = '_install.json';
56
89
  /** Search the registry (empty query = list all, official first). */
57
90
  async function searchRegistry(query, limit = 50) {
@@ -170,6 +203,7 @@ function inspectPluginDir(dir) {
170
203
  hasHooks: (0, index_1.resolvePluginFile)(dir, 'hooks') !== null,
171
204
  hasMcp: (0, index_1.resolvePluginFile)(dir, 'mcp') !== null,
172
205
  meta,
206
+ dependencies: parsePluginDependencies(dir),
173
207
  };
174
208
  }
175
209
  /** Read a flat `commands/*.md` or `agents/*.md` directory, returning name + frontmatter description. */
@@ -428,6 +462,42 @@ function pluginsRoot(scope, workDir) {
428
462
  ? path.join(workDir || process.cwd(), '.nexrall', 'plugins')
429
463
  : path.join(os.homedir(), '.nexrall', 'plugins');
430
464
  }
465
+ /**
466
+ * Directory of an installed plugin of this name, from EITHER scope (project first,
467
+ * matching loader precedence), or null. Used to decide that a dependency is already
468
+ * satisfied: the loader serves both scopes into one session, so an install in either
469
+ * one is a plugin the plugin can actually use.
470
+ */
471
+ function installedPluginDir(name, workDir) {
472
+ if (!/^[\w.-]+$/.test(name))
473
+ return null;
474
+ for (const scope of ['project', 'global']) {
475
+ const dir = path.join(pluginsRoot(scope, workDir), name);
476
+ if (fs.existsSync(dir))
477
+ return dir;
478
+ }
479
+ return null;
480
+ }
481
+ /**
482
+ * Where would installing dependency `name` come from? A declared source alias wins
483
+ * (the more specific, locally-declared intent), then the registry — the same order
484
+ * `nex plugin install <name>` uses, so the two can never disagree about what a name
485
+ * means.
486
+ */
487
+ async function resolveDependencySpec(name, workDir) {
488
+ const alias = (0, sources_1.resolveSourceAlias)(name, workDir ?? process.cwd(), { includeProject: true });
489
+ if (alias)
490
+ return { spec: alias.spec };
491
+ try {
492
+ const entry = await getRegistryPlugin(name);
493
+ if (entry)
494
+ return { spec: entry.source };
495
+ }
496
+ catch (err) {
497
+ return { error: `the registry could not be reached (${err.message})` };
498
+ }
499
+ return { error: 'it is not a declared source and not in the registry' };
500
+ }
431
501
  async function installPlugin(spec, opts = {}) {
432
502
  const source = parsePluginSource(spec);
433
503
  const scope = opts.scope ?? 'global';
@@ -496,6 +566,7 @@ async function installPlugin(spec, opts = {}) {
496
566
  sha: sha ?? undefined,
497
567
  previousSha: opts._previousSha,
498
568
  isUpdate: Boolean(opts._previousSha) || Boolean(opts.force),
569
+ ...(opts._auto ? { requiredBy: opts._auto.requiredBy } : {}),
499
570
  });
500
571
  if (!ok)
501
572
  throw new Error('Installation cancelled.');
@@ -509,6 +580,52 @@ async function installPlugin(spec, opts = {}) {
509
580
  fs.rmSync(dest, { recursive: true, force: true });
510
581
  }
511
582
  copyPluginDir(pluginRoot, dest);
583
+ // 5b. Dependencies — install the ones that are missing, at the same scope.
584
+ // A plugin that calls another plugin's MCP server or skill is broken unless its
585
+ // dependency is present, and "install A, then go read A's manifest to find out
586
+ // what else to install" is exactly the step users skip. The safety gate still
587
+ // runs for each dependency: a dependency can ship hooks of its own.
588
+ const manifestDeps = parsePluginDependencies(pluginRoot).filter((d) => d.name !== name);
589
+ const dependencyWarnings = [];
590
+ const installedDependencies = [];
591
+ // Every directory this call wrote, newest last. A failed install removes them
592
+ // again — the point of a failure is that nothing changed, and a half-installed
593
+ // plugin directory is worse than no plugin at all: it LOADS.
594
+ const writtenDirs = [dest];
595
+ try {
596
+ for (const dep of manifestDeps) {
597
+ if (dep.version) {
598
+ dependencyWarnings.push(`"${dep.name}" declares version "${dep.version}", which is not enforced yet (sources are git specs, not versioned registry entries) — its default branch is used.`);
599
+ }
600
+ if ((opts._auto?.stack ?? []).includes(dep.name))
601
+ continue; // cycle: A → B → A
602
+ if (installedPluginDir(dep.name, opts.workDir))
603
+ continue; // satisfied by either scope
604
+ const resolved = await resolveDependencySpec(dep.name, opts.workDir);
605
+ if ('error' in resolved) {
606
+ throw new Error(`Dependency "${dep.name}" of "${name}": ${resolved.error}. Install it first: nex plugin install ${dep.name}`);
607
+ }
608
+ const child = await installPlugin(resolved.spec, {
609
+ scope,
610
+ workDir: opts.workDir,
611
+ name: dep.name,
612
+ confirm: opts.confirm,
613
+ _auto: { requiredBy: name, stack: [...(opts._auto?.stack ?? []), name] },
614
+ });
615
+ writtenDirs.push(...(child.writtenDirs ?? [child.dir]));
616
+ installedDependencies.push(dep.name, ...(child.installedDependencies ?? []));
617
+ dependencyWarnings.push(...(child.dependencyWarnings ?? []));
618
+ }
619
+ }
620
+ catch (err) {
621
+ for (const d of writtenDirs.reverse()) {
622
+ try {
623
+ fs.rmSync(d, { recursive: true, force: true });
624
+ }
625
+ catch { /* best effort */ }
626
+ }
627
+ throw err;
628
+ }
512
629
  // 6. Receipt for `nex plugin update`
513
630
  const receipt = {
514
631
  source: source.raw,
@@ -517,9 +634,21 @@ async function installPlugin(spec, opts = {}) {
517
634
  subdir: source.subdir,
518
635
  installedAt: new Date().toISOString(),
519
636
  ...(sha ? { sha } : {}),
637
+ ...(opts._auto ? { auto: true, requiredBy: opts._auto.requiredBy } : {}),
638
+ ...(manifestDeps.length ? { dependencies: manifestDeps.map((d) => d.name) } : {}),
520
639
  };
521
640
  fs.writeFileSync(path.join(dest, RECEIPT_FILE), JSON.stringify(receipt, null, 2));
522
- return { name, dir: dest, scope, inspection, sha: sha ?? undefined, previousSha: opts._previousSha };
641
+ return {
642
+ name,
643
+ dir: dest,
644
+ scope,
645
+ inspection,
646
+ sha: sha ?? undefined,
647
+ previousSha: opts._previousSha,
648
+ ...(installedDependencies.length ? { installedDependencies } : {}),
649
+ ...(dependencyWarnings.length ? { dependencyWarnings } : {}),
650
+ writtenDirs,
651
+ };
523
652
  }
524
653
  finally {
525
654
  fs.rmSync(tmpBase, { recursive: true, force: true });
@@ -543,6 +672,11 @@ function removePlugin(name, opts = {}) {
543
672
  const dir = path.join(pluginsRoot(s, opts.workDir), name);
544
673
  if (fs.existsSync(dir)) {
545
674
  fs.rmSync(dir, { recursive: true, force: true });
675
+ // Data goes with the plugin unless --keep-data: the install directory is gone,
676
+ // and leaving a database/cache behind with no owner is how a later reinstall
677
+ // silently inherits state nobody meant to keep.
678
+ if (!opts.keepData)
679
+ (0, data_1.removePluginData)(name);
546
680
  return dir;
547
681
  }
548
682
  }
@@ -576,4 +710,76 @@ async function updatePlugin(name, opts = {}) {
576
710
  }
577
711
  throw new Error(`Plugin "${name}" is not installed.`);
578
712
  }
713
+ /** What would `nex plugin prune` remove? Pure inspection — installs/removes nothing. */
714
+ function planPluginPrune(opts = {}) {
715
+ const scopes = opts.scope ? [opts.scope] : ['project', 'global'];
716
+ const rows = [];
717
+ for (const s of scopes) {
718
+ let entries;
719
+ try {
720
+ entries = fs.readdirSync(pluginsRoot(s, opts.workDir), { withFileTypes: true });
721
+ }
722
+ catch {
723
+ continue; // scope has no plugins directory — nothing to prune there
724
+ }
725
+ for (const e of entries) {
726
+ if (!e.isDirectory())
727
+ continue;
728
+ const dir = path.join(pluginsRoot(s, opts.workDir), e.name);
729
+ rows.push({ name: e.name, scope: s, dir, receipt: readReceipt(dir) });
730
+ }
731
+ }
732
+ const byName = new Map();
733
+ for (const r of rows)
734
+ byName.set(r.name, [...(byName.get(r.name) ?? []), r]);
735
+ const needed = new Set();
736
+ const stack = rows.filter((r) => !r.receipt?.auto).map((r) => r.name);
737
+ while (stack.length) {
738
+ const n = stack.pop();
739
+ if (needed.has(n))
740
+ continue;
741
+ needed.add(n);
742
+ for (const r of byName.get(n) ?? []) {
743
+ // Union of the receipt (what was installed) and the manifest as it stands now
744
+ // (a hand-edited manifest must not lead to pruning something it still names).
745
+ const deps = new Set([
746
+ ...(r.receipt?.dependencies ?? []),
747
+ ...parsePluginDependencies(r.dir).map((d) => d.name),
748
+ ]);
749
+ for (const d of deps)
750
+ if (!needed.has(d))
751
+ stack.push(d);
752
+ }
753
+ }
754
+ const orphans = [];
755
+ const kept = [];
756
+ for (const r of rows) {
757
+ if (r.receipt?.auto && !needed.has(r.name)) {
758
+ orphans.push({ name: r.name, scope: r.scope, dir: r.dir, requiredBy: r.receipt.requiredBy });
759
+ }
760
+ else {
761
+ kept.push({ name: r.name, scope: r.scope, reason: r.receipt?.auto ? 'needed' : 'manual' });
762
+ }
763
+ }
764
+ return { orphans, kept };
765
+ }
766
+ /**
767
+ * Remove a plan's orphans. Returns what was actually removed — a directory that
768
+ * could not be deleted is left in the plan AND out of the result, so the caller
769
+ * reports reality rather than the intention.
770
+ */
771
+ function prunePlugins(plan, opts = {}) {
772
+ const removed = [];
773
+ for (const o of plan.orphans) {
774
+ try {
775
+ fs.rmSync(o.dir, { recursive: true, force: true });
776
+ removed.push(o);
777
+ // Same rule as removePlugin: uninstalling takes the data unless told to keep it.
778
+ if (!opts.keepData)
779
+ (0, data_1.removePluginData)(o.name);
780
+ }
781
+ catch { /* locked directory, permissions — leave it and report the rest */ }
782
+ }
783
+ return removed;
784
+ }
579
785
  //# sourceMappingURL=installer.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexrall/code-core",
3
- "version": "1.4.67",
3
+ "version": "1.4.69",
4
4
  "description": "Core agent loop, tools, and extension primitives for Nexrall Code — embed an AI coding agent in any Node.js application.",
5
5
  "license": "MIT",
6
6
  "author": "Nexrall <support@nexrall.com> (https://nexrall.com)",