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:
@@ -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>");
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
240
packages/cli/src/commands/__tests__/node.test.ts
Normal file
240
packages/cli/src/commands/__tests__/node.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
266
packages/cli/src/commands/node.ts
Normal file
266
packages/cli/src/commands/node.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user