@lifeaitools/clauth 2.0.0 → 2.0.1
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.
- package/.clauth-skill/references/keys-guide.md +270 -270
- package/cli/api.js +238 -238
- package/cli/commands/install.js +396 -396
- package/cli/commands/serve.js +174 -2
- package/cli/commands/uninstall.js +164 -164
- package/cli/index.js +73 -1
- package/cli/supervisor-registry.js +291 -0
- package/cli/supervisor-registry.test.js +345 -4
- package/cli/supervisor-ui.test.js +436 -0
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +2 -2
- package/scripts/bootstrap.cjs +121 -121
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
|
@@ -6,6 +6,7 @@ import test from "node:test";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
addTunnelRoute,
|
|
9
|
+
deregisterPlugin,
|
|
9
10
|
discoverPlugins,
|
|
10
11
|
getClauthPm2Home,
|
|
11
12
|
listPlugins,
|
|
@@ -18,6 +19,9 @@ import {
|
|
|
18
19
|
setPluginEnabled,
|
|
19
20
|
shellQuote,
|
|
20
21
|
supervisorHealth,
|
|
22
|
+
syncPluginsFromRepos,
|
|
23
|
+
SYNC_REPO_NAMES,
|
|
24
|
+
SYNC_SKIP_STATES,
|
|
21
25
|
validatePluginManifest,
|
|
22
26
|
} from "./supervisor-registry.js";
|
|
23
27
|
import { isLoopbackAddress, supervisorLogDto, supervisorRequiresWriteToken } from "./commands/serve.js";
|
|
@@ -29,6 +33,14 @@ function withTempSupervisor(fn) {
|
|
|
29
33
|
const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
30
34
|
const oldPm2 = process.env.CLAUTH_PM2_HOME;
|
|
31
35
|
process.env.CLAUTH_SUPERVISOR_DIR = root;
|
|
36
|
+
// CLAUTH_MANAGED_PLUGIN_ROOTS / CLAUTH_USER_PLUGIN_ROOTS are set as PERSISTENT
|
|
37
|
+
// MACHINE-LEVEL env vars on developer boxes, pointing at the LIVE daemon's
|
|
38
|
+
// plugin directories. rootEntries() prefers them over CLAUTH_SUPERVISOR_DIR,
|
|
39
|
+
// so overriding the supervisor dir ALONE leaves registerPlugin/deregisterPlugin
|
|
40
|
+
// writing to (and deleting from) the live fleet. Pin all three to the temp
|
|
41
|
+
// root here so a test cannot reach the live daemon by forgetting to.
|
|
42
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed-plugins");
|
|
43
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user-plugins");
|
|
32
44
|
delete process.env.CLAUTH_PM2_HOME;
|
|
33
45
|
try {
|
|
34
46
|
return fn(root);
|
|
@@ -398,17 +410,46 @@ test("health reconciliation never restarts external or plugin-owned surfaces", a
|
|
|
398
410
|
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
|
|
399
411
|
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
400
412
|
try {
|
|
413
|
+
// Both fixtures are deliberately PROBE-ABLE — local destination + port, so
|
|
414
|
+
// healthUrlForSurface() yields a real URL for each. That is what gives this
|
|
415
|
+
// test teeth.
|
|
416
|
+
//
|
|
417
|
+
// An earlier revision made the external fixture `vultr/` with no port. It
|
|
418
|
+
// read as equivalent and was not: portless + relative health resolves to a
|
|
419
|
+
// null URL, so reconcileSurfaceHealth's `if (!url) continue` skipped it
|
|
420
|
+
// BEFORE the owner/destination guard was consulted. Delete the guard this
|
|
421
|
+
// test is named for and it still passed green — it proved the surface was
|
|
422
|
+
// unprobe-able, not that it was probe-able and correctly left alone.
|
|
423
|
+
//
|
|
424
|
+
// Now the ONLY thing that can exclude external-demo is lifecycle_owner, and
|
|
425
|
+
// clauth-owned-demo is the positive control proving the probe machinery
|
|
426
|
+
// actually runs against this fixture shape. If `inspected` ever comes back
|
|
427
|
+
// empty, the control failed and the negative result means nothing.
|
|
401
428
|
writePlugin(root, "managed", "external-demo", baseManifest("external-demo", {
|
|
402
429
|
core: true,
|
|
403
430
|
enable_default: true,
|
|
404
|
-
destination: "
|
|
431
|
+
destination: "local/clauth/pm2",
|
|
405
432
|
lifecycle_owner: "external",
|
|
406
|
-
surfaces: [{ id: "primary", destination: "
|
|
433
|
+
surfaces: [{ id: "primary", destination: "local/clauth/pm2", lifecycle_owner: "external", port: 39112, health: "/health" }],
|
|
434
|
+
}));
|
|
435
|
+
writePlugin(root, "managed", "clauth-owned-demo", baseManifest("clauth-owned-demo", {
|
|
436
|
+
core: true,
|
|
437
|
+
enable_default: true,
|
|
438
|
+
destination: "local/clauth/pm2",
|
|
439
|
+
lifecycle_owner: "clauth",
|
|
440
|
+
surfaces: [{ id: "primary", destination: "local/clauth/pm2", lifecycle_owner: "clauth", port: 39113, health: "/health" }],
|
|
407
441
|
}));
|
|
408
442
|
discoverPlugins();
|
|
409
443
|
const result = await reconcileSurfaceHealth({ fetchImpl: async () => ({ ok: false, status: 503 }) });
|
|
410
|
-
|
|
411
|
-
|
|
444
|
+
const inspectedIds = result.inspected.map((s) => (typeof s === "string" ? s : s.surface_id));
|
|
445
|
+
// Positive control: the probe machinery ran at all.
|
|
446
|
+
assert.ok(inspectedIds.some((id) => String(id).includes("clauth-owned-demo")),
|
|
447
|
+
`control failed — clauth-owned surface was not probed, so "external was skipped" proves nothing (inspected: ${JSON.stringify(result.inspected)})`);
|
|
448
|
+
// The actual assertion: an externally-owned surface is never touched.
|
|
449
|
+
assert.ok(!inspectedIds.some((id) => String(id).includes("external-demo")),
|
|
450
|
+
`external-demo was probed — the lifecycle_owner guard is not excluding it (inspected: ${JSON.stringify(result.inspected)})`);
|
|
451
|
+
const external = listSurfaces().find((s) => s.plugin_id === "external-demo");
|
|
452
|
+
assert.equal(external.state, "current");
|
|
412
453
|
} finally {
|
|
413
454
|
if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
|
|
414
455
|
else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
|
|
@@ -529,6 +570,306 @@ test("registerPlugin rejects an invalid manifest without writing anything", () =
|
|
|
529
570
|
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
530
571
|
}));
|
|
531
572
|
|
|
573
|
+
// Builds throwaway product repos laid out like the real ones, so a sync sweep
|
|
574
|
+
// exercises the real relative manifest paths without reading a live checkout.
|
|
575
|
+
function withTempProductRepos(fn) {
|
|
576
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sync-repos-"));
|
|
577
|
+
const regenRoot = path.join(base, "regen-root");
|
|
578
|
+
const rdcSkills = path.join(base, "rdc-skills");
|
|
579
|
+
const writeManifest = (repoRoot, relPath, manifest) => {
|
|
580
|
+
const full = path.join(repoRoot, relPath);
|
|
581
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
582
|
+
fs.writeFileSync(full, typeof manifest === "string" ? manifest : `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
583
|
+
return full;
|
|
584
|
+
};
|
|
585
|
+
writeManifest(regenRoot, "packages/codeflow/clauth-plugin.json", baseManifest("codeflow-mcp"));
|
|
586
|
+
writeManifest(regenRoot, "apps/dev-center/clauth-plugin.json", baseManifest("dev-center"));
|
|
587
|
+
writeManifest(regenRoot, "mcp-servers/regen-media/clauth-plugin.json", baseManifest("regen-media"));
|
|
588
|
+
writeManifest(regenRoot, "mcp-servers/web-research/clauth-plugin.json", baseManifest("web-research"));
|
|
589
|
+
writeManifest(rdcSkills, "clauth-plugin.json", baseManifest("rdc-skills"));
|
|
590
|
+
try {
|
|
591
|
+
return fn({ base, regenRoot, rdcSkills, writeManifest, roots: { "regen-root": regenRoot, "rdc-skills": rdcSkills } });
|
|
592
|
+
} finally {
|
|
593
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
test("plugin sync inherits registerPlugin idempotence — a second sweep reports every manifest unchanged", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
|
|
598
|
+
const first = syncPluginsFromRepos(roots, "test");
|
|
599
|
+
assert.equal(first.length, 5, "one receipt per attempted manifest");
|
|
600
|
+
assert.deepEqual(
|
|
601
|
+
first.filter((entry) => !entry.ok).map((entry) => `${entry.repo}:${entry.state}`),
|
|
602
|
+
[],
|
|
603
|
+
"every manifest in a complete checkout must register",
|
|
604
|
+
);
|
|
605
|
+
assert.equal(first.every((entry) => entry.state === "registered"), true);
|
|
606
|
+
|
|
607
|
+
// registerPlugin sha256-compares before writing; sync must not defeat that by
|
|
608
|
+
// re-writing or re-hashing on its own.
|
|
609
|
+
const second = syncPluginsFromRepos(roots, "test");
|
|
610
|
+
assert.equal(second.length, 5);
|
|
611
|
+
assert.equal(second.every((entry) => entry.ok && entry.state === "unchanged"), true, "re-sweeping identical content must be a no-op");
|
|
612
|
+
})));
|
|
613
|
+
|
|
614
|
+
test("plugin sync warns and continues over a missing repo root instead of throwing", () => withTempSupervisor(() => withTempProductRepos(({ base, rdcSkills }) => {
|
|
615
|
+
// A box that never checked out regen-root must still sync what it does have.
|
|
616
|
+
const absent = path.join(base, "no-such-checkout");
|
|
617
|
+
assert.equal(fs.existsSync(absent), false);
|
|
618
|
+
let receipts;
|
|
619
|
+
assert.doesNotThrow(() => {
|
|
620
|
+
receipts = syncPluginsFromRepos({ "regen-root": absent, "rdc-skills": rdcSkills }, "test");
|
|
621
|
+
});
|
|
622
|
+
assert.equal(receipts.length, 5, "a skipped repo still yields a receipt per attempted manifest");
|
|
623
|
+
const missing = receipts.filter((entry) => entry.state === "repo_root_missing");
|
|
624
|
+
assert.equal(missing.length, 4, "all four regen-root manifests report the missing root");
|
|
625
|
+
assert.equal(missing.every((entry) => entry.ok === false), true);
|
|
626
|
+
const skills = receipts.find((entry) => entry.repo === "rdc-skills");
|
|
627
|
+
assert.equal(skills.ok, true);
|
|
628
|
+
assert.equal(skills.state, "registered");
|
|
629
|
+
assert.ok(listPlugins().find((plugin) => plugin.id === "rdc-skills"), "the reachable repo still registered");
|
|
630
|
+
})));
|
|
631
|
+
|
|
632
|
+
test("plugin sync registers the remaining manifests when one is malformed", () => withTempSupervisor(() => withTempProductRepos(({ regenRoot, roots, writeManifest }) => {
|
|
633
|
+
writeManifest(regenRoot, "apps/dev-center/clauth-plugin.json", "{ this is not valid json");
|
|
634
|
+
|
|
635
|
+
const receipts = syncPluginsFromRepos(roots, "test");
|
|
636
|
+
assert.equal(receipts.length, 5);
|
|
637
|
+
const bad = receipts.find((entry) => entry.path.includes("dev-center"));
|
|
638
|
+
assert.equal(bad.ok, false);
|
|
639
|
+
assert.equal(bad.state, "manifest_invalid");
|
|
640
|
+
const good = receipts.filter((entry) => entry !== bad);
|
|
641
|
+
assert.equal(good.length, 4);
|
|
642
|
+
assert.equal(good.every((entry) => entry.ok && entry.state === "registered"), true, "one bad manifest must not abort the sweep");
|
|
643
|
+
const ids = new Set(listPlugins().map((plugin) => plugin.id));
|
|
644
|
+
for (const id of ["codeflow-mcp", "regen-media", "web-research", "rdc-skills"]) {
|
|
645
|
+
assert.ok(ids.has(id), `${id} must still be registered`);
|
|
646
|
+
}
|
|
647
|
+
})));
|
|
648
|
+
|
|
649
|
+
test("deregisterPlugin removes only the named plugin and leaves siblings intact", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
|
|
650
|
+
syncPluginsFromRepos(roots, "test");
|
|
651
|
+
const managed = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
652
|
+
assert.equal(fs.existsSync(path.join(managed, "web-research")), true);
|
|
653
|
+
|
|
654
|
+
const receipt = deregisterPlugin("web-research", "test");
|
|
655
|
+
assert.equal(receipt.resulting_state.ok, true);
|
|
656
|
+
assert.equal(receipt.resulting_state.state, "deregistered");
|
|
657
|
+
assert.equal(fs.existsSync(path.join(managed, "web-research")), false, "the named plugin directory is gone");
|
|
658
|
+
|
|
659
|
+
for (const sibling of ["codeflow-mcp", "dev-center", "regen-media", "rdc-skills"]) {
|
|
660
|
+
assert.equal(fs.existsSync(path.join(managed, sibling)), true, `${sibling} must survive`);
|
|
661
|
+
}
|
|
662
|
+
// discovery re-ran, so the removed managed plugin is reported missing, not current
|
|
663
|
+
const after = listPlugins().find((plugin) => plugin.id === "web-research");
|
|
664
|
+
assert.equal(after.state, "missing_default");
|
|
665
|
+
assert.equal(after.enabled, false);
|
|
666
|
+
})));
|
|
667
|
+
|
|
668
|
+
test("deregisterPlugin on an unregistered id is a safe no-op, not an error", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
|
|
669
|
+
syncPluginsFromRepos(roots, "test");
|
|
670
|
+
const managed = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
671
|
+
const before = fs.readdirSync(managed).sort();
|
|
672
|
+
|
|
673
|
+
const receipt = deregisterPlugin("never-registered-plugin", "test");
|
|
674
|
+
assert.equal(receipt.resulting_state.ok, true, "a no-op is success, not failure");
|
|
675
|
+
assert.equal(receipt.resulting_state.state, "not_registered");
|
|
676
|
+
assert.deepEqual(fs.readdirSync(managed).sort(), before, "nothing else may be touched");
|
|
677
|
+
})));
|
|
678
|
+
|
|
679
|
+
test("deregisterPlugin rejects an id that would escape the managed-plugins root", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
|
|
680
|
+
// Mirrors the registerPlugin traversal test. The id here is raw CLI/HTTP
|
|
681
|
+
// input with no manifest validation upstream, and the operation DELETES
|
|
682
|
+
// recursively — so an id resolving to the root itself must reject too, which
|
|
683
|
+
// is where this is strictly stricter than registerPlugin's assertion.
|
|
684
|
+
syncPluginsFromRepos(roots, "test");
|
|
685
|
+
const managed = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
686
|
+
const sentinel = path.join(managed, "..", "sentinel-outside-root.txt");
|
|
687
|
+
fs.writeFileSync(sentinel, "must survive", "utf8");
|
|
688
|
+
const before = fs.readdirSync(managed).sort();
|
|
689
|
+
|
|
690
|
+
const evilIds = [
|
|
691
|
+
"..", "../..", "../sentinel-outside-root.txt", "..\\..", "/etc", "", ".",
|
|
692
|
+
// These three are stopped by the CHARSET guard alone — the containment
|
|
693
|
+
// assert waves all of them through, because path.resolve() normalizes the
|
|
694
|
+
// `..` away and Windows re-anchors a drive-relative path, so each lands
|
|
695
|
+
// back INSIDE the root (measured during code review). They are pinned here
|
|
696
|
+
// so the two guards are independently tested and nobody "simplifies" the
|
|
697
|
+
// charset rule believing containment is a safety net for it.
|
|
698
|
+
"sub/../web-research",
|
|
699
|
+
"C:web-research",
|
|
700
|
+
"web-research::$DATA",
|
|
701
|
+
];
|
|
702
|
+
for (const evil of evilIds) {
|
|
703
|
+
const receipt = deregisterPlugin(evil, "test");
|
|
704
|
+
assert.equal(receipt.resulting_state.ok, false, `id ${JSON.stringify(evil)} must be rejected`);
|
|
705
|
+
assert.equal(receipt.resulting_state.state, "invalid_plugin_id");
|
|
706
|
+
}
|
|
707
|
+
assert.equal(fs.existsSync(path.join(managed, "web-research")), true, "a charset-rejected alias must not have deleted the real plugin");
|
|
708
|
+
|
|
709
|
+
assert.equal(fs.existsSync(sentinel), true, "must never delete outside the managed-plugins root");
|
|
710
|
+
assert.equal(fs.existsSync(managed), true, "must never delete the managed-plugins root itself");
|
|
711
|
+
assert.deepEqual(fs.readdirSync(managed).sort(), before, "no registered plugin may be removed by a rejected id");
|
|
712
|
+
})));
|
|
713
|
+
|
|
714
|
+
test("validatePluginManifest rejects a port on any non-local destination, and keeps local ports", () => {
|
|
715
|
+
// A remote surface is reached by URL; its port is the deployment registry's
|
|
716
|
+
// fact. Copying it into the manifest creates a second home for one fact that
|
|
717
|
+
// then drifts (regen-media 3121 vs 3120, dev-center 3012 vs 3003). Worse, a
|
|
718
|
+
// ported remote surface makes localhostHealth() synthesize
|
|
719
|
+
// http://127.0.0.1:<port>/health for a service on another box, pointing the
|
|
720
|
+
// health reconciler at the wrong machine.
|
|
721
|
+
//
|
|
722
|
+
// The rule is keyed on the validated DESTINATION enum, never on the surface's
|
|
723
|
+
// free-text id/role — keying on the label was tried in review and failed both
|
|
724
|
+
// ways: renaming the surface bypassed it, and a local surface named "remote"
|
|
725
|
+
// was falsely rejected. Both directions are asserted below.
|
|
726
|
+
for (const destination of ["vultr/clauth/pm2", "coolify/clauth/docker"]) {
|
|
727
|
+
for (const port of [3110, "auto", 0]) {
|
|
728
|
+
assert.throws(() => validatePluginManifest(baseManifest("remote-port", {
|
|
729
|
+
surfaces: [{ id: "remote", destination, lifecycle_owner: "external", port }],
|
|
730
|
+
})), /must not declare a port/, `${destination} + port ${JSON.stringify(port)} must be rejected`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
// Renaming the surface must NOT bypass the rule — this is the bypass that
|
|
734
|
+
// keying on the `remote` label allowed.
|
|
735
|
+
for (const id of ["vultr", "prod", "primary", "local"]) {
|
|
736
|
+
assert.throws(() => validatePluginManifest(baseManifest("renamed-remote", {
|
|
737
|
+
surfaces: [{ id, destination: "vultr/clauth/pm2", lifecycle_owner: "external", port: 3110 }],
|
|
738
|
+
})), /must not declare a port/, `a non-local surface named "${id}" must still be rejected`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// A remote surface WITHOUT a port is accepted — the shape all four shipped
|
|
742
|
+
// remote surfaces already use.
|
|
743
|
+
const remote = validatePluginManifest(baseManifest("remote-ok", {
|
|
744
|
+
surfaces: [{ id: "remote", destination: "vultr/clauth/pm2", lifecycle_owner: "external" }],
|
|
745
|
+
}), "clauth-plugin.json");
|
|
746
|
+
assert.equal(remote.surfaces[0].port, null);
|
|
747
|
+
|
|
748
|
+
// A LOCAL surface keeps its port on purpose — that port describes how the
|
|
749
|
+
// service runs on a developer box. This is not drift; do not "fix" it. A
|
|
750
|
+
// local surface merely NAMED "remote" is local, and must not be rejected.
|
|
751
|
+
for (const [id, destination] of [["local", "local/clauth/pm2"], ["primary", "local/clauth/pm2"], ["remote", "local/clauth/daemon"]]) {
|
|
752
|
+
const localSurface = validatePluginManifest(baseManifest(`${id}-ok`, {
|
|
753
|
+
surfaces: [{ id, destination, lifecycle_owner: "clauth", port: 3109, health: "/health" }],
|
|
754
|
+
}), "clauth-plugin.json");
|
|
755
|
+
assert.equal(localSurface.surfaces[0].port, 3109, `${destination} surface must keep its port`);
|
|
756
|
+
assert.equal(localSurface.surfaces[0].health, "http://127.0.0.1:3109/health");
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("deregisterPlugin removes a scoped @scope/pkg plugin instead of falsely reporting it absent", () => withTempSupervisor((root) => {
|
|
761
|
+
// A removal verb that reports ✓ while the plugin stays installed AND enabled
|
|
762
|
+
// is the one receipt this must never get wrong. The flat <root>/<id> probe
|
|
763
|
+
// misses the scoped layout entirely, so the location is resolved from the
|
|
764
|
+
// discovery_root/sourcePath discovery already records.
|
|
765
|
+
const managed = path.join(root, "managed");
|
|
766
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
767
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
768
|
+
writeScopedPlugin(root, "managed", "@lifeaitools", "fs-mcp", baseManifest("fs-mcp", { core: true, enable_default: true }));
|
|
769
|
+
discoverPlugins();
|
|
770
|
+
assert.equal(listPlugins().find((plugin) => plugin.id === "fs-mcp").enabled, true, "precondition: scoped plugin is enabled");
|
|
771
|
+
|
|
772
|
+
const receipt = deregisterPlugin("fs-mcp", "test");
|
|
773
|
+
assert.equal(receipt.resulting_state.state, "deregistered", "must not report not_registered for a scoped plugin");
|
|
774
|
+
assert.equal(receipt.resulting_state.ok, true);
|
|
775
|
+
assert.equal(fs.existsSync(path.join(managed, "@lifeaitools", "fs-mcp")), false, "the scoped package directory is actually gone");
|
|
776
|
+
const after = listPlugins().find((plugin) => plugin.id === "fs-mcp");
|
|
777
|
+
assert.equal(after.enabled, false, "a deregistered plugin must not remain enabled");
|
|
778
|
+
assert.equal(after.state, "missing_default");
|
|
779
|
+
}));
|
|
780
|
+
|
|
781
|
+
test("deregisterPlugin finds a plugin in a non-first managed root and refuses a user-root plugin", () => withTempSupervisor((root) => {
|
|
782
|
+
// CLAUTH_MANAGED_PLUGIN_ROOTS is a path-delimited LIST; honoring only the
|
|
783
|
+
// first entry silently reports a real plugin as absent.
|
|
784
|
+
const managedA = path.join(root, "managed-a");
|
|
785
|
+
const managedB = path.join(root, "managed-b");
|
|
786
|
+
const user = path.join(root, "user");
|
|
787
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = [managedA, managedB].join(path.delimiter);
|
|
788
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = user;
|
|
789
|
+
writePlugin(root, "managed-b", "second-root-plugin", baseManifest("second-root-plugin"));
|
|
790
|
+
writePlugin(root, "user", "user-only-plugin", baseManifest("user-only-plugin"));
|
|
791
|
+
discoverPlugins();
|
|
792
|
+
|
|
793
|
+
const second = deregisterPlugin("second-root-plugin", "test");
|
|
794
|
+
assert.equal(second.resulting_state.state, "deregistered", "a plugin in the 2nd managed root must be found");
|
|
795
|
+
assert.equal(fs.existsSync(path.join(managedB, "second-root-plugin")), false);
|
|
796
|
+
|
|
797
|
+
// A user-root plugin is out of this verb's remit — refuse it explicitly
|
|
798
|
+
// rather than reporting a green "not_registered" that implies it is gone.
|
|
799
|
+
const userReceipt = deregisterPlugin("user-only-plugin", "test");
|
|
800
|
+
assert.equal(userReceipt.resulting_state.ok, false);
|
|
801
|
+
assert.equal(userReceipt.resulting_state.state, "not_managed");
|
|
802
|
+
assert.equal(fs.existsSync(path.join(user, "user-only-plugin")), true, "the user plugin must be left intact");
|
|
803
|
+
}));
|
|
804
|
+
|
|
805
|
+
test("deregisterPlugin --dry-run resolves the target without deleting it", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
|
|
806
|
+
syncPluginsFromRepos(roots, "test");
|
|
807
|
+
const managed = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
808
|
+
|
|
809
|
+
const receipt = deregisterPlugin("web-research", "test", { dryRun: true });
|
|
810
|
+
assert.equal(receipt.resulting_state.ok, true);
|
|
811
|
+
assert.equal(receipt.resulting_state.state, "would_deregister");
|
|
812
|
+
assert.equal(receipt.resulting_state.target_dir, path.resolve(managed, "web-research"));
|
|
813
|
+
assert.equal(fs.existsSync(path.join(managed, "web-research")), true, "a dry run must not delete anything");
|
|
814
|
+
assert.equal(listPlugins().find((plugin) => plugin.id === "web-research").state !== "missing_default", true);
|
|
815
|
+
})));
|
|
816
|
+
|
|
817
|
+
test("plugin sync reports an unknown repo-root override instead of silently sweeping the default checkout", () => withTempSupervisor(() => withTempProductRepos(({ roots, base }) => {
|
|
818
|
+
// A typo'd override name dropped on the floor means the sweep reads the
|
|
819
|
+
// DEFAULT checkout while printing ✓ on every line — success from the wrong repo.
|
|
820
|
+
const receipts = syncPluginsFromRepos({ ...roots, regen_root: path.join(base, "typo-checkout") }, "test");
|
|
821
|
+
const rejected = receipts.filter((entry) => entry.state === "unknown_repo_name");
|
|
822
|
+
assert.equal(rejected.length, 1);
|
|
823
|
+
assert.equal(rejected[0].repo, "regen_root");
|
|
824
|
+
assert.equal(rejected[0].ok, false);
|
|
825
|
+
assert.match(rejected[0].error, /unknown repo name/);
|
|
826
|
+
assert.equal(SYNC_SKIP_STATES.includes("unknown_repo_name"), false, "a typo'd repo name must fail the sweep, not be skipped");
|
|
827
|
+
assert.deepEqual([...SYNC_REPO_NAMES].sort(), ["rdc-skills", "regen-root"]);
|
|
828
|
+
})));
|
|
829
|
+
|
|
830
|
+
test("plugin sync never throws on a malformed repo root — the contract absence must not break", () => withTempSupervisor(() => withTempProductRepos(({ rdcSkills }) => {
|
|
831
|
+
// path.resolve() throws on a non-string; doing that before the existence
|
|
832
|
+
// guard aborted the whole sweep and lost every later repo's receipt.
|
|
833
|
+
for (const badRoot of [123, " ", {}, [], true]) {
|
|
834
|
+
let receipts;
|
|
835
|
+
assert.doesNotThrow(() => {
|
|
836
|
+
receipts = syncPluginsFromRepos({ "regen-root": badRoot, "rdc-skills": rdcSkills }, "test");
|
|
837
|
+
}, `root ${JSON.stringify(badRoot)} must not throw`);
|
|
838
|
+
assert.equal(receipts.length, 5, "every attempted manifest still yields a receipt");
|
|
839
|
+
assert.equal(
|
|
840
|
+
receipts.filter((entry) => entry.repo === "regen-root" && entry.state === "repo_root_unknown").length,
|
|
841
|
+
4,
|
|
842
|
+
`root ${JSON.stringify(badRoot)} must be reported, not thrown`,
|
|
843
|
+
);
|
|
844
|
+
const skills = receipts.find((entry) => entry.repo === "rdc-skills");
|
|
845
|
+
assert.equal(skills.ok, true, "a later repo must still be swept after a bad earlier root");
|
|
846
|
+
}
|
|
847
|
+
})));
|
|
848
|
+
|
|
849
|
+
test("regression: scoped @scope/pkg and unscoped discovery both still work alongside deregister", () => withTempSupervisor((root) => {
|
|
850
|
+
// Epic 63d4d778 WP-1 taught findManifestFiles to descend one extra level for
|
|
851
|
+
// an @scope directory (node_modules/@lifeaitools/fs-mcp). Neither the sync
|
|
852
|
+
// sweep nor deregisterPlugin may regress that.
|
|
853
|
+
const managed = path.join(root, "managed");
|
|
854
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
855
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
856
|
+
writePlugin(root, "managed", "plain-plugin", baseManifest("plain-plugin"));
|
|
857
|
+
writeScopedPlugin(root, "managed", "@lifeaitools", "fs-mcp", baseManifest("fs-mcp"));
|
|
858
|
+
|
|
859
|
+
const discovered = discoverPlugins();
|
|
860
|
+
assert.ok(discovered.plugins.find((plugin) => plugin.id === "plain-plugin"), "unscoped layout still discovered");
|
|
861
|
+
assert.ok(discovered.plugins.find((plugin) => plugin.id === "fs-mcp"), "scoped layout still discovered");
|
|
862
|
+
|
|
863
|
+
// Deregistering the unscoped plugin must not disturb the scoped tree.
|
|
864
|
+
const receipt = deregisterPlugin("plain-plugin", "test");
|
|
865
|
+
assert.equal(receipt.resulting_state.ok, true);
|
|
866
|
+
assert.equal(fs.existsSync(path.join(managed, "plain-plugin")), false);
|
|
867
|
+
assert.equal(fs.existsSync(path.join(managed, "@lifeaitools", "fs-mcp", "clauth-plugin.json")), true, "scoped package must be untouched");
|
|
868
|
+
|
|
869
|
+
const after = discoverPlugins();
|
|
870
|
+
assert.ok(after.plugins.find((plugin) => plugin.id === "fs-mcp" && plugin.state !== "missing_default"), "scoped plugin still discoverable after a sibling deregister");
|
|
871
|
+
}));
|
|
872
|
+
|
|
532
873
|
test("tunnel route add and remove produce reversible operation receipts", () => withTempSupervisor((root) => {
|
|
533
874
|
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
|
|
534
875
|
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|