feat(FN-1080): add node management APIs and CLI commands

- Add dashboard /api/nodes CRUD, health-check, and metrics routes backed by CentralCore with request validation
- Extend project updates and frontend API types to support node assignment via nodeId
- Add dashboard API client helpers for node list/register/update/delete/health/metrics operations
- Implement fn node list/add/remove/show/health commands with validation, confirmation, and table/JSON output
- Add route and CLI/bin tests plus a minor @gsxdsm/fusion changeset for node management support
This commit is contained in:
gsxdsm
2026-04-07 22:28:58 -07:00
parent a0a7a9c1db
commit b5980ebbb3
8 changed files with 1277 additions and 5 deletions

View File

@@ -17,6 +17,11 @@ const runProjectShow = vi.fn();
const runProjectInfo = vi.fn();
const runProjectSetDefault = vi.fn();
const runProjectDetect = vi.fn();
const runNodeList = vi.fn();
const runNodeAdd = vi.fn();
const runNodeRemove = vi.fn();
const runNodeShow = vi.fn();
const runNodeHealth = vi.fn();
vi.mock("../commands/dashboard.js", () => ({
runDashboard: vi.fn(),
@@ -81,6 +86,14 @@ vi.mock("../commands/project.js", () => ({
runProjectDetect,
}));
vi.mock("../commands/node.js", () => ({
runNodeList,
runNodeAdd,
runNodeRemove,
runNodeShow,
runNodeHealth,
}));
describe("bin", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
@@ -188,6 +201,36 @@ describe("bin", () => {
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: project wat");
});
it("routes node subcommands and aliases", async () => {
await runBin(["node", "list"]);
await runBin(["node", "ls", "--json"]);
expect(runNodeList).toHaveBeenNthCalledWith(1, { json: false });
expect(runNodeList).toHaveBeenNthCalledWith(2, { json: true });
await runBin(["node", "add", "my-node", "--url", "https://node.example.com", "--api-key", "abc", "--max-concurrent", "3"]);
expect(runNodeAdd).toHaveBeenCalledWith("my-node", {
url: "https://node.example.com",
apiKey: "abc",
maxConcurrent: 3,
});
await runBin(["node", "remove", "my-node", "--force"]);
await runBin(["node", "rm", "my-node", "--force"]);
expect(runNodeRemove).toHaveBeenCalledWith("my-node", { force: true });
await runBin(["node", "show", "my-node"]);
await runBin(["node", "info", "my-node"]);
expect(runNodeShow).toHaveBeenCalledWith("my-node");
await runBin(["node", "health", "my-node"]);
expect(runNodeHealth).toHaveBeenCalledWith("my-node");
});
it("rejects unknown node subcommands", async () => {
await expect(runBin(["node", "wat"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: node wat");
});
it("rejects duplicate --project flags", async () => {
await expect(runBin(["task", "list", "--project", "one", "-P", "two"]))
.rejects.toThrow("Duplicate --project flag. Specify a project only once.");
@@ -204,6 +247,7 @@ describe("bin", () => {
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(help).toContain("fn project list | ls");
expect(help).toContain("fn node list | ls");
expect(runTaskList).not.toHaveBeenCalled();
});
@@ -222,6 +266,7 @@ describe("bin", () => {
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(help).toContain("fn project list | ls");
expect(help).toContain("fn node list | ls");
expect(help).toContain("fn task comments <id>");
expect(help).toContain("--project, -P <name>");
});

View File

@@ -47,6 +47,7 @@ const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./co
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
const { runNodeList, runNodeAdd, runNodeRemove, runNodeShow, runNodeHealth } = await import("./commands/node.js");
const { runInit } = await import("./commands/init.js");
const { runAgentStop, runAgentStart } = await import("./commands/agent.js");
const { runAgentImport } = await import("./commands/agent-import.js");
@@ -100,6 +101,13 @@ Usage:
fn project set-default | default <name>
Set default project
fn project detect Detect project from current directory
fn node list | ls [--json] List all nodes
fn node add <name> [--url <url>] [--api-key <key>] [--max-concurrent <n>]
Register a new node
fn node remove | rm <name> [--force]
Unregister a node
fn node show | info [name] Show node details
fn node health <name> Check node health
fn settings Show current Fusion configuration
fn settings set <key> <value> Update a configuration setting
fn settings export [opts] Export settings to a JSON file
@@ -170,6 +178,30 @@ function extractGlobalProjectFlag(argv: string[]): { cleanedArgs: string[]; proj
return { cleanedArgs, projectName };
}
function getFlagValue(args: string[], flag: string): string | undefined {
const index = args.indexOf(flag);
if (index === -1 || index + 1 >= args.length) {
return undefined;
}
const value = args[index + 1];
if (!value || value.startsWith("-")) {
return undefined;
}
return value;
}
function getFlagValueNumber(args: string[], flag: string): number | undefined {
const value = getFlagValue(args, flag);
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
/**
* Check if migration is needed and run it automatically.
* This handles the transition from single-project to multi-project mode.
@@ -319,6 +351,46 @@ async function main() {
break;
}
case "node": {
const subcommand = args[1];
switch (subcommand) {
case "list":
case "ls": {
await runNodeList({ json: args.includes("--json") });
break;
}
case "add": {
const name = args[2];
await runNodeAdd(name, {
url: getFlagValue(args, "--url"),
apiKey: getFlagValue(args, "--api-key"),
maxConcurrent: getFlagValueNumber(args, "--max-concurrent"),
});
break;
}
case "remove":
case "rm": {
const name = args[2];
await runNodeRemove(name, { force: args.includes("--force") });
break;
}
case "show":
case "info": {
await runNodeShow(args[2]);
break;
}
case "health": {
await runNodeHealth(args[2]);
break;
}
default:
console.error(`Unknown subcommand: node ${subcommand || ""}`);
console.log("Try: fn node list | add | remove | show | health");
process.exit(1);
}
break;
}
case "task": {
const subcommand = args[1];
switch (subcommand) {

View File

@@ -0,0 +1,240 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockClose = vi.fn().mockResolvedValue(undefined);
const mockListNodes = vi.fn();
const mockRegisterNode = vi.fn();
const mockGetNode = vi.fn();
const mockGetNodeByName = vi.fn();
const mockUnregisterNode = vi.fn();
const mockCheckNodeHealth = vi.fn();
const mockQuestion = vi.fn();
const mockRlClose = vi.fn();
vi.mock("@fusion/core", () => ({
CentralCore: vi.fn().mockImplementation(() => ({
init: mockInit,
close: mockClose,
listNodes: mockListNodes,
registerNode: mockRegisterNode,
getNode: mockGetNode,
getNodeByName: mockGetNodeByName,
unregisterNode: mockUnregisterNode,
checkNodeHealth: mockCheckNodeHealth,
})),
}));
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn().mockImplementation(() => ({
question: mockQuestion,
close: mockRlClose,
})),
}));
import {
runNodeList,
runNodeAdd,
runNodeRemove,
runNodeShow,
runNodeHealth,
} from "../node.js";
function makeNode(overrides: Record<string, unknown> = {}) {
return {
id: "node_123",
name: "local-node",
type: "local",
status: "offline",
maxConcurrent: 2,
capabilities: ["executor"],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("node commands", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as never);
beforeEach(() => {
vi.clearAllMocks();
mockListNodes.mockResolvedValue([]);
mockRegisterNode.mockResolvedValue(makeNode());
mockGetNode.mockResolvedValue(undefined);
mockGetNodeByName.mockResolvedValue(undefined);
mockUnregisterNode.mockResolvedValue(undefined);
mockCheckNodeHealth.mockResolvedValue("online");
mockQuestion.mockResolvedValue("y");
});
afterEach(() => {
vi.clearAllMocks();
});
it("runNodeList prints table output with nodes", async () => {
mockListNodes.mockResolvedValue([
makeNode({ name: "b-node" }),
makeNode({ name: "a-node" }),
]);
await runNodeList();
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Registered Nodes");
expect(output).toContain("a-node");
expect(output).toContain("b-node");
});
it("runNodeList supports JSON output", async () => {
const nodes = [makeNode({ name: "json-node" })];
mockListNodes.mockResolvedValue(nodes);
await runNodeList({ json: true });
expect(logSpy).toHaveBeenCalledWith(JSON.stringify(nodes, null, 2));
});
it("runNodeList prints empty message when no nodes", async () => {
mockListNodes.mockResolvedValue([]);
await runNodeList();
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("No nodes registered");
});
it("runNodeAdd registers local node", async () => {
mockRegisterNode.mockResolvedValue(makeNode({ id: "node_local", name: "local-node", type: "local" }));
await runNodeAdd("local-node", {});
expect(mockRegisterNode).toHaveBeenCalledWith({
name: "local-node",
type: "local",
url: undefined,
apiKey: undefined,
maxConcurrent: undefined,
});
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Registered node 'local-node'"));
});
it("runNodeAdd registers remote node with url and apiKey", async () => {
mockRegisterNode.mockResolvedValue(
makeNode({
id: "node_remote",
name: "remote-node",
type: "remote",
url: "https://node.example.com",
}),
);
await runNodeAdd("remote-node", {
url: "https://node.example.com",
apiKey: "secret",
maxConcurrent: 4,
});
expect(mockRegisterNode).toHaveBeenCalledWith({
name: "remote-node",
type: "remote",
url: "https://node.example.com",
apiKey: "secret",
maxConcurrent: 4,
});
});
it("runNodeAdd validates name format", async () => {
await expect(runNodeAdd("invalid name", {})).rejects.toThrow("process.exit");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("runNodeAdd rejects missing name", async () => {
await expect(runNodeAdd(undefined as any, {})).rejects.toThrow("process.exit");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("runNodeRemove removes with --force", async () => {
mockGetNode.mockResolvedValue(makeNode({ id: "node_123", name: "to-remove" }));
await runNodeRemove("node_123", { force: true });
expect(mockUnregisterNode).toHaveBeenCalledWith("node_123");
});
it("runNodeRemove prompts without --force", async () => {
mockGetNodeByName.mockResolvedValue(makeNode({ id: "node_222", name: "prompt-node" }));
mockQuestion.mockResolvedValue("y");
await runNodeRemove("prompt-node", { force: false });
expect(mockQuestion).toHaveBeenCalled();
expect(mockUnregisterNode).toHaveBeenCalledWith("node_222");
});
it("runNodeRemove rejects unknown node", async () => {
mockGetNode.mockResolvedValue(undefined);
mockGetNodeByName.mockResolvedValue(undefined);
await expect(runNodeRemove("missing", { force: true })).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith("Error: Node 'missing' not found.");
});
it("runNodeShow displays named node details", async () => {
mockGetNodeByName.mockResolvedValue(
makeNode({
id: "node_remote",
name: "remote-node",
type: "remote",
url: "https://node.example.com",
}),
);
await runNodeShow("remote-node");
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Node: remote-node");
expect(output).toContain("URL: https://node.example.com");
});
it("runNodeShow picks local node when no name provided", async () => {
mockListNodes.mockResolvedValue([
makeNode({ id: "node_remote", name: "remote", type: "remote", url: "https://remote" }),
makeNode({ id: "node_local", name: "local", type: "local" }),
]);
await runNodeShow();
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Node: local");
});
it("runNodeShow rejects unknown node", async () => {
mockGetNode.mockResolvedValue(undefined);
mockGetNodeByName.mockResolvedValue(undefined);
await expect(runNodeShow("missing")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith("Error: Node 'missing' not found.");
});
it("runNodeHealth reports node health status", async () => {
mockGetNodeByName.mockResolvedValue(makeNode({ id: "node_1", name: "health-node" }));
mockCheckNodeHealth.mockResolvedValue("online");
await runNodeHealth("health-node");
expect(mockCheckNodeHealth).toHaveBeenCalledWith("node_1");
expect(logSpy).toHaveBeenCalledWith(" Node 'health-node' health: online");
});
it("runNodeHealth handles unknown node", async () => {
mockGetNode.mockResolvedValue(undefined);
mockGetNodeByName.mockResolvedValue(undefined);
await expect(runNodeHealth("missing")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith("Error: Node 'missing' not found.");
});
});

View File

@@ -0,0 +1,266 @@
import { CentralCore, type NodeConfig } from "@fusion/core";
import { createInterface } from "node:readline/promises";
/** Options for node list command. */
export interface NodeListOptions {
/** Output as JSON instead of table */
json?: boolean;
}
/** Options for node add command. */
export interface NodeAddOptions {
/** Remote node URL (if provided, node is registered as remote) */
url?: string;
/** Optional API key for remote node authentication */
apiKey?: string;
/** Max concurrent tasks for the node */
maxConcurrent?: number;
}
/** Options for node remove command. */
export interface NodeRemoveOptions {
/** Skip confirmation prompt */
force?: boolean;
}
/**
* List all registered nodes.
*/
export async function runNodeList(options: NodeListOptions = {}): Promise<void> {
const central = new CentralCore();
await central.init();
try {
const nodes = await central.listNodes();
if (nodes.length === 0) {
if (options.json) {
console.log(JSON.stringify([], null, 2));
} else {
console.log("\n No nodes registered.");
console.log(" Register one with: kb node add <name>\n");
}
return;
}
const sorted = [...nodes].sort((a, b) => a.name.localeCompare(b.name));
if (options.json) {
console.log(JSON.stringify(sorted, null, 2));
return;
}
console.log();
console.log(" Registered Nodes:");
console.log();
console.log(" Name Type Status Max URL");
console.log(` ${"─".repeat(78)}`);
for (const node of sorted) {
const name = node.name.padEnd(16);
const type = node.type.padEnd(8);
const status = node.status.padEnd(12);
const max = String(node.maxConcurrent).padStart(3);
const url = node.type === "remote" ? (node.url ?? "-") : "-";
console.log(` ${name} ${type} ${status} ${max} ${url}`);
}
console.log();
console.log(` ${sorted.length} node${sorted.length === 1 ? "" : "s"} registered`);
console.log();
} finally {
await central.close();
}
}
/**
* Register a node (local by default, remote when --url is provided).
*/
export async function runNodeAdd(name: string, options: NodeAddOptions = {}): Promise<void> {
if (!name) {
console.error("Usage: kb node add <name> [--url <url>] [--api-key <key>] [--max-concurrent <n>]");
process.exit(1);
}
if (!isValidNodeName(name)) {
console.error(`\n ✗ Invalid node name '${name}'`);
console.error(" Name must be 1-64 characters and contain only: a-z, A-Z, 0-9, _, -\n");
process.exit(1);
}
const type: "local" | "remote" = options.url ? "remote" : "local";
const url = options.url?.trim();
if (type === "remote" && !url) {
console.error("\n ✗ --url is required when adding a remote node\n");
process.exit(1);
}
if (type === "local" && options.apiKey) {
console.error("\n ✗ --api-key is only valid for remote nodes\n");
process.exit(1);
}
if (
options.maxConcurrent !== undefined
&& (!Number.isFinite(options.maxConcurrent) || options.maxConcurrent < 1)
) {
console.error("\n ✗ --max-concurrent must be a number >= 1\n");
process.exit(1);
}
const central = new CentralCore();
await central.init();
try {
const node = await central.registerNode({
name: name.trim(),
type,
url,
apiKey: options.apiKey,
maxConcurrent: options.maxConcurrent,
});
console.log();
console.log(` ✓ Registered node '${node.name}'`);
console.log(` ID: ${node.id}`);
console.log(` Type: ${node.type}`);
console.log(` Max Concurrent: ${node.maxConcurrent}`);
if (node.type === "remote") {
console.log(` URL: ${node.url ?? "(missing)"}`);
}
console.log();
} finally {
await central.close();
}
}
/**
* Unregister a node.
*/
export async function runNodeRemove(name: string, options: NodeRemoveOptions = {}): Promise<void> {
if (!name) {
console.error("Usage: kb node remove <name> [--force]");
process.exit(1);
}
const central = new CentralCore();
await central.init();
try {
const node = await findNodeByNameOrId(central, name);
if (!node) {
console.error(`Error: Node '${name}' not found.`);
process.exit(1);
}
if (!options.force) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(`Unregister node '${node.name}'? [y/N] `);
rl.close();
if (answer.trim().toLowerCase() !== "y") {
console.log("Cancelled.");
return;
}
}
await central.unregisterNode(node.id);
console.log();
console.log(` ✓ Unregistered node '${node.name}'`);
if (node.type === "remote" && node.url) {
console.log(` URL: ${node.url}`);
}
console.log();
} finally {
await central.close();
}
}
/**
* Show detailed node information.
*/
export async function runNodeShow(name?: string): Promise<void> {
const central = new CentralCore();
await central.init();
try {
let node: NodeConfig | undefined;
if (name) {
node = await findNodeByNameOrId(central, name);
} else {
const nodes = await central.listNodes();
node = nodes.find((candidate) => candidate.type === "local");
}
if (!node) {
console.error(`Error: Node '${name || "local"}' not found.`);
process.exit(1);
}
console.log();
console.log(` Node: ${node.name}`);
console.log(` ID: ${node.id}`);
console.log(` Type: ${node.type}`);
console.log(` Status: ${node.status}`);
if (node.type === "remote") {
console.log(` URL: ${node.url ?? "(missing)"}`);
}
console.log(` Max Concurrent: ${node.maxConcurrent}`);
console.log(` Capabilities: ${node.capabilities?.length ? node.capabilities.join(", ") : "(none)"}`);
console.log(` Created: ${node.createdAt}`);
console.log(` Updated: ${node.updatedAt}`);
console.log();
} finally {
await central.close();
}
}
/**
* Run a health check for a node.
*/
export async function runNodeHealth(name: string): Promise<void> {
if (!name) {
console.error("Usage: kb node health <name>");
process.exit(1);
}
const central = new CentralCore();
await central.init();
try {
const node = await findNodeByNameOrId(central, name);
if (!node) {
console.error(`Error: Node '${name}' not found.`);
process.exit(1);
}
const status = await central.checkNodeHealth(node.id);
console.log();
console.log(` Node '${node.name}' health: ${status}`);
console.log();
} finally {
await central.close();
}
}
export async function findNodeByNameOrId(
central: CentralCore,
nameOrId: string,
): Promise<NodeConfig | undefined> {
const byId = await central.getNode(nameOrId);
if (byId) {
return byId;
}
return central.getNodeByName(nameOrId);
}
export function isValidNodeName(name: string): boolean {
if (!name || name.length < 1 || name.length > 64) {
return false;
}
return /^[a-zA-Z0-9_-]+$/.test(name);
}