From 624452d31cbb294748c2d6cae648e59f00338156 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Fri, 24 Apr 2026 19:07:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20plugin=20management=20commands=20?= =?UTF-8?q?=E2=80=94=20add,=20list,=20remove=20(#605)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New CLI commands for managing external chart and connector plugins: neoboard plugin add Install, validate, register neoboard plugin list Show built-in + external plugins neoboard plugin remove Unregister and uninstall Validation pipeline (runs after npm install): - Checks package resolves and has a valid export - Validates required fields: type, label - Auto-detects chart (has transform) vs connector (has createModule) - Chart: validates compatibleWith array - Connector: validates category enum - Auto-rollback: uninstalls package on validation failure Infrastructure: - cli/src/lib/plugin-validator.ts — export validation (10 tests) - cli/src/lib/manifest.ts — read/write/remove manifest entries (9 tests) - cli/src/commands/plugin.ts — add, list, remove implementations Closes #605 Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/lib/manifest.test.ts | 133 +++++++++ .../__tests__/lib/plugin-validator.test.ts | 136 +++++++++ cli/src/commands/plugin.ts | 270 ++++++++++++++++++ cli/src/index.ts | 38 +++ cli/src/lib/manifest.ts | 64 +++++ cli/src/lib/plugin-validator.ts | 94 ++++++ 6 files changed, 735 insertions(+) create mode 100644 cli/src/__tests__/lib/manifest.test.ts create mode 100644 cli/src/__tests__/lib/plugin-validator.test.ts create mode 100644 cli/src/commands/plugin.ts create mode 100644 cli/src/lib/manifest.ts create mode 100644 cli/src/lib/plugin-validator.ts diff --git a/cli/src/__tests__/lib/manifest.test.ts b/cli/src/__tests__/lib/manifest.test.ts new file mode 100644 index 00000000..56e03fb8 --- /dev/null +++ b/cli/src/__tests__/lib/manifest.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + readManifest, + addToManifest, + removeFromManifest, +} from "../../lib/manifest.js"; + +describe("manifest", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), "neoboard-test-" + Date.now()); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("readManifest", () => { + it("returns empty arrays when file does not exist", () => { + const result = readManifest(join(tempDir, "missing.json"), "plugins"); + expect(result).toEqual([]); + }); + + it("reads existing plugins", () => { + const path = join(tempDir, "plugins.json"); + writeFileSync( + path, + JSON.stringify({ + plugins: [{ package: "@myorg/heatmap" }], + }), + ); + const result = readManifest(path, "plugins"); + expect(result).toHaveLength(1); + expect(result[0].package).toBe("@myorg/heatmap"); + }); + + it("reads existing connectors", () => { + const path = join(tempDir, "connectors.json"); + writeFileSync( + path, + JSON.stringify({ + connectors: [{ package: "@myorg/mongodb" }], + }), + ); + const result = readManifest(path, "connectors"); + expect(result).toHaveLength(1); + expect(result[0].package).toBe("@myorg/mongodb"); + }); + }); + + describe("addToManifest", () => { + it("creates file and adds entry when file does not exist", () => { + const path = join(tempDir, "new.json"); + addToManifest(path, "plugins", { package: "@myorg/heatmap" }); + const result = readManifest(path, "plugins"); + expect(result).toHaveLength(1); + expect(result[0].package).toBe("@myorg/heatmap"); + }); + + it("appends to existing entries", () => { + const path = join(tempDir, "existing.json"); + writeFileSync( + path, + JSON.stringify({ plugins: [{ package: "@myorg/a" }] }), + ); + addToManifest(path, "plugins", { package: "@myorg/b" }); + const result = readManifest(path, "plugins"); + expect(result).toHaveLength(2); + }); + + it("does not duplicate existing package", () => { + const path = join(tempDir, "dup.json"); + writeFileSync( + path, + JSON.stringify({ plugins: [{ package: "@myorg/a" }] }), + ); + addToManifest(path, "plugins", { package: "@myorg/a" }); + const result = readManifest(path, "plugins"); + expect(result).toHaveLength(1); + }); + + it("supports overrides flag", () => { + const path = join(tempDir, "override.json"); + addToManifest(path, "plugins", { + package: "@myorg/bar", + overrides: true, + }); + const result = readManifest(path, "plugins"); + expect(result[0].overrides).toBe(true); + }); + }); + + describe("removeFromManifest", () => { + it("removes an entry by package name", () => { + const path = join(tempDir, "remove.json"); + writeFileSync( + path, + JSON.stringify({ + plugins: [{ package: "@myorg/a" }, { package: "@myorg/b" }], + }), + ); + const removed = removeFromManifest(path, "plugins", "@myorg/a"); + expect(removed).toBe(true); + const result = readManifest(path, "plugins"); + expect(result).toHaveLength(1); + expect(result[0].package).toBe("@myorg/b"); + }); + + it("returns false when package not found", () => { + const path = join(tempDir, "notfound.json"); + writeFileSync( + path, + JSON.stringify({ plugins: [{ package: "@myorg/a" }] }), + ); + const removed = removeFromManifest(path, "plugins", "@myorg/missing"); + expect(removed).toBe(false); + }); + + it("returns false when file does not exist", () => { + const removed = removeFromManifest( + join(tempDir, "missing.json"), + "plugins", + "@myorg/a", + ); + expect(removed).toBe(false); + }); + }); +}); diff --git a/cli/src/__tests__/lib/plugin-validator.test.ts b/cli/src/__tests__/lib/plugin-validator.test.ts new file mode 100644 index 00000000..aa3f0edd --- /dev/null +++ b/cli/src/__tests__/lib/plugin-validator.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { + validatePluginExport, + detectPluginType, +} from "../../lib/plugin-validator.js"; + +describe("validatePluginExport", () => { + it("accepts a valid chart plugin", () => { + const plugin = { + type: "heatmap", + label: "Heatmap", + component: () => null, + transform: (d: unknown) => d, + compatibleWith: ["neo4j", "postgresql"], + }; + const result = validatePluginExport(plugin); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("accepts a valid connector plugin", () => { + const plugin = { + type: "mongodb", + label: "MongoDB", + category: "database", + createModule: () => ({}), + }; + const result = validatePluginExport(plugin); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("rejects null", () => { + const result = validatePluginExport(null); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("must be an object"); + }); + + it("rejects missing type", () => { + const result = validatePluginExport({ label: "X", transform: () => {} }); + expect(result.valid).toBe(false); + expect(result.errors).toContain('"type" must be a non-empty string'); + }); + + it("rejects missing label", () => { + const result = validatePluginExport({ type: "x", transform: () => {} }); + expect(result.valid).toBe(false); + expect(result.errors).toContain('"label" must be a non-empty string'); + }); + + it("rejects empty type", () => { + const result = validatePluginExport({ + type: "", + label: "X", + transform: () => {}, + }); + expect(result.valid).toBe(false); + expect(result.errors).toContain('"type" must be a non-empty string'); + }); + + it("rejects plugin with neither transform nor createModule", () => { + const result = validatePluginExport({ type: "x", label: "X" }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Not a valid NeoBoard plugin"); + }); + + it("rejects chart plugin missing compatibleWith", () => { + const result = validatePluginExport({ + type: "x", + label: "X", + transform: () => {}, + }); + expect(result.valid).toBe(false); + expect(result.errors).toContain( + '"compatibleWith" must be a non-empty array of connector types', + ); + }); + + it("rejects connector plugin missing category", () => { + const result = validatePluginExport({ + type: "x", + label: "X", + createModule: () => ({}), + }); + expect(result.valid).toBe(false); + expect(result.errors).toContain( + '"category" must be one of: database, graph, api, file', + ); + }); + + it("rejects connector with invalid category", () => { + const result = validatePluginExport({ + type: "x", + label: "X", + category: "invalid", + createModule: () => ({}), + }); + expect(result.valid).toBe(false); + expect(result.errors).toContain( + '"category" must be one of: database, graph, api, file', + ); + }); +}); + +describe("detectPluginType", () => { + it("detects chart plugin", () => { + expect( + detectPluginType({ type: "x", label: "X", transform: () => {} }), + ).toBe("chart"); + }); + + it("detects connector plugin", () => { + expect( + detectPluginType({ + type: "x", + label: "X", + createModule: () => ({}), + }), + ).toBe("connector"); + }); + + it("returns null for ambiguous (both)", () => { + expect( + detectPluginType({ + type: "x", + label: "X", + transform: () => {}, + createModule: () => ({}), + }), + ).toBe(null); + }); + + it("returns null for neither", () => { + expect(detectPluginType({ type: "x", label: "X" })).toBe(null); + }); +}); diff --git a/cli/src/commands/plugin.ts b/cli/src/commands/plugin.ts new file mode 100644 index 00000000..2fef58db --- /dev/null +++ b/cli/src/commands/plugin.ts @@ -0,0 +1,270 @@ +import { join } from "node:path"; +import { findProjectRoot } from "../lib/config.js"; +import { run } from "../lib/exec.js"; +import { + success, + info, + error as logError, + warn, + createSpinner, +} from "../lib/output.js"; +import { + readManifest, + addToManifest, + removeFromManifest, +} from "../lib/manifest.js"; +import { + validatePluginExport, + detectPluginType, +} from "../lib/plugin-validator.js"; + +const PLUGINS_MANIFEST = "neoboard-plugins.json"; +const CONNECTORS_MANIFEST = "neoboard-connectors.json"; + +/** + * Install an npm package, validate it as a NeoBoard plugin, and register it. + */ +export async function runPluginAdd( + packageName: string, + opts?: { override?: boolean; export?: string }, +): Promise { + const root = findProjectRoot(); + const overrides = opts?.override ?? false; + const exportName = opts?.export ?? "default"; + + // 1. Install the package + const spinner = createSpinner("Installing " + packageName + "..."); + spinner.start(); + try { + run("npm install " + packageName, { cwd: root }); + spinner.succeed("Installed " + packageName); + } catch (err) { + spinner.fail("Failed to install " + packageName); + logError(String(err)); + process.exitCode = 1; + return; + } + + // 2. Try to load and validate the export + let exported: unknown; + try { + const mod = await import(packageName); + exported = + exportName === "default" ? (mod.default ?? mod) : mod[exportName]; + } catch (err) { + logError("Failed to import " + packageName + ": " + String(err)); + rollback(packageName, root); + return; + } + + if (!exported) { + logError( + 'Package "' + + packageName + + '" has no ' + + (exportName === "default" ? "default" : '"' + exportName + '"') + + " export.", + ); + rollback(packageName, root); + return; + } + + const validation = validatePluginExport(exported); + if (!validation.valid) { + logError('Package "' + packageName + '" is not a valid NeoBoard plugin:'); + for (const e of validation.errors) { + logError(" - " + e); + } + rollback(packageName, root); + return; + } + + const pluginType = validation.pluginType!; + const obj = exported as Record; + const pluginLabel = String(obj.type); + + // 3. Register in the appropriate manifest + const manifestFile = + pluginType === "chart" ? PLUGINS_MANIFEST : CONNECTORS_MANIFEST; + const manifestKey = pluginType === "chart" ? "plugins" : "connectors"; + const manifestPath = join(root, manifestFile); + + const entry = { + package: packageName, + ...(exportName !== "default" ? { export: exportName } : {}), + ...(overrides ? { overrides: true } : {}), + }; + + const added = addToManifest( + manifestPath, + manifestKey as "plugins" | "connectors", + entry, + ); + if (!added) { + warn( + packageName + " is already registered in " + manifestFile + ". Skipping.", + ); + } + + // 4. Run codegen + const codegenScript = + pluginType === "chart" + ? "scripts/generate-plugin-imports.mjs" + : "scripts/generate-connector-imports.mjs"; + + try { + run("node " + codegenScript, { cwd: root }); + } catch { + warn("Codegen script failed. Run manually: node " + codegenScript); + } + + success( + 'Plugin "' + + pluginLabel + + '" registered as ' + + pluginType + + " in " + + manifestFile, + ); +} + +/** + * List all registered plugins (built-in chart types + external from manifests). + */ +export function runPluginList(): void { + const root = findProjectRoot(); + + // Built-in chart types (hardcoded — mirrors chart-types.ts) + const builtInCharts = [ + "bar", + "line", + "pie", + "table", + "single-value", + "graph", + "map", + "json", + "parameter-select", + "form", + "markdown", + "iframe", + "gauge", + "sankey", + "sunburst", + "radar", + "treemap", + "gantt", + ]; + + const builtInConnectors = ["neo4j", "postgresql"]; + + const externalCharts = readManifest(join(root, PLUGINS_MANIFEST), "plugins"); + const externalConnectors = readManifest( + join(root, CONNECTORS_MANIFEST), + "connectors", + ); + + info( + "Charts (" + + builtInCharts.length + + " built-in, " + + externalCharts.length + + " external):", + ); + for (const type of builtInCharts) { + console.log(" " + type.padEnd(20) + "built-in"); + } + for (const ext of externalCharts) { + console.log( + " " + + ext.package.padEnd(20) + + "external" + + (ext.overrides ? " (overrides)" : ""), + ); + } + + console.log(""); + info( + "Connectors (" + + builtInConnectors.length + + " built-in, " + + externalConnectors.length + + " external):", + ); + for (const type of builtInConnectors) { + console.log(" " + type.padEnd(20) + "built-in"); + } + for (const ext of externalConnectors) { + console.log( + " " + + ext.package.padEnd(20) + + "external" + + (ext.overrides ? " (overrides)" : ""), + ); + } +} + +/** + * Remove an external plugin by package name and uninstall it. + */ +export async function runPluginRemove(packageName: string): Promise { + const root = findProjectRoot(); + + // Try both manifests + let removed = removeFromManifest( + join(root, PLUGINS_MANIFEST), + "plugins", + packageName, + ); + let manifestType: "chart" | "connector" = "chart"; + + if (!removed) { + removed = removeFromManifest( + join(root, CONNECTORS_MANIFEST), + "connectors", + packageName, + ); + manifestType = "connector"; + } + + if (!removed) { + logError( + 'Package "' + + packageName + + '" is not registered as an external plugin. Cannot remove built-in plugins.', + ); + process.exitCode = 1; + return; + } + + // Run codegen + const codegenScript = + manifestType === "chart" + ? "scripts/generate-plugin-imports.mjs" + : "scripts/generate-connector-imports.mjs"; + + try { + run("node " + codegenScript, { cwd: root }); + } catch { + warn("Codegen script failed. Run manually: node " + codegenScript); + } + + // Uninstall the package + try { + run("npm uninstall " + packageName, { cwd: root }); + } catch { + warn("npm uninstall failed. Run manually: npm uninstall " + packageName); + } + + success('Plugin "' + packageName + '" removed'); +} + +function rollback(packageName: string, root: string): void { + warn("Rolling back: uninstalling " + packageName); + try { + run("npm uninstall " + packageName, { cwd: root }); + } catch { + // best effort + } + process.exitCode = 1; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 23fea4b5..4d2a7843 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -198,6 +198,44 @@ config runConfigSet(key, value); }); +// plugin subcommand group + +const plugin = program + .command("plugin") + .description("Manage external chart and connector plugins"); + +plugin + .command("add ") + .description( + "Install and register an external plugin\n" + + " Auto-detects chart vs connector from the package export", + ) + .option("--override", "Allow replacing a built-in plugin") + .option("--export ", "Named export to use (default: default)") + .action(async (packageName, opts) => { + const { runPluginAdd } = await import("./commands/plugin.js"); + await runPluginAdd(packageName, { + override: opts.override, + export: opts.export, + }); + }); + +plugin + .command("list") + .description("Show all registered plugins (built-in + external)") + .action(async () => { + const { runPluginList } = await import("./commands/plugin.js"); + runPluginList(); + }); + +plugin + .command("remove ") + .description("Unregister and uninstall an external plugin") + .action(async (packageName) => { + const { runPluginRemove } = await import("./commands/plugin.js"); + await runPluginRemove(packageName); + }); + // logs command program diff --git a/cli/src/lib/manifest.ts b/cli/src/lib/manifest.ts new file mode 100644 index 00000000..4ee17b81 --- /dev/null +++ b/cli/src/lib/manifest.ts @@ -0,0 +1,64 @@ +/** + * Read/write helpers for neoboard-plugins.json and neoboard-connectors.json. + */ + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; + +export interface ManifestEntry { + package: string; + export?: string; + overrides?: boolean; +} + +type ManifestKey = "plugins" | "connectors"; + +/** + * Read entries from a manifest file. Returns empty array if file missing. + */ +export function readManifest( + filePath: string, + key: ManifestKey, +): ManifestEntry[] { + if (!existsSync(filePath)) return []; + try { + const raw = JSON.parse(readFileSync(filePath, "utf-8")); + return Array.isArray(raw[key]) ? raw[key] : []; + } catch { + return []; + } +} + +/** + * Add an entry to a manifest file. Creates the file if missing. + * Skips if the package is already registered. + */ +export function addToManifest( + filePath: string, + key: ManifestKey, + entry: ManifestEntry, +): boolean { + const entries = readManifest(filePath, key); + if (entries.some((e) => e.package === entry.package)) { + return false; // already exists + } + entries.push(entry); + writeFileSync(filePath, JSON.stringify({ [key]: entries }, null, 2) + "\n"); + return true; +} + +/** + * Remove an entry from a manifest file by package name. + * Returns true if the entry was found and removed. + */ +export function removeFromManifest( + filePath: string, + key: ManifestKey, + packageName: string, +): boolean { + if (!existsSync(filePath)) return false; + const entries = readManifest(filePath, key); + const filtered = entries.filter((e) => e.package !== packageName); + if (filtered.length === entries.length) return false; // not found + writeFileSync(filePath, JSON.stringify({ [key]: filtered }, null, 2) + "\n"); + return true; +} diff --git a/cli/src/lib/plugin-validator.ts b/cli/src/lib/plugin-validator.ts new file mode 100644 index 00000000..135e6772 --- /dev/null +++ b/cli/src/lib/plugin-validator.ts @@ -0,0 +1,94 @@ +/** + * Validates an exported plugin object from an npm package. + * Checks that it has the required fields for either a chart or connector plugin. + */ + +const VALID_CATEGORIES = ["database", "graph", "api", "file"] as const; + +export interface ValidationResult { + valid: boolean; + errors: string[]; + pluginType?: "chart" | "connector"; +} + +/** + * Detect whether an export is a chart plugin, connector plugin, or neither. + */ +export function detectPluginType( + obj: Record, +): "chart" | "connector" | null { + const hasTransform = typeof obj.transform === "function"; + const hasCreateModule = typeof obj.createModule === "function"; + + if (hasTransform && hasCreateModule) return null; // ambiguous + if (hasTransform) return "chart"; + if (hasCreateModule) return "connector"; + return null; +} + +/** + * Validate a plugin export object. + * Returns a list of specific validation errors, or an empty list if valid. + */ +export function validatePluginExport(exported: unknown): ValidationResult { + if (!exported || typeof exported !== "object" || Array.isArray(exported)) { + return { valid: false, errors: ["Plugin export must be an object"] }; + } + + const obj = exported as Record; + const errors: string[] = []; + + // Required fields for all plugins + if (typeof obj.type !== "string" || obj.type.trim() === "") { + errors.push('"type" must be a non-empty string'); + } + if (typeof obj.label !== "string" || obj.label.trim() === "") { + errors.push('"label" must be a non-empty string'); + } + + // Detect type + const pluginType = detectPluginType(obj); + + if (pluginType === null) { + const hasTransform = typeof obj.transform === "function"; + const hasCreateModule = typeof obj.createModule === "function"; + if (hasTransform && hasCreateModule) { + errors.push( + "Ambiguous plugin: has both transform (chart) and createModule (connector). " + + 'Set "neoboard": { "type": "chart" | "connector" } in package.json to disambiguate.', + ); + } else { + errors.push( + "Not a valid NeoBoard plugin: must export either a transform function (chart) " + + "or a createModule function (connector).", + ); + } + return { valid: false, errors }; + } + + // Type-specific validation + if (pluginType === "chart") { + if (!Array.isArray(obj.compatibleWith) || obj.compatibleWith.length === 0) { + errors.push( + '"compatibleWith" must be a non-empty array of connector types', + ); + } + } + + if (pluginType === "connector") { + if ( + typeof obj.category !== "string" || + !VALID_CATEGORIES.includes( + obj.category as (typeof VALID_CATEGORIES)[number], + ) + ) { + errors.push('"category" must be one of: ' + VALID_CATEGORIES.join(", ")); + } + } + + return { + valid: errors.length === 0, + errors, + pluginType: errors.length === 0 ? pluginType : undefined, + }; +}