@meshxdata/fops 0.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.

Potentially problematic release.


This version of @meshxdata/fops might be problematic. Click here for more details.

Files changed (57) hide show
  1. package/README.md +98 -0
  2. package/STRUCTURE.md +43 -0
  3. package/foundation.mjs +16 -0
  4. package/package.json +52 -0
  5. package/src/agent/agent.js +367 -0
  6. package/src/agent/agent.test.js +233 -0
  7. package/src/agent/context.js +143 -0
  8. package/src/agent/context.test.js +81 -0
  9. package/src/agent/index.js +2 -0
  10. package/src/agent/llm.js +127 -0
  11. package/src/agent/llm.test.js +139 -0
  12. package/src/auth/index.js +4 -0
  13. package/src/auth/keychain.js +58 -0
  14. package/src/auth/keychain.test.js +185 -0
  15. package/src/auth/login.js +421 -0
  16. package/src/auth/login.test.js +192 -0
  17. package/src/auth/oauth.js +203 -0
  18. package/src/auth/oauth.test.js +118 -0
  19. package/src/auth/resolve.js +78 -0
  20. package/src/auth/resolve.test.js +153 -0
  21. package/src/commands/index.js +268 -0
  22. package/src/config.js +24 -0
  23. package/src/config.test.js +70 -0
  24. package/src/doctor.js +487 -0
  25. package/src/doctor.test.js +134 -0
  26. package/src/plugins/api.js +37 -0
  27. package/src/plugins/api.test.js +95 -0
  28. package/src/plugins/discovery.js +78 -0
  29. package/src/plugins/discovery.test.js +92 -0
  30. package/src/plugins/hooks.js +13 -0
  31. package/src/plugins/hooks.test.js +118 -0
  32. package/src/plugins/index.js +3 -0
  33. package/src/plugins/loader.js +110 -0
  34. package/src/plugins/manifest.js +26 -0
  35. package/src/plugins/manifest.test.js +106 -0
  36. package/src/plugins/registry.js +14 -0
  37. package/src/plugins/registry.test.js +43 -0
  38. package/src/plugins/skills.js +126 -0
  39. package/src/plugins/skills.test.js +173 -0
  40. package/src/project.js +61 -0
  41. package/src/project.test.js +196 -0
  42. package/src/setup/aws.js +369 -0
  43. package/src/setup/aws.test.js +280 -0
  44. package/src/setup/index.js +3 -0
  45. package/src/setup/setup.js +161 -0
  46. package/src/setup/wizard.js +119 -0
  47. package/src/shell.js +9 -0
  48. package/src/shell.test.js +72 -0
  49. package/src/skills/foundation/SKILL.md +107 -0
  50. package/src/ui/banner.js +56 -0
  51. package/src/ui/banner.test.js +97 -0
  52. package/src/ui/confirm.js +97 -0
  53. package/src/ui/index.js +5 -0
  54. package/src/ui/input.js +199 -0
  55. package/src/ui/spinner.js +170 -0
  56. package/src/ui/spinner.test.js +29 -0
  57. package/src/ui/streaming.js +106 -0
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createPluginApi } from "./api.js";
3
+ import { createRegistry } from "./registry.js";
4
+
5
+ describe("plugins/api", () => {
6
+ describe("createPluginApi", () => {
7
+ it("returns api with id and methods", () => {
8
+ const registry = createRegistry();
9
+ const api = createPluginApi("test-plugin", registry);
10
+ expect(api.id).toBe("test-plugin");
11
+ expect(typeof api.registerCommand).toBe("function");
12
+ expect(typeof api.registerDoctorCheck).toBe("function");
13
+ expect(typeof api.registerHook).toBe("function");
14
+ });
15
+
16
+ it("has config property (empty by default)", () => {
17
+ const registry = createRegistry();
18
+ const api = createPluginApi("test-plugin", registry);
19
+ expect(api.config).toEqual({});
20
+ });
21
+
22
+ it("registerCommand adds to registry.commands", () => {
23
+ const registry = createRegistry();
24
+ const api = createPluginApi("test-plugin", registry);
25
+ const spec = { name: "my-cmd", description: "A command" };
26
+ api.registerCommand(spec);
27
+ expect(registry.commands).toHaveLength(1);
28
+ expect(registry.commands[0]).toEqual({ pluginId: "test-plugin", spec });
29
+ });
30
+
31
+ it("registerCommand can add multiple commands", () => {
32
+ const registry = createRegistry();
33
+ const api = createPluginApi("test-plugin", registry);
34
+ api.registerCommand({ name: "cmd1" });
35
+ api.registerCommand({ name: "cmd2" });
36
+ api.registerCommand({ name: "cmd3" });
37
+ expect(registry.commands).toHaveLength(3);
38
+ });
39
+
40
+ it("registerDoctorCheck adds to registry.doctorChecks", () => {
41
+ const registry = createRegistry();
42
+ const api = createPluginApi("test-plugin", registry);
43
+ const fn = vi.fn();
44
+ api.registerDoctorCheck({ name: "check-foo", fn });
45
+ expect(registry.doctorChecks).toHaveLength(1);
46
+ expect(registry.doctorChecks[0].name).toBe("check-foo");
47
+ expect(registry.doctorChecks[0].fn).toBe(fn);
48
+ expect(registry.doctorChecks[0].pluginId).toBe("test-plugin");
49
+ });
50
+
51
+ it("registerHook adds to registry.hooks with default priority", () => {
52
+ const registry = createRegistry();
53
+ const api = createPluginApi("test-plugin", registry);
54
+ const handler = vi.fn();
55
+ api.registerHook("pre:up", handler);
56
+ expect(registry.hooks).toHaveLength(1);
57
+ expect(registry.hooks[0]).toEqual({
58
+ pluginId: "test-plugin",
59
+ event: "pre:up",
60
+ handler,
61
+ priority: 0,
62
+ });
63
+ });
64
+
65
+ it("registerHook respects custom priority", () => {
66
+ const registry = createRegistry();
67
+ const api = createPluginApi("test-plugin", registry);
68
+ const handler = vi.fn();
69
+ api.registerHook("post:up", handler, 10);
70
+ expect(registry.hooks[0].priority).toBe(10);
71
+ });
72
+
73
+ it("multiple plugins can register on same registry", () => {
74
+ const registry = createRegistry();
75
+ const api1 = createPluginApi("plugin-a", registry);
76
+ const api2 = createPluginApi("plugin-b", registry);
77
+ api1.registerCommand({ name: "cmd-a" });
78
+ api2.registerCommand({ name: "cmd-b" });
79
+ api1.registerHook("pre:up", vi.fn());
80
+ api2.registerHook("pre:up", vi.fn());
81
+ expect(registry.commands).toHaveLength(2);
82
+ expect(registry.hooks).toHaveLength(2);
83
+ expect(registry.commands[0].pluginId).toBe("plugin-a");
84
+ expect(registry.commands[1].pluginId).toBe("plugin-b");
85
+ });
86
+
87
+ it("registerDoctorCheck preserves fn reference", () => {
88
+ const registry = createRegistry();
89
+ const api = createPluginApi("test", registry);
90
+ const checkFn = async (ok, warn, fail) => { ok("test passed"); };
91
+ api.registerDoctorCheck({ name: "my-check", fn: checkFn });
92
+ expect(registry.doctorChecks[0].fn).toBe(checkFn);
93
+ });
94
+ });
95
+ });
@@ -0,0 +1,78 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ /**
10
+ * Discover plugin candidates from two sources:
11
+ * 1. ~/.fops/plugins/<name>/ (user-global, each must have fops.plugin.json)
12
+ * 2. npm packages matching fops-plugin-* or @*\/fops-plugin-* in the CLI's node_modules
13
+ *
14
+ * Returns array of { id, path, source: "global" | "npm" }.
15
+ */
16
+ export function discoverPlugins() {
17
+ const candidates = [];
18
+
19
+ // 1. User-global plugins: ~/.fops/plugins/
20
+ const globalDir = path.join(os.homedir(), ".fops", "plugins");
21
+ if (fs.existsSync(globalDir)) {
22
+ try {
23
+ const entries = fs.readdirSync(globalDir, { withFileTypes: true });
24
+ for (const entry of entries) {
25
+ if (!entry.isDirectory()) continue;
26
+ const pluginPath = path.join(globalDir, entry.name);
27
+ if (fs.existsSync(path.join(pluginPath, "fops.plugin.json"))) {
28
+ candidates.push({ id: entry.name, path: pluginPath, source: "global" });
29
+ }
30
+ }
31
+ } catch {
32
+ // ignore read errors
33
+ }
34
+ }
35
+
36
+ // 2. npm packages: look in the CLI's own node_modules
37
+ const cliRoot = path.resolve(__dirname, "../..");
38
+ const nodeModules = path.join(cliRoot, "node_modules");
39
+ if (fs.existsSync(nodeModules)) {
40
+ try {
41
+ const entries = fs.readdirSync(nodeModules, { withFileTypes: true });
42
+ for (const entry of entries) {
43
+ if (entry.name.startsWith(".")) continue;
44
+
45
+ // Scoped packages: @scope/fops-plugin-*
46
+ if (entry.name.startsWith("@") && entry.isDirectory()) {
47
+ const scopeDir = path.join(nodeModules, entry.name);
48
+ try {
49
+ const scopedEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
50
+ for (const scoped of scopedEntries) {
51
+ if (scoped.name.startsWith("fops-plugin-") && scoped.isDirectory()) {
52
+ const pkgPath = path.join(scopeDir, scoped.name);
53
+ if (fs.existsSync(path.join(pkgPath, "fops.plugin.json"))) {
54
+ candidates.push({ id: `${entry.name}/${scoped.name}`, path: pkgPath, source: "npm" });
55
+ }
56
+ }
57
+ }
58
+ } catch {
59
+ // ignore
60
+ }
61
+ continue;
62
+ }
63
+
64
+ // Unscoped packages: fops-plugin-*
65
+ if (entry.name.startsWith("fops-plugin-") && entry.isDirectory()) {
66
+ const pkgPath = path.join(nodeModules, entry.name);
67
+ if (fs.existsSync(path.join(pkgPath, "fops.plugin.json"))) {
68
+ candidates.push({ id: entry.name, path: pkgPath, source: "npm" });
69
+ }
70
+ }
71
+ }
72
+ } catch {
73
+ // ignore read errors
74
+ }
75
+ }
76
+
77
+ return candidates;
78
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ describe("plugins/discovery", () => {
7
+ let tmpHome;
8
+
9
+ beforeEach(() => {
10
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "fops-home-"));
11
+ vi.spyOn(os, "homedir").mockReturnValue(tmpHome);
12
+ });
13
+
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ fs.rmSync(tmpHome, { recursive: true, force: true });
17
+ });
18
+
19
+ it("returns empty when no plugin dirs exist", async () => {
20
+ vi.resetModules();
21
+ const { discoverPlugins } = await import("./discovery.js");
22
+ const result = discoverPlugins();
23
+ const globalPlugins = result.filter((p) => p.source === "global");
24
+ expect(globalPlugins).toHaveLength(0);
25
+ });
26
+
27
+ it("discovers global plugins with fops.plugin.json", async () => {
28
+ const pluginDir = path.join(tmpHome, ".fops", "plugins", "my-plugin");
29
+ fs.mkdirSync(pluginDir, { recursive: true });
30
+ fs.writeFileSync(
31
+ path.join(pluginDir, "fops.plugin.json"),
32
+ JSON.stringify({ id: "my-plugin", name: "My Plugin", version: "1.0.0" })
33
+ );
34
+
35
+ vi.resetModules();
36
+ const { discoverPlugins } = await import("./discovery.js");
37
+ const result = discoverPlugins();
38
+ const globalPlugins = result.filter((p) => p.source === "global");
39
+ expect(globalPlugins).toHaveLength(1);
40
+ expect(globalPlugins[0].id).toBe("my-plugin");
41
+ expect(globalPlugins[0].path).toBe(pluginDir);
42
+ expect(globalPlugins[0].source).toBe("global");
43
+ });
44
+
45
+ it("skips global dirs without fops.plugin.json", async () => {
46
+ const pluginDir = path.join(tmpHome, ".fops", "plugins", "no-manifest");
47
+ fs.mkdirSync(pluginDir, { recursive: true });
48
+ fs.writeFileSync(path.join(pluginDir, "index.js"), "");
49
+
50
+ vi.resetModules();
51
+ const { discoverPlugins } = await import("./discovery.js");
52
+ const result = discoverPlugins();
53
+ const globalPlugins = result.filter((p) => p.source === "global");
54
+ expect(globalPlugins).toHaveLength(0);
55
+ });
56
+
57
+ it("discovers multiple global plugins", async () => {
58
+ for (const name of ["plugin-a", "plugin-b", "plugin-c"]) {
59
+ const dir = path.join(tmpHome, ".fops", "plugins", name);
60
+ fs.mkdirSync(dir, { recursive: true });
61
+ fs.writeFileSync(
62
+ path.join(dir, "fops.plugin.json"),
63
+ JSON.stringify({ id: name, name, version: "1.0.0" })
64
+ );
65
+ }
66
+
67
+ vi.resetModules();
68
+ const { discoverPlugins } = await import("./discovery.js");
69
+ const result = discoverPlugins();
70
+ const globalPlugins = result.filter((p) => p.source === "global");
71
+ expect(globalPlugins).toHaveLength(3);
72
+ });
73
+
74
+ it("skips files (non-directories) in plugins dir", async () => {
75
+ const pluginsDir = path.join(tmpHome, ".fops", "plugins");
76
+ fs.mkdirSync(pluginsDir, { recursive: true });
77
+ fs.writeFileSync(path.join(pluginsDir, "not-a-dir.txt"), "");
78
+
79
+ vi.resetModules();
80
+ const { discoverPlugins } = await import("./discovery.js");
81
+ const result = discoverPlugins();
82
+ const globalPlugins = result.filter((p) => p.source === "global");
83
+ expect(globalPlugins).toHaveLength(0);
84
+ });
85
+
86
+ it("returns array even when ~/.fops/plugins does not exist", async () => {
87
+ vi.resetModules();
88
+ const { discoverPlugins } = await import("./discovery.js");
89
+ const result = discoverPlugins();
90
+ expect(Array.isArray(result)).toBe(true);
91
+ });
92
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Hook execution engine.
3
+ * Collects all handlers for an event, sorts by priority (higher first), runs sequentially.
4
+ */
5
+ export async function runHook(registry, event, context = {}) {
6
+ const handlers = registry.hooks
7
+ .filter((h) => h.event === event)
8
+ .sort((a, b) => b.priority - a.priority);
9
+
10
+ for (const h of handlers) {
11
+ await h.handler(context);
12
+ }
13
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { runHook } from "./hooks.js";
3
+
4
+ describe("plugins/hooks", () => {
5
+ describe("runHook", () => {
6
+ it("runs matching handlers", async () => {
7
+ const handler = vi.fn();
8
+ const registry = {
9
+ hooks: [{ event: "pre:up", handler, priority: 0 }],
10
+ };
11
+ await runHook(registry, "pre:up", { root: "/project" });
12
+ expect(handler).toHaveBeenCalledWith({ root: "/project" });
13
+ });
14
+
15
+ it("skips non-matching events", async () => {
16
+ const handler = vi.fn();
17
+ const registry = {
18
+ hooks: [{ event: "pre:up", handler, priority: 0 }],
19
+ };
20
+ await runHook(registry, "post:up");
21
+ expect(handler).not.toHaveBeenCalled();
22
+ });
23
+
24
+ it("sorts by priority (higher first)", async () => {
25
+ const order = [];
26
+ const low = vi.fn(() => order.push("low"));
27
+ const high = vi.fn(() => order.push("high"));
28
+ const registry = {
29
+ hooks: [
30
+ { event: "pre:up", handler: low, priority: 1 },
31
+ { event: "pre:up", handler: high, priority: 10 },
32
+ ],
33
+ };
34
+ await runHook(registry, "pre:up");
35
+ expect(order).toEqual(["high", "low"]);
36
+ });
37
+
38
+ it("runs handlers sequentially (not parallel)", async () => {
39
+ const order = [];
40
+ const slow = vi.fn(async () => {
41
+ await new Promise((r) => setTimeout(r, 10));
42
+ order.push("slow");
43
+ });
44
+ const fast = vi.fn(() => order.push("fast"));
45
+ const registry = {
46
+ hooks: [
47
+ { event: "test", handler: slow, priority: 10 },
48
+ { event: "test", handler: fast, priority: 0 },
49
+ ],
50
+ };
51
+ await runHook(registry, "test");
52
+ expect(order).toEqual(["slow", "fast"]);
53
+ });
54
+
55
+ it("handles empty hooks array", async () => {
56
+ const registry = { hooks: [] };
57
+ await expect(runHook(registry, "pre:up")).resolves.toBeUndefined();
58
+ });
59
+
60
+ it("passes context to all handlers", async () => {
61
+ const h1 = vi.fn();
62
+ const h2 = vi.fn();
63
+ const ctx = { root: "/proj", extra: "data" };
64
+ const registry = {
65
+ hooks: [
66
+ { event: "test", handler: h1, priority: 0 },
67
+ { event: "test", handler: h2, priority: 0 },
68
+ ],
69
+ };
70
+ await runHook(registry, "test", ctx);
71
+ expect(h1).toHaveBeenCalledWith(ctx);
72
+ expect(h2).toHaveBeenCalledWith(ctx);
73
+ });
74
+
75
+ it("uses empty object as default context", async () => {
76
+ const handler = vi.fn();
77
+ const registry = {
78
+ hooks: [{ event: "test", handler, priority: 0 }],
79
+ };
80
+ await runHook(registry, "test");
81
+ expect(handler).toHaveBeenCalledWith({});
82
+ });
83
+
84
+ it("handles three priority levels correctly", async () => {
85
+ const order = [];
86
+ const registry = {
87
+ hooks: [
88
+ { event: "e", handler: () => order.push("med"), priority: 5 },
89
+ { event: "e", handler: () => order.push("low"), priority: 1 },
90
+ { event: "e", handler: () => order.push("high"), priority: 10 },
91
+ ],
92
+ };
93
+ await runHook(registry, "e");
94
+ expect(order).toEqual(["high", "med", "low"]);
95
+ });
96
+
97
+ it("only runs handlers for the exact event name", async () => {
98
+ const h1 = vi.fn();
99
+ const h2 = vi.fn();
100
+ const registry = {
101
+ hooks: [
102
+ { event: "pre:up", handler: h1, priority: 0 },
103
+ { event: "pre:down", handler: h2, priority: 0 },
104
+ ],
105
+ };
106
+ await runHook(registry, "pre:up");
107
+ expect(h1).toHaveBeenCalled();
108
+ expect(h2).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it("handler errors propagate", async () => {
112
+ const registry = {
113
+ hooks: [{ event: "test", handler: () => { throw new Error("boom"); }, priority: 0 }],
114
+ };
115
+ await expect(runHook(registry, "test")).rejects.toThrow("boom");
116
+ });
117
+ });
118
+ });
@@ -0,0 +1,3 @@
1
+ export { loadPlugins } from "./loader.js";
2
+ export { runHook } from "./hooks.js";
3
+ export { loadSkills } from "./skills.js";
@@ -0,0 +1,110 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createRegistry } from "./registry.js";
5
+ import { validateManifest } from "./manifest.js";
6
+ import { discoverPlugins } from "./discovery.js";
7
+ import { createPluginApi } from "./api.js";
8
+
9
+ /**
10
+ * Parse SKILL.md frontmatter (lightweight, same logic as skills.js).
11
+ */
12
+ function parseSkillFrontmatter(content) {
13
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
14
+ if (!match) return { meta: {}, body: content };
15
+ const meta = {};
16
+ for (const line of match[1].split("\n")) {
17
+ const kv = line.match(/^(\w+)\s*:\s*(.+)/);
18
+ if (kv) meta[kv[1]] = kv[2].trim();
19
+ }
20
+ return { meta, body: match[2] };
21
+ }
22
+
23
+ /**
24
+ * Check if a plugin is enabled in ~/.fops.json.
25
+ * Default: enabled unless explicitly set to false.
26
+ */
27
+ function isPluginEnabled(pluginId) {
28
+ try {
29
+ const fopsConfig = path.join(os.homedir(), ".fops.json");
30
+ if (!fs.existsSync(fopsConfig)) return true;
31
+ const raw = JSON.parse(fs.readFileSync(fopsConfig, "utf8"));
32
+ const entry = raw?.plugins?.entries?.[pluginId];
33
+ if (entry && entry.enabled === false) return false;
34
+ } catch {
35
+ // ignore parse errors
36
+ }
37
+ return true;
38
+ }
39
+
40
+ /**
41
+ * Load and activate all discovered plugins.
42
+ * Returns a populated PluginRegistry.
43
+ */
44
+ export async function loadPlugins() {
45
+ const registry = createRegistry();
46
+ const candidates = discoverPlugins();
47
+
48
+ for (const candidate of candidates) {
49
+ // Validate manifest
50
+ const manifest = validateManifest(candidate.path);
51
+ if (!manifest) continue;
52
+
53
+ // Check enable/disable state
54
+ if (!isPluginEnabled(manifest.id)) continue;
55
+
56
+ // Load plugin skills from manifest "skills" array
57
+ if (manifest.skills && Array.isArray(manifest.skills)) {
58
+ for (const skillRelPath of manifest.skills) {
59
+ const skillMd = path.join(candidate.path, skillRelPath, "SKILL.md");
60
+ if (fs.existsSync(skillMd)) {
61
+ const raw = fs.readFileSync(skillMd, "utf8");
62
+ const { meta, body } = parseSkillFrontmatter(raw);
63
+ registry.skills.push({
64
+ pluginId: manifest.id,
65
+ name: meta.name || path.basename(skillRelPath),
66
+ description: meta.description || "",
67
+ content: body.trim(),
68
+ });
69
+ }
70
+ }
71
+ }
72
+
73
+ // Dynamic import the plugin entry point
74
+ const entryPoint = path.join(candidate.path, "index.js");
75
+ if (!fs.existsSync(entryPoint)) {
76
+ // Plugin with only skills and no code is valid
77
+ registry.plugins.push({
78
+ id: manifest.id,
79
+ name: manifest.name,
80
+ version: manifest.version,
81
+ path: candidate.path,
82
+ source: candidate.source,
83
+ });
84
+ continue;
85
+ }
86
+
87
+ try {
88
+ const mod = await import(entryPoint);
89
+ const plugin = mod.default || mod;
90
+
91
+ if (typeof plugin.register === "function") {
92
+ const api = createPluginApi(manifest.id, registry);
93
+ await plugin.register(api);
94
+ }
95
+
96
+ registry.plugins.push({
97
+ id: manifest.id,
98
+ name: manifest.name,
99
+ version: manifest.version,
100
+ path: candidate.path,
101
+ source: candidate.source,
102
+ });
103
+ } catch (err) {
104
+ // Skip plugins that fail to load — log for debugging
105
+ console.error(` Plugin "${manifest.id}" failed to load: ${err.message}`);
106
+ }
107
+ }
108
+
109
+ return registry;
110
+ }
@@ -0,0 +1,26 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const REQUIRED_FIELDS = ["id", "name", "version"];
5
+
6
+ /**
7
+ * Read and validate fops.plugin.json from a plugin directory.
8
+ * Returns the parsed manifest or null if invalid.
9
+ */
10
+ export function validateManifest(pluginPath) {
11
+ const manifestPath = path.join(pluginPath, "fops.plugin.json");
12
+ if (!fs.existsSync(manifestPath)) return null;
13
+
14
+ let manifest;
15
+ try {
16
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
17
+ } catch {
18
+ return null;
19
+ }
20
+
21
+ for (const field of REQUIRED_FIELDS) {
22
+ if (!manifest[field] || typeof manifest[field] !== "string") return null;
23
+ }
24
+
25
+ return manifest;
26
+ }
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { validateManifest } from "./manifest.js";
6
+
7
+ describe("plugins/manifest", () => {
8
+ let tmpDir;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fops-manifest-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, { recursive: true, force: true });
16
+ });
17
+
18
+ it("returns null when no fops.plugin.json exists", () => {
19
+ expect(validateManifest(tmpDir)).toBe(null);
20
+ });
21
+
22
+ it("returns null for invalid JSON", () => {
23
+ fs.writeFileSync(path.join(tmpDir, "fops.plugin.json"), "not json{{{");
24
+ expect(validateManifest(tmpDir)).toBe(null);
25
+ });
26
+
27
+ it("returns null when id is missing", () => {
28
+ fs.writeFileSync(
29
+ path.join(tmpDir, "fops.plugin.json"),
30
+ JSON.stringify({ name: "Test", version: "1.0.0" })
31
+ );
32
+ expect(validateManifest(tmpDir)).toBe(null);
33
+ });
34
+
35
+ it("returns null when name is missing", () => {
36
+ fs.writeFileSync(
37
+ path.join(tmpDir, "fops.plugin.json"),
38
+ JSON.stringify({ id: "test", version: "1.0.0" })
39
+ );
40
+ expect(validateManifest(tmpDir)).toBe(null);
41
+ });
42
+
43
+ it("returns null when version is missing", () => {
44
+ fs.writeFileSync(
45
+ path.join(tmpDir, "fops.plugin.json"),
46
+ JSON.stringify({ id: "test", name: "Test" })
47
+ );
48
+ expect(validateManifest(tmpDir)).toBe(null);
49
+ });
50
+
51
+ it("returns null when fields are not strings", () => {
52
+ fs.writeFileSync(
53
+ path.join(tmpDir, "fops.plugin.json"),
54
+ JSON.stringify({ id: "test", name: "Test", version: 1 })
55
+ );
56
+ expect(validateManifest(tmpDir)).toBe(null);
57
+ });
58
+
59
+ it("returns null when id is empty string", () => {
60
+ fs.writeFileSync(
61
+ path.join(tmpDir, "fops.plugin.json"),
62
+ JSON.stringify({ id: "", name: "Test", version: "1.0.0" })
63
+ );
64
+ expect(validateManifest(tmpDir)).toBe(null);
65
+ });
66
+
67
+ it("returns null when fields are numbers", () => {
68
+ fs.writeFileSync(
69
+ path.join(tmpDir, "fops.plugin.json"),
70
+ JSON.stringify({ id: 1, name: 2, version: 3 })
71
+ );
72
+ expect(validateManifest(tmpDir)).toBe(null);
73
+ });
74
+
75
+ it("returns manifest when all required fields present", () => {
76
+ const manifest = { id: "test-plugin", name: "Test Plugin", version: "1.0.0" };
77
+ fs.writeFileSync(path.join(tmpDir, "fops.plugin.json"), JSON.stringify(manifest));
78
+ const result = validateManifest(tmpDir);
79
+ expect(result).toEqual(manifest);
80
+ });
81
+
82
+ it("preserves extra fields in manifest", () => {
83
+ const manifest = {
84
+ id: "test-plugin",
85
+ name: "Test Plugin",
86
+ version: "1.0.0",
87
+ description: "A test",
88
+ author: "test",
89
+ skills: ["skills/foo"],
90
+ };
91
+ fs.writeFileSync(path.join(tmpDir, "fops.plugin.json"), JSON.stringify(manifest));
92
+ const result = validateManifest(tmpDir);
93
+ expect(result.description).toBe("A test");
94
+ expect(result.skills).toEqual(["skills/foo"]);
95
+ });
96
+
97
+ it("returns null for empty JSON object", () => {
98
+ fs.writeFileSync(path.join(tmpDir, "fops.plugin.json"), "{}");
99
+ expect(validateManifest(tmpDir)).toBe(null);
100
+ });
101
+
102
+ it("returns null for empty file", () => {
103
+ fs.writeFileSync(path.join(tmpDir, "fops.plugin.json"), "");
104
+ expect(validateManifest(tmpDir)).toBe(null);
105
+ });
106
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * PluginRegistry — flat store for all registered plugin extensions.
3
+ * Created once at startup, passed through the system.
4
+ */
5
+
6
+ export function createRegistry() {
7
+ return {
8
+ plugins: [], // { id, name, version, path, source }
9
+ commands: [], // { pluginId, spec }
10
+ doctorChecks: [], // { pluginId, name, fn }
11
+ hooks: [], // { pluginId, event, handler, priority }
12
+ skills: [], // { pluginId, name, description, content }
13
+ };
14
+ }