feat(FN-1229): add node mesh CLI commands for node and mesh management
- Add fn node list command to list all registered nodes with status and URL - Add fn node show command to display detailed node information including system metrics - Add fn node health-check command to trigger and verify node health status - Add fn node remove command to unregister nodes from the mesh - Add fn mesh status command to show mesh topology with peer connections - Add comprehensive tests for all node and mesh CLI commands - Update bin.ts to wire up new node and mesh subcommands - Update memory with CLI mock pattern for testing
This commit is contained in:
@@ -8,6 +8,7 @@ const mockGetNode = vi.fn();
|
||||
const mockGetNodeByName = vi.fn();
|
||||
const mockUnregisterNode = vi.fn();
|
||||
const mockCheckNodeHealth = vi.fn();
|
||||
const mockListProjects = vi.fn();
|
||||
const mockQuestion = vi.fn();
|
||||
const mockRlClose = vi.fn();
|
||||
|
||||
@@ -21,6 +22,7 @@ vi.mock("@fusion/core", () => ({
|
||||
getNodeByName: mockGetNodeByName,
|
||||
unregisterNode: mockUnregisterNode,
|
||||
checkNodeHealth: mockCheckNodeHealth,
|
||||
listProjects: mockListProjects,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -33,22 +35,48 @@ vi.mock("node:readline/promises", () => ({
|
||||
|
||||
import {
|
||||
runNodeList,
|
||||
runNodeAdd,
|
||||
runNodeRemove,
|
||||
runNodeConnect,
|
||||
runNodeDisconnect,
|
||||
runNodeShow,
|
||||
runNodeHealth,
|
||||
runMeshStatus,
|
||||
maskApiKey,
|
||||
formatBytes,
|
||||
formatUptime,
|
||||
formatStatusBar,
|
||||
formatLastActivity,
|
||||
// Legacy aliases
|
||||
runNodeAdd,
|
||||
runNodeRemove,
|
||||
} from "../node.js";
|
||||
|
||||
function makeNode(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "node_123",
|
||||
name: "local-node",
|
||||
type: "local",
|
||||
status: "offline",
|
||||
type: "local" as const,
|
||||
status: "offline" as const,
|
||||
maxConcurrent: 2,
|
||||
capabilities: ["executor"],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
apiKey: undefined,
|
||||
url: undefined,
|
||||
systemMetrics: undefined,
|
||||
knownPeers: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProject(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "proj_123",
|
||||
name: "test-project",
|
||||
path: "/path/to/project",
|
||||
status: "active" as const,
|
||||
isolationMode: "in-process" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -68,6 +96,7 @@ describe("node commands", () => {
|
||||
mockGetNodeByName.mockResolvedValue(undefined);
|
||||
mockUnregisterNode.mockResolvedValue(undefined);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
mockQuestion.mockResolvedValue("y");
|
||||
});
|
||||
|
||||
@@ -75,6 +104,114 @@ describe("node commands", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Helper Function Tests ──────────────────────────────────────────────────
|
||||
|
||||
describe("maskApiKey", () => {
|
||||
it("returns 'none' for undefined", () => {
|
||||
expect(maskApiKey(undefined)).toBe("none");
|
||||
});
|
||||
|
||||
it("returns 'none' for empty string", () => {
|
||||
expect(maskApiKey("")).toBe("none");
|
||||
});
|
||||
|
||||
it("returns '****' for keys less than 4 chars", () => {
|
||||
expect(maskApiKey("abc")).toBe("****");
|
||||
expect(maskApiKey("ab")).toBe("****");
|
||||
});
|
||||
|
||||
it("shows last 4 chars for keys >= 4 chars", () => {
|
||||
expect(maskApiKey("secret1234")).toBe("****1234");
|
||||
expect(maskApiKey("abcd")).toBe("****abcd");
|
||||
expect(maskApiKey("abcdefgh")).toBe("****efgh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("formats bytes correctly", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
expect(formatBytes(500)).toBe("500 B");
|
||||
expect(formatBytes(1024)).toBe("1.00 KB");
|
||||
expect(formatBytes(1536)).toBe("1.50 KB");
|
||||
expect(formatBytes(1048576)).toBe("1.00 MB");
|
||||
expect(formatBytes(1073741824)).toBe("1.00 GB");
|
||||
expect(formatBytes(1099511627776)).toBe("1.00 TB");
|
||||
});
|
||||
|
||||
it("handles large values with whole number formatting", () => {
|
||||
expect(formatBytes(2048)).toBe("2.00 KB");
|
||||
expect(formatBytes(2097152)).toBe("2.00 MB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUptime", () => {
|
||||
it("formats milliseconds correctly", () => {
|
||||
expect(formatUptime(0)).toBe("0s");
|
||||
expect(formatUptime(-1000)).toBe("0s");
|
||||
expect(formatUptime(1000)).toBe("1s");
|
||||
expect(formatUptime(60000)).toBe("1m");
|
||||
expect(formatUptime(90000)).toBe("1m 30s");
|
||||
expect(formatUptime(3600000)).toBe("1h");
|
||||
expect(formatUptime(3660000)).toBe("1h 1m");
|
||||
expect(formatUptime(86400000)).toBe("1d");
|
||||
expect(formatUptime(90000000)).toBe("1d 1h");
|
||||
expect(formatUptime(90120000)).toBe("1d 1h 2m");
|
||||
});
|
||||
|
||||
it("omits zero segments", () => {
|
||||
expect(formatUptime(3600000)).toBe("1h");
|
||||
expect(formatUptime(86400000)).toBe("1d");
|
||||
expect(formatUptime(90060000)).toBe("1d 1h 1m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStatusBar", () => {
|
||||
it("formats percentage with default width", () => {
|
||||
expect(formatStatusBar(0)).toBe("[░░░░░░░░] 0%");
|
||||
expect(formatStatusBar(50)).toBe("[████░░░░] 50%");
|
||||
expect(formatStatusBar(100)).toBe("[████████] 100%");
|
||||
});
|
||||
|
||||
it("formats percentage with custom width", () => {
|
||||
expect(formatStatusBar(50, 4)).toBe("[██░░] 50%");
|
||||
expect(formatStatusBar(75, 4)).toBe("[███░] 75%");
|
||||
});
|
||||
|
||||
it("clamps values outside 0-100", () => {
|
||||
expect(formatStatusBar(-10)).toBe("[░░░░░░░░] 0%");
|
||||
expect(formatStatusBar(150)).toBe("[████████] 100%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLastActivity", () => {
|
||||
it("returns 'never' for null/undefined", () => {
|
||||
expect(formatLastActivity(null)).toBe("never");
|
||||
expect(formatLastActivity(undefined)).toBe("never");
|
||||
});
|
||||
|
||||
it("returns 'just now' for very recent timestamps", () => {
|
||||
const now = new Date().toISOString();
|
||||
expect(formatLastActivity(now)).toBe("just now");
|
||||
});
|
||||
|
||||
it("returns minutes ago for recent timestamps", () => {
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
expect(formatLastActivity(fiveMinutesAgo)).toBe("5m ago");
|
||||
});
|
||||
|
||||
it("returns hours ago for older timestamps", () => {
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
expect(formatLastActivity(twoHoursAgo)).toBe("2h ago");
|
||||
});
|
||||
|
||||
it("returns days ago for even older timestamps", () => {
|
||||
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
expect(formatLastActivity(threeDaysAgo)).toBe("3d ago");
|
||||
});
|
||||
});
|
||||
|
||||
// ── runNodeList Tests ─────────────────────────────────────────────────────
|
||||
|
||||
it("runNodeList prints table output with nodes", async () => {
|
||||
mockListNodes.mockResolvedValue([
|
||||
makeNode({ name: "b-node" }),
|
||||
@@ -95,7 +232,23 @@ describe("node commands", () => {
|
||||
|
||||
await runNodeList({ json: true });
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(JSON.stringify(nodes, null, 2));
|
||||
// JSON output should have masked API key
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].name).toBe("json-node");
|
||||
expect(parsed[0].apiKey).toBe("none");
|
||||
});
|
||||
|
||||
it("runNodeList masks API keys in JSON output", async () => {
|
||||
const nodes = [makeNode({ apiKey: "secret1234" })];
|
||||
mockListNodes.mockResolvedValue(nodes);
|
||||
|
||||
await runNodeList({ json: true });
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("****1234");
|
||||
expect(output).not.toContain("secret1234");
|
||||
});
|
||||
|
||||
it("runNodeList prints empty message when no nodes", async () => {
|
||||
@@ -107,22 +260,24 @@ describe("node commands", () => {
|
||||
expect(output).toContain("No nodes registered");
|
||||
});
|
||||
|
||||
it("runNodeAdd registers local node", async () => {
|
||||
mockRegisterNode.mockResolvedValue(makeNode({ id: "node_local", name: "local-node", type: "local" }));
|
||||
it("runNodeList shows status indicators", async () => {
|
||||
mockListNodes.mockResolvedValue([
|
||||
makeNode({ name: "online-node", status: "online" }),
|
||||
makeNode({ name: "offline-node", status: "offline" }),
|
||||
makeNode({ name: "error-node", status: "error" }),
|
||||
]);
|
||||
|
||||
await runNodeAdd("local-node", {});
|
||||
await runNodeList();
|
||||
|
||||
expect(mockRegisterNode).toHaveBeenCalledWith({
|
||||
name: "local-node",
|
||||
type: "local",
|
||||
url: undefined,
|
||||
apiKey: undefined,
|
||||
maxConcurrent: undefined,
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Registered node 'local-node'"));
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("● online");
|
||||
expect(output).toContain("○ offline");
|
||||
expect(output).toContain("✕ error");
|
||||
});
|
||||
|
||||
it("runNodeAdd registers remote node with url and apiKey", async () => {
|
||||
// ── runNodeConnect Tests ─────────────────────────────────────────────────
|
||||
|
||||
it("runNodeConnect registers remote node and runs health check", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
@@ -131,8 +286,9 @@ describe("node commands", () => {
|
||||
url: "https://node.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
await runNodeAdd("remote-node", {
|
||||
await runNodeConnect("remote-node", {
|
||||
url: "https://node.example.com",
|
||||
apiKey: "secret",
|
||||
maxConcurrent: 4,
|
||||
@@ -145,45 +301,113 @@ describe("node commands", () => {
|
||||
apiKey: "secret",
|
||||
maxConcurrent: 4,
|
||||
});
|
||||
expect(mockCheckNodeHealth).toHaveBeenCalledWith("node_remote");
|
||||
});
|
||||
|
||||
it("runNodeAdd validates name format", async () => {
|
||||
await expect(runNodeAdd("invalid name", {})).rejects.toThrow("process.exit");
|
||||
it("runNodeConnect masks API key in output", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "remote-node",
|
||||
type: "remote",
|
||||
url: "https://node.example.com",
|
||||
apiKey: "secret1234",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
await runNodeConnect("remote-node", {
|
||||
url: "https://node.example.com",
|
||||
apiKey: "secret1234",
|
||||
});
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("****1234");
|
||||
expect(output).not.toContain("secret1234");
|
||||
});
|
||||
|
||||
it("runNodeConnect exits with error if URL is missing", async () => {
|
||||
await expect(runNodeConnect("remote-node", { url: "" })).rejects.toThrow("process.exit");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("runNodeAdd rejects missing name", async () => {
|
||||
await expect(runNodeAdd(undefined as any, {})).rejects.toThrow("process.exit");
|
||||
it("runNodeConnect exits with error if name is missing", async () => {
|
||||
await expect(runNodeConnect("", { url: "https://example.com" })).rejects.toThrow("process.exit");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("runNodeRemove removes with --force", async () => {
|
||||
it("runNodeConnect validates name format", async () => {
|
||||
await expect(runNodeConnect("invalid name", { url: "https://example.com" })).rejects.toThrow("process.exit");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
// ── runNodeDisconnect Tests ─────────────────────────────────────────────
|
||||
|
||||
it("runNodeDisconnect removes with --force", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNode({ id: "node_123", name: "to-remove" }));
|
||||
|
||||
await runNodeRemove("node_123", { force: true });
|
||||
await runNodeDisconnect("node_123", { force: true });
|
||||
|
||||
expect(mockUnregisterNode).toHaveBeenCalledWith("node_123");
|
||||
expect(mockQuestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runNodeRemove prompts without --force", async () => {
|
||||
it("runNodeDisconnect prompts without --force", async () => {
|
||||
mockGetNodeByName.mockResolvedValue(makeNode({ id: "node_222", name: "prompt-node" }));
|
||||
mockQuestion.mockResolvedValue("y");
|
||||
|
||||
await runNodeRemove("prompt-node", { force: false });
|
||||
await runNodeDisconnect("prompt-node", { force: false });
|
||||
|
||||
expect(mockQuestion).toHaveBeenCalled();
|
||||
expect(mockUnregisterNode).toHaveBeenCalledWith("node_222");
|
||||
});
|
||||
|
||||
it("runNodeRemove rejects unknown node", async () => {
|
||||
it("runNodeDisconnect cancels on 'n' answer", async () => {
|
||||
mockGetNodeByName.mockResolvedValue(makeNode({ id: "node_222", name: "prompt-node" }));
|
||||
mockQuestion.mockResolvedValue("n");
|
||||
|
||||
await runNodeDisconnect("prompt-node", { force: false });
|
||||
|
||||
expect(mockUnregisterNode).not.toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith("Cancelled.");
|
||||
});
|
||||
|
||||
it("runNodeDisconnect rejects unknown node", async () => {
|
||||
mockGetNode.mockResolvedValue(undefined);
|
||||
mockGetNodeByName.mockResolvedValue(undefined);
|
||||
|
||||
await expect(runNodeRemove("missing", { force: true })).rejects.toThrow("process.exit");
|
||||
await expect(runNodeDisconnect("missing", { force: true })).rejects.toThrow("process.exit");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Node 'missing' not found.");
|
||||
});
|
||||
|
||||
it("runNodeShow displays named node details", async () => {
|
||||
it("runNodeDisconnect exits with error if name is missing", async () => {
|
||||
await expect(runNodeDisconnect("", { force: false })).rejects.toThrow("process.exit");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
// ── runNodeShow Tests ───────────────────────────────────────────────────
|
||||
|
||||
it("runNodeShow displays node details with masked API key", async () => {
|
||||
mockGetNodeByName.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "remote-node",
|
||||
type: "remote",
|
||||
url: "https://node.example.com",
|
||||
apiKey: "secret1234",
|
||||
}),
|
||||
);
|
||||
|
||||
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");
|
||||
expect(output).toContain("****1234");
|
||||
expect(output).not.toContain("secret1234");
|
||||
});
|
||||
|
||||
it("runNodeShow supports JSON output", async () => {
|
||||
mockGetNodeByName.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
@@ -193,11 +417,74 @@ describe("node commands", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await runNodeShow("remote-node");
|
||||
await runNodeShow("remote-node", { json: true });
|
||||
|
||||
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");
|
||||
expect(output).toContain("\"name\": \"remote-node\"");
|
||||
});
|
||||
|
||||
it("runNodeShow shows assigned projects", async () => {
|
||||
mockGetNodeByName.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_local",
|
||||
name: "local-node",
|
||||
type: "local",
|
||||
}),
|
||||
);
|
||||
mockListProjects.mockResolvedValue([
|
||||
makeProject({ id: "proj_1", name: "project-1", nodeId: "node_local" }),
|
||||
makeProject({ id: "proj_2", name: "project-2", nodeId: "node_other" }),
|
||||
makeProject({ id: "proj_3", name: "project-3", nodeId: undefined }),
|
||||
]);
|
||||
|
||||
await runNodeShow("local-node");
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("project-1");
|
||||
expect(output).not.toContain("project-2");
|
||||
expect(output).not.toContain("project-3");
|
||||
});
|
||||
|
||||
it("runNodeShow displays system metrics when available", async () => {
|
||||
mockGetNodeByName.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_local",
|
||||
name: "local-node",
|
||||
type: "local",
|
||||
systemMetrics: {
|
||||
cpuUsage: 45,
|
||||
memoryUsed: 4 * 1024 * 1024 * 1024,
|
||||
memoryTotal: 16 * 1024 * 1024 * 1024,
|
||||
storageUsed: 100 * 1024 * 1024 * 1024,
|
||||
storageTotal: 500 * 1024 * 1024 * 1024,
|
||||
uptime: 86400000,
|
||||
reportedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await runNodeShow("local-node");
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("System Metrics:");
|
||||
expect(output).toContain("Memory:");
|
||||
expect(output).toContain("Storage:");
|
||||
expect(output).toContain("Uptime:");
|
||||
});
|
||||
|
||||
it("runNodeShow shows 'not available' when metrics are missing", async () => {
|
||||
mockGetNodeByName.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_local",
|
||||
name: "local-node",
|
||||
type: "local",
|
||||
}),
|
||||
);
|
||||
|
||||
await runNodeShow("local-node");
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("System Metrics: not available");
|
||||
});
|
||||
|
||||
it("runNodeShow picks local node when no name provided", async () => {
|
||||
@@ -220,14 +507,27 @@ describe("node commands", () => {
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Node 'missing' not found.");
|
||||
});
|
||||
|
||||
// ── runNodeHealth Tests ──────────────────────────────────────────────────
|
||||
|
||||
it("runNodeHealth reports node health status", async () => {
|
||||
mockGetNodeByName.mockResolvedValue(makeNode({ id: "node_1", name: "health-node" }));
|
||||
mockGetNodeByName.mockResolvedValue(makeNode({ id: "node_1", name: "health-node", status: "offline" }));
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
await runNodeHealth("health-node");
|
||||
|
||||
expect(mockCheckNodeHealth).toHaveBeenCalledWith("node_1");
|
||||
expect(logSpy).toHaveBeenCalledWith(" Node 'health-node' health: online");
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("online"));
|
||||
});
|
||||
|
||||
it("runNodeHealth shows previous and current status", async () => {
|
||||
mockGetNodeByName.mockResolvedValue(makeNode({ id: "node_1", name: "health-node", status: "offline" }));
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
await runNodeHealth("health-node");
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("Previous:");
|
||||
expect(output).toContain("Current:");
|
||||
});
|
||||
|
||||
it("runNodeHealth handles unknown node", async () => {
|
||||
@@ -237,4 +537,135 @@ describe("node commands", () => {
|
||||
await expect(runNodeHealth("missing")).rejects.toThrow("process.exit");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Node 'missing' not found.");
|
||||
});
|
||||
|
||||
it("runNodeHealth exits with error if name is missing", async () => {
|
||||
await expect(runNodeHealth("")).rejects.toThrow("process.exit");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
// ── runMeshStatus Tests ─────────────────────────────────────────────────
|
||||
|
||||
it("runMeshStatus shows summary counts", async () => {
|
||||
mockListNodes.mockResolvedValue([
|
||||
makeNode({ name: "online-node", status: "online" }),
|
||||
makeNode({ name: "offline-node", status: "offline" }),
|
||||
makeNode({ name: "error-node", status: "error" }),
|
||||
]);
|
||||
|
||||
await runMeshStatus();
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("Mesh Status:");
|
||||
expect(output).toContain("Total: 3");
|
||||
expect(output).toContain("Online: 1");
|
||||
expect(output).toContain("Offline: 1");
|
||||
expect(output).toContain("Error: 1");
|
||||
});
|
||||
|
||||
it("runMeshStatus supports JSON output", async () => {
|
||||
mockListNodes.mockResolvedValue([
|
||||
makeNode({ name: "node-1", status: "online" }),
|
||||
]);
|
||||
|
||||
await runMeshStatus({ json: true });
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed.summary.total).toBe(1);
|
||||
expect(parsed.summary.online).toBe(1);
|
||||
expect(parsed.nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("runMeshStatus shows empty message when no nodes", async () => {
|
||||
mockListNodes.mockResolvedValue([]);
|
||||
|
||||
await runMeshStatus();
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("No nodes in mesh");
|
||||
});
|
||||
|
||||
it("runMeshStatus sorts online nodes first", async () => {
|
||||
mockListNodes.mockResolvedValue([
|
||||
makeNode({ name: "zzz-node", status: "offline" }),
|
||||
makeNode({ name: "aaa-node", status: "online" }),
|
||||
]);
|
||||
|
||||
await runMeshStatus();
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
const onlineIndex = output.indexOf("aaa-node");
|
||||
const offlineIndex = output.indexOf("zzz-node");
|
||||
expect(onlineIndex).toBeLessThan(offlineIndex);
|
||||
});
|
||||
|
||||
it("runMeshStatus masks API keys in JSON output", async () => {
|
||||
mockListNodes.mockResolvedValue([
|
||||
makeNode({ name: "secure-node", apiKey: "supersecret123" }),
|
||||
]);
|
||||
|
||||
await runMeshStatus({ json: true });
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
// supersecret123.slice(-4) = "t123", so mask should be ****t123
|
||||
expect(output).toContain("****t123");
|
||||
expect(output).not.toContain("supersecret123");
|
||||
});
|
||||
|
||||
it("runMeshStatus shows mesh connections when peers exist", async () => {
|
||||
mockListNodes.mockResolvedValue([
|
||||
makeNode({ id: "node_1", name: "node-a", status: "online", knownPeers: ["node_2"] }),
|
||||
makeNode({ id: "node_2", name: "node-b", status: "online", knownPeers: ["node_1"] }),
|
||||
]);
|
||||
|
||||
await runMeshStatus();
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("Mesh Connections:");
|
||||
expect(output).toContain("node-a");
|
||||
expect(output).toContain("node-b");
|
||||
});
|
||||
|
||||
// ── Legacy Alias Tests ───────────────────────────────────────────────────
|
||||
|
||||
it("runNodeAdd is alias for runNodeConnect", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_legacy",
|
||||
name: "legacy-node",
|
||||
type: "remote",
|
||||
url: "https://legacy.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
await runNodeAdd("legacy-node", {
|
||||
url: "https://legacy.example.com",
|
||||
});
|
||||
|
||||
expect(mockRegisterNode).toHaveBeenCalled();
|
||||
expect(mockCheckNodeHealth).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runNodeRemove is alias for runNodeDisconnect", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNode({ id: "node_legacy", name: "legacy-remove" }));
|
||||
|
||||
await runNodeRemove("node_legacy", { force: true });
|
||||
|
||||
expect(mockUnregisterNode).toHaveBeenCalledWith("node_legacy");
|
||||
});
|
||||
|
||||
// ── Error Handling Tests ────────────────────────────────────────────────
|
||||
|
||||
it("handles CentralCore initialization errors gracefully", async () => {
|
||||
mockInit.mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(runNodeList()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("handles API errors gracefully", async () => {
|
||||
mockListNodes.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(runNodeList()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,155 @@
|
||||
import { CentralCore, type NodeConfig } from "@fusion/core";
|
||||
import { CentralCore, type NodeConfig, type SystemMetrics } from "@fusion/core";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
// ── Options Interfaces ───────────────────────────────────────────────────────
|
||||
|
||||
/** 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;
|
||||
/** Options for node connect command. */
|
||||
export interface NodeConnectOptions {
|
||||
/** Remote node URL */
|
||||
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 {
|
||||
/** Options for node disconnect command. */
|
||||
export interface NodeDisconnectOptions {
|
||||
/** Skip confirmation prompt */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** Options for node show command. */
|
||||
export interface NodeShowOptions {
|
||||
/** Output as JSON instead of table */
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
/** Options for mesh status command. */
|
||||
export interface MeshStatusOptions {
|
||||
/** Output as JSON instead of table */
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
// ── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mask an API key for display, showing last 4 characters.
|
||||
* @param key - The API key to mask
|
||||
* @returns Masked key like "****abcd" or "none" for undefined
|
||||
*/
|
||||
export function maskApiKey(key?: string): string {
|
||||
if (!key) return "none";
|
||||
if (key.length < 4) return "****";
|
||||
return `****${key.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes into human-readable format.
|
||||
* @param bytes - Number of bytes
|
||||
* @returns Human-readable string like "1.2 GB", "512 MB", "64 KB"
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, i);
|
||||
|
||||
if (i === 0) return `${bytes} B`;
|
||||
if (value >= 100) return `${Math.round(value)} ${units[i]}`;
|
||||
if (value >= 10) return `${value.toFixed(1)} ${units[i]}`;
|
||||
return `${value.toFixed(2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds into human-readable uptime.
|
||||
* @param ms - Milliseconds
|
||||
* @returns String like "2d 4h 32m" or "5m 30s"
|
||||
*/
|
||||
export function formatUptime(ms: number): string {
|
||||
if (ms < 0) return "0s";
|
||||
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours % 24 > 0) parts.push(`${hours % 24}h`);
|
||||
if (minutes % 60 > 0) parts.push(`${minutes % 60}m`);
|
||||
if (seconds % 60 > 0 && days === 0) parts.push(`${seconds % 60}s`);
|
||||
|
||||
if (parts.length === 0) return "0s";
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a percentage as an ASCII progress bar.
|
||||
* @param percent - Percentage value (0-100)
|
||||
* @param width - Width of the bar in characters (default: 8)
|
||||
* @returns String like "[████░░░░] 48%"
|
||||
*/
|
||||
export function formatStatusBar(percent: number, width: number = 8): string {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const filled = Math.round((clamped / 100) * width);
|
||||
const empty = width - filled;
|
||||
const bar = "█".repeat(filled) + "░".repeat(empty);
|
||||
return `[${bar}] ${Math.round(clamped)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp for display (relative time).
|
||||
* @param timestamp - ISO timestamp or null
|
||||
* @returns Relative time string like "5m ago", "2h ago", "3d ago", "just now"
|
||||
*/
|
||||
export function formatLastActivity(timestamp?: string | null): string {
|
||||
if (!timestamp) return "never";
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status indicator character for a node status.
|
||||
*/
|
||||
function getStatusIndicator(status: string): string {
|
||||
switch (status) {
|
||||
case "online": return "●";
|
||||
case "offline": return "○";
|
||||
case "error": return "✕";
|
||||
case "connecting": return "◐";
|
||||
default: return "○";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color-coded status string.
|
||||
*/
|
||||
function formatStatus(status: string): string {
|
||||
return `${getStatusIndicator(status)} ${status}`;
|
||||
}
|
||||
|
||||
// ── Core Command Functions ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all registered nodes.
|
||||
*/
|
||||
@@ -38,31 +165,56 @@ export async function runNodeList(options: NodeListOptions = {}): Promise<void>
|
||||
console.log(JSON.stringify([], null, 2));
|
||||
} else {
|
||||
console.log("\n No nodes registered.");
|
||||
console.log(" Register one with: fn node add <name>\n");
|
||||
console.log(" Register one with: fn node connect <name> --url <url>\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = [...nodes].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Check if SystemMetrics is available for display
|
||||
const hasMetrics = sorted.some(n => n.systemMetrics);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(sorted, null, 2));
|
||||
// Mask API keys in JSON output
|
||||
const masked = sorted.map(node => ({
|
||||
...node,
|
||||
apiKey: maskApiKey(node.apiKey),
|
||||
}));
|
||||
console.log(JSON.stringify(masked, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(" Registered Nodes:");
|
||||
console.log();
|
||||
console.log(" Name Type Status Max URL");
|
||||
console.log(` ${"─".repeat(78)}`);
|
||||
|
||||
// Build header based on whether metrics are available
|
||||
if (hasMetrics) {
|
||||
console.log(" Name Type Status Max CPU Memory URL");
|
||||
console.log(` ${"─".repeat(85)}`);
|
||||
} else {
|
||||
console.log(" Name Type Status Max URL");
|
||||
console.log(` ${"─".repeat(72)}`);
|
||||
}
|
||||
|
||||
for (const node of sorted) {
|
||||
const name = node.name.padEnd(16);
|
||||
const type = node.type.padEnd(8);
|
||||
const status = node.status.padEnd(12);
|
||||
const statusStr = formatStatus(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}`);
|
||||
|
||||
if (hasMetrics && node.systemMetrics) {
|
||||
const metrics = node.systemMetrics;
|
||||
const cpu = formatStatusBar(metrics.cpuUsage, 4).padEnd(10);
|
||||
const memPercent = (metrics.memoryUsed / metrics.memoryTotal) * 100;
|
||||
const mem = formatStatusBar(memPercent, 4).padEnd(10);
|
||||
const url = node.type === "remote" ? (node.url ?? "-") : "-";
|
||||
console.log(` ${name} ${type} ${statusStr} ${max} ${cpu} ${mem} ${url}`);
|
||||
} else {
|
||||
const url = node.type === "remote" ? (node.url ?? "-") : "-";
|
||||
console.log(` ${name} ${type} ${statusStr} ${max} ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
@@ -74,11 +226,20 @@ export async function runNodeList(options: NodeListOptions = {}): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a node (local by default, remote when --url is provided).
|
||||
* Connect to a remote node.
|
||||
* This is the preferred way to add remote nodes as it runs a health check.
|
||||
*/
|
||||
export async function runNodeAdd(name: string, options: NodeAddOptions = {}): Promise<void> {
|
||||
export async function runNodeConnect(
|
||||
name: string,
|
||||
options: NodeConnectOptions,
|
||||
): Promise<void> {
|
||||
if (!name) {
|
||||
console.error("Usage: fn node add <name> [--url <url>] [--api-key <key>] [--max-concurrent <n>]");
|
||||
console.error("Usage: fn node connect <name> --url <url> [--api-key <key>] [--max-concurrent <n>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!options.url) {
|
||||
console.error("\n ✗ --url is required for remote nodes\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -88,19 +249,6 @@ export async function runNodeAdd(name: string, options: NodeAddOptions = {}): Pr
|
||||
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)
|
||||
@@ -113,22 +261,34 @@ export async function runNodeAdd(name: string, options: NodeAddOptions = {}): Pr
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
// Register the node
|
||||
const node = await central.registerNode({
|
||||
name: name.trim(),
|
||||
type,
|
||||
url,
|
||||
type: "remote",
|
||||
url: options.url,
|
||||
apiKey: options.apiKey,
|
||||
maxConcurrent: options.maxConcurrent,
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Registered node '${node.name}'`);
|
||||
console.log(` ✓ Node '${node.name}' registered`);
|
||||
console.log(` ID: ${node.id}`);
|
||||
console.log(` Type: ${node.type}`);
|
||||
console.log(` URL: ${node.url}`);
|
||||
console.log(` Max Concurrent: ${node.maxConcurrent}`);
|
||||
if (node.type === "remote") {
|
||||
console.log(` URL: ${node.url ?? "(missing)"}`);
|
||||
|
||||
// Run health check to verify connectivity
|
||||
console.log();
|
||||
console.log(" Checking connectivity...");
|
||||
|
||||
const status = await central.checkNodeHealth(node.id);
|
||||
console.log(` Status: ${formatStatus(status)}`);
|
||||
|
||||
if (options.apiKey) {
|
||||
console.log(` API Key: ${maskApiKey(options.apiKey)}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(" ✓ Node connected successfully");
|
||||
console.log();
|
||||
} finally {
|
||||
await central.close();
|
||||
@@ -136,11 +296,14 @@ export async function runNodeAdd(name: string, options: NodeAddOptions = {}): Pr
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a node.
|
||||
* Disconnect (unregister) a node.
|
||||
*/
|
||||
export async function runNodeRemove(name: string, options: NodeRemoveOptions = {}): Promise<void> {
|
||||
export async function runNodeDisconnect(
|
||||
name: string,
|
||||
options: NodeDisconnectOptions = {},
|
||||
): Promise<void> {
|
||||
if (!name) {
|
||||
console.error("Usage: fn node remove <name> [--force]");
|
||||
console.error("Usage: fn node disconnect <name> [--force]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -156,7 +319,7 @@ export async function runNodeRemove(name: string, options: NodeRemoveOptions = {
|
||||
|
||||
if (!options.force) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await rl.question(`Unregister node '${node.name}'? [y/N] `);
|
||||
const answer = await rl.question(`Disconnect node '${node.name}'? [y/N] `);
|
||||
rl.close();
|
||||
|
||||
if (answer.trim().toLowerCase() !== "y") {
|
||||
@@ -168,7 +331,7 @@ export async function runNodeRemove(name: string, options: NodeRemoveOptions = {
|
||||
await central.unregisterNode(node.id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Unregistered node '${node.name}'`);
|
||||
console.log(` ✓ Disconnected node '${node.name}'`);
|
||||
if (node.type === "remote" && node.url) {
|
||||
console.log(` URL: ${node.url}`);
|
||||
}
|
||||
@@ -181,7 +344,10 @@ export async function runNodeRemove(name: string, options: NodeRemoveOptions = {
|
||||
/**
|
||||
* Show detailed node information.
|
||||
*/
|
||||
export async function runNodeShow(name?: string): Promise<void> {
|
||||
export async function runNodeShow(
|
||||
name?: string,
|
||||
options: NodeShowOptions = {},
|
||||
): Promise<void> {
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
@@ -200,18 +366,65 @@ export async function runNodeShow(name?: string): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get assigned projects
|
||||
const allProjects = await central.listProjects();
|
||||
const assignedProjects = allProjects.filter(p => p.nodeId === node!.id);
|
||||
|
||||
if (options.json) {
|
||||
const output = {
|
||||
...node,
|
||||
apiKey: maskApiKey(node.apiKey),
|
||||
assignedProjects: assignedProjects.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
path: p.path,
|
||||
status: p.status,
|
||||
})),
|
||||
};
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` Node: ${node.name}`);
|
||||
console.log(` ID: ${node.id}`);
|
||||
console.log(` Type: ${node.type}`);
|
||||
console.log(` Status: ${node.status}`);
|
||||
console.log(` Status: ${formatStatus(node.status)}`);
|
||||
|
||||
if (node.type === "remote") {
|
||||
console.log(` URL: ${node.url ?? "(missing)"}`);
|
||||
console.log(` API Key: ${maskApiKey(node.apiKey)}`);
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
// Display system metrics if available
|
||||
if (node.systemMetrics) {
|
||||
const metrics = node.systemMetrics;
|
||||
console.log();
|
||||
console.log(" System Metrics:");
|
||||
console.log(` CPU Usage: ${formatStatusBar(metrics.cpuUsage)}`);
|
||||
console.log(` Memory: ${formatBytes(metrics.memoryUsed)} / ${formatBytes(metrics.memoryTotal)}`);
|
||||
console.log(` Storage: ${formatBytes(metrics.storageUsed)} / ${formatBytes(metrics.storageTotal)}`);
|
||||
console.log(` Uptime: ${formatUptime(metrics.uptime)}`);
|
||||
console.log(` Reported: ${formatLastActivity(metrics.reportedAt)}`);
|
||||
} else {
|
||||
console.log();
|
||||
console.log(" System Metrics: not available");
|
||||
}
|
||||
|
||||
// Display assigned projects
|
||||
if (assignedProjects.length > 0) {
|
||||
console.log();
|
||||
console.log(` Assigned Projects (${assignedProjects.length}):`);
|
||||
for (const project of assignedProjects) {
|
||||
console.log(` • ${project.name} (${project.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
} finally {
|
||||
await central.close();
|
||||
@@ -237,16 +450,107 @@ export async function runNodeHealth(name: string): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const previousStatus = node.status;
|
||||
const status = await central.checkNodeHealth(node.id);
|
||||
|
||||
console.log();
|
||||
console.log(` Node '${node.name}' health: ${status}`);
|
||||
console.log(` Node '${node.name}' health check:`);
|
||||
console.log(` Previous: ${formatStatus(previousStatus)}`);
|
||||
console.log(` Current: ${formatStatus(status)}`);
|
||||
|
||||
if (node.type === "remote" && node.url) {
|
||||
console.log(` URL: ${node.url}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show mesh status with all nodes and summary.
|
||||
*/
|
||||
export async function runMeshStatus(options: MeshStatusOptions = {}): Promise<void> {
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
const nodes = await central.listNodes();
|
||||
|
||||
// Compute summary counts
|
||||
const summary = {
|
||||
total: nodes.length,
|
||||
online: nodes.filter(n => n.status === "online").length,
|
||||
offline: nodes.filter(n => n.status === "offline").length,
|
||||
error: nodes.filter(n => n.status === "error").length,
|
||||
connecting: nodes.filter(n => n.status === "connecting").length,
|
||||
};
|
||||
|
||||
if (options.json) {
|
||||
// Mask API keys in JSON output
|
||||
const maskedNodes = nodes.map(node => ({
|
||||
...node,
|
||||
apiKey: maskApiKey(node.apiKey),
|
||||
}));
|
||||
console.log(JSON.stringify({ nodes: maskedNodes, summary }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
console.log("\n No nodes in mesh.");
|
||||
console.log(" Register a remote node with: fn node connect <name> --url <url>\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = [...nodes].sort((a, b) => {
|
||||
// Sort by status: online first, then by name
|
||||
if (a.status === "online" && b.status !== "online") return -1;
|
||||
if (b.status === "online" && a.status !== "online") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(" Mesh Status:");
|
||||
console.log();
|
||||
console.log(` Total: ${summary.total} | Online: ${summary.online} | Offline: ${summary.offline} | Error: ${summary.error}`);
|
||||
console.log();
|
||||
console.log(" Name Type Status URL");
|
||||
console.log(` ${"─".repeat(68)}`);
|
||||
|
||||
for (const node of sorted) {
|
||||
const name = node.name.padEnd(16);
|
||||
const type = node.type.padEnd(8);
|
||||
const statusStr = formatStatus(node.status).padEnd(12);
|
||||
const url = node.type === "remote" ? (node.url ?? "-") : "(local)";
|
||||
console.log(` ${name} ${type} ${statusStr} ${url}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
// Show mesh topology if there are peers
|
||||
const nodesWithPeers = nodes.filter(n => n.knownPeers && n.knownPeers.length > 0);
|
||||
if (nodesWithPeers.length > 0) {
|
||||
console.log(" Mesh Connections:");
|
||||
for (const node of nodesWithPeers) {
|
||||
const peers = node.knownPeers?.map(p => {
|
||||
const peerNode = nodes.find(n => n.id === p);
|
||||
return peerNode ? peerNode.name : p;
|
||||
}).join(", ") || "none";
|
||||
console.log(` ${node.name} → ${peers}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exported Helper for Reuse ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find a node by name or ID.
|
||||
*/
|
||||
export async function findNodeByNameOrId(
|
||||
central: CentralCore,
|
||||
nameOrId: string,
|
||||
@@ -258,9 +562,29 @@ export async function findNodeByNameOrId(
|
||||
return central.getNodeByName(nameOrId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a node name.
|
||||
*/
|
||||
export function isValidNodeName(name: string): boolean {
|
||||
if (!name || name.length < 1 || name.length > 64) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name);
|
||||
}
|
||||
|
||||
// ── Legacy Aliases (for backward compatibility) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* @deprecated Use runNodeConnect instead
|
||||
*/
|
||||
export const runNodeAdd = runNodeConnect;
|
||||
|
||||
/**
|
||||
* @deprecated Use runNodeDisconnect instead
|
||||
*/
|
||||
export const runNodeRemove = runNodeDisconnect;
|
||||
|
||||
/**
|
||||
* @deprecated Use runNodeShow instead
|
||||
*/
|
||||
export const runNodeInfo = runNodeShow;
|
||||
|
||||
Reference in New Issue
Block a user