From b9c261b490e5a09119f7cf16f6da44664e4d6158 Mon Sep 17 00:00:00 2001 From: Smithery Factory Date: Tue, 5 May 2026 13:39:52 +0000 Subject: [PATCH] Add tests and documentation for namespace switching This addresses SMI-1880 by adding comprehensive tests for the existing namespace switching functionality and expanding the README documentation. The CLI already supports: - `smithery namespace list` - lists all accessible namespaces with current marker - `smithery namespace use ` - switches to a different namespace instantly - Persistent namespace storage in ~/.config/smithery/settings.json Changes: - Added unit tests for namespace use command covering success/failure cases - Added unit tests for namespace list command with current namespace marking - Expanded README namespace section with detailed usage examples - Documented that namespace switching works without re-authentication The feature allows users to switch between personal and team namespaces without re-running the OAuth flow, as the login grants access to all namespaces the user is a member of. --- README.md | 10 +- src/commands/namespace/__tests__/list.test.ts | 157 ++++++++++++++++++ src/commands/namespace/__tests__/use.test.ts | 133 +++++++++++++++ 3 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 src/commands/namespace/__tests__/list.test.ts create mode 100644 src/commands/namespace/__tests__/use.test.ts diff --git a/README.md b/README.md index 98f22239..96b7d305 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,17 @@ smithery auth token --policy '' # Mint a restricted token ### Namespaces +Switch between personal and team namespaces without re-authenticating. Your login grants access to all namespaces (personal + team memberships), and you can switch between them instantly. + ```bash -smithery namespace list # List your namespaces -smithery namespace use # Set current namespace +smithery namespace list # List available namespaces (shows current) +smithery namespace use # Switch to a different namespace +smithery namespace show # Show current namespace +smithery namespace create # Create and claim a new namespace ``` +The active namespace persists in your local config (`~/.config/smithery/settings.json`) and applies to all subsequent commands until you switch again. + ### Publishing ```bash diff --git a/src/commands/namespace/__tests__/list.test.ts b/src/commands/namespace/__tests__/list.test.ts new file mode 100644 index 00000000..15183d73 --- /dev/null +++ b/src/commands/namespace/__tests__/list.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// Mock dependencies +vi.mock("../../../lib/smithery-client", () => ({ + createSmitheryClient: vi.fn(), +})) + +vi.mock("../../../utils/smithery-settings", () => ({ + getNamespace: vi.fn(), +})) + +vi.mock("../../../utils/output", () => ({ + isJsonMode: vi.fn(), + outputTable: vi.fn(), +})) + +import { createSmitheryClient } from "../../../lib/smithery-client" +import { isJsonMode, outputTable } from "../../../utils/output" +import { getNamespace } from "../../../utils/smithery-settings" +import { listNamespaces } from "../list" + +describe("listNamespaces", () => { + let mockClient: { + namespaces: { + list: ReturnType + } + } + + beforeEach(() => { + vi.clearAllMocks() + + mockClient = { + namespaces: { + list: vi.fn(), + }, + } + + vi.mocked(createSmitheryClient).mockResolvedValue( + mockClient as unknown as Awaited>, + ) + vi.mocked(isJsonMode).mockReturnValue(false) + }) + + test("lists namespaces with current marker", async () => { + mockClient.namespaces.list.mockResolvedValue({ + namespaces: [ + { name: "personal" }, + { name: "team-alpha" }, + { name: "team-beta" }, + ], + }) + vi.mocked(getNamespace).mockResolvedValue("team-alpha") + + await listNamespaces() + + expect(mockClient.namespaces.list).toHaveBeenCalledOnce() + expect(getNamespace).toHaveBeenCalledOnce() + expect(outputTable).toHaveBeenCalledWith({ + data: [ + { name: "personal", current: "" }, + { name: "team-alpha", current: "✓" }, + { name: "team-beta", current: "" }, + ], + columns: [ + { key: "name", header: "NAME" }, + { key: "current", header: "CURRENT" }, + ], + json: false, + jsonData: { + namespaces: ["personal", "team-alpha", "team-beta"], + current: "team-alpha", + }, + tip: "Use smithery namespace use to switch namespaces.", + }) + }) + + test("handles no current namespace", async () => { + mockClient.namespaces.list.mockResolvedValue({ + namespaces: [{ name: "personal" }, { name: "team-alpha" }], + }) + vi.mocked(getNamespace).mockResolvedValue(undefined) + + await listNamespaces() + + expect(outputTable).toHaveBeenCalledWith( + expect.objectContaining({ + data: [ + { name: "personal", current: "" }, + { name: "team-alpha", current: "" }, + ], + jsonData: { + namespaces: ["personal", "team-alpha"], + current: null, + }, + }), + ) + }) + + test("handles empty namespace list", async () => { + mockClient.namespaces.list.mockResolvedValue({ + namespaces: [], + }) + vi.mocked(getNamespace).mockResolvedValue(undefined) + + await listNamespaces() + + expect(outputTable).toHaveBeenCalledWith( + expect.objectContaining({ + data: [], + jsonData: { + namespaces: [], + current: null, + }, + }), + ) + }) + + test("handles JSON output mode", async () => { + mockClient.namespaces.list.mockResolvedValue({ + namespaces: [{ name: "ns1" }, { name: "ns2" }], + }) + vi.mocked(getNamespace).mockResolvedValue("ns1") + vi.mocked(isJsonMode).mockReturnValue(true) + + await listNamespaces() + + expect(outputTable).toHaveBeenCalledWith( + expect.objectContaining({ + json: true, + }), + ) + }) + + test("handles API error", async () => { + mockClient.namespaces.list.mockRejectedValue(new Error("API error")) + + await expect(listNamespaces()).rejects.toThrow("API error") + + expect(outputTable).not.toHaveBeenCalled() + }) + + test("marks current namespace correctly in large list", async () => { + const namespaces = Array.from({ length: 10 }, (_, i) => ({ + name: `namespace-${i}`, + })) + mockClient.namespaces.list.mockResolvedValue({ namespaces }) + vi.mocked(getNamespace).mockResolvedValue("namespace-5") + + await listNamespaces() + + const callArg = vi.mocked(outputTable).mock.calls[0][0] + const data = callArg.data as Array<{ name: string; current: string }> + + expect(data.find((d) => d.name === "namespace-5")?.current).toBe("✓") + expect(data.filter((d) => d.current === "✓")).toHaveLength(1) + }) +}) diff --git a/src/commands/namespace/__tests__/use.test.ts b/src/commands/namespace/__tests__/use.test.ts new file mode 100644 index 00000000..4cc5900d --- /dev/null +++ b/src/commands/namespace/__tests__/use.test.ts @@ -0,0 +1,133 @@ +import pc from "picocolors" +import { beforeEach, describe, expect, test, vi } from "vitest" + +// Mock dependencies +vi.mock("../../../lib/smithery-client", () => ({ + createSmitheryClient: vi.fn(), +})) + +vi.mock("../../../utils/smithery-settings", () => ({ + setNamespace: vi.fn(), +})) + +import { createSmitheryClient } from "../../../lib/smithery-client" +import { setNamespace } from "../../../utils/smithery-settings" +import { useNamespace } from "../use" + +describe("useNamespace", () => { + let mockClient: { + namespaces: { + list: ReturnType + } + } + let consoleLogSpy: any + let consoleErrorSpy: any + let processExitSpy: any + + beforeEach(() => { + vi.clearAllMocks() + + mockClient = { + namespaces: { + list: vi.fn(), + }, + } + + vi.mocked(createSmitheryClient).mockResolvedValue( + mockClient as unknown as Awaited>, + ) + + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit called") + }) as never) + }) + + test("successfully switches to existing namespace", async () => { + mockClient.namespaces.list.mockResolvedValue({ + namespaces: [ + { name: "personal" }, + { name: "team-alpha" }, + { name: "team-beta" }, + ], + }) + vi.mocked(setNamespace).mockResolvedValue({ + success: true, + }) + + await useNamespace("team-alpha") + + expect(mockClient.namespaces.list).toHaveBeenCalledOnce() + expect(setNamespace).toHaveBeenCalledWith("team-alpha") + expect(consoleLogSpy).toHaveBeenCalledWith( + pc.green("Switched to namespace: team-alpha"), + ) + expect(processExitSpy).not.toHaveBeenCalled() + }) + + test("fails when namespace does not exist", async () => { + mockClient.namespaces.list.mockResolvedValue({ + namespaces: [{ name: "personal" }, { name: "team-alpha" }], + }) + + await expect(useNamespace("nonexistent")).rejects.toThrow( + "process.exit called", + ) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + pc.red('Namespace "nonexistent" not found.'), + ) + expect(consoleErrorSpy).toHaveBeenCalledWith( + pc.gray("Available namespaces: personal, team-alpha"), + ) + expect(setNamespace).not.toHaveBeenCalled() + expect(processExitSpy).toHaveBeenCalledWith(1) + }) + + test("handles save failure", async () => { + mockClient.namespaces.list.mockResolvedValue({ + namespaces: [{ name: "personal" }], + }) + vi.mocked(setNamespace).mockResolvedValue({ + success: false, + error: "Permission denied", + }) + + await expect(useNamespace("personal")).rejects.toThrow( + "process.exit called", + ) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + pc.red("Failed to save namespace setting."), + ) + expect(consoleErrorSpy).toHaveBeenCalledWith(pc.gray("Permission denied")) + expect(processExitSpy).toHaveBeenCalledWith(1) + }) + + test("handles API error when listing namespaces", async () => { + mockClient.namespaces.list.mockRejectedValue(new Error("Network error")) + + await expect(useNamespace("any-namespace")).rejects.toThrow("Network error") + + expect(setNamespace).not.toHaveBeenCalled() + expect(processExitSpy).not.toHaveBeenCalled() + }) + + test("lists available namespaces when target not found", async () => { + mockClient.namespaces.list.mockResolvedValue({ + namespaces: [ + { name: "ns1" }, + { name: "ns2" }, + { name: "ns3" }, + { name: "ns4" }, + ], + }) + + await expect(useNamespace("wrong")).rejects.toThrow("process.exit called") + + expect(consoleErrorSpy).toHaveBeenCalledWith( + pc.gray("Available namespaces: ns1, ns2, ns3, ns4"), + ) + }) +})