diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c3c9deb..9435305 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -25,6 +25,7 @@ memberstack-cli/ │ │ ├── records.ts # Record CRUD, query, find, import/export, bulk ops │ │ ├── skills.ts # Agent skill add/remove (wraps npx skills) │ │ ├── providers.ts # Auth provider management (list, configure, remove) +│ │ ├── sso.ts # SSO app management (list, create, update, delete) │ │ ├── tables.ts # Data table CRUD, describe │ │ ├── users.ts # App user management (list, get, add, remove, update-role) │ │ └── whoami.ts # Show current app and user @@ -51,6 +52,7 @@ memberstack-cli/ │ │ ├── records.test.ts │ │ ├── skills.test.ts │ │ ├── providers.test.ts +│ │ ├── sso.test.ts │ │ ├── tables.test.ts │ │ ├── users.test.ts │ │ └── whoami.test.ts diff --git a/src/commands/sso.ts b/src/commands/sso.ts new file mode 100644 index 0000000..940f91a --- /dev/null +++ b/src/commands/sso.ts @@ -0,0 +1,176 @@ +import { Command } from "commander"; +import yoctoSpinner from "yocto-spinner"; +import { graphqlRequest } from "../lib/graphql-client.js"; +import { + printError, + printRecord, + printSuccess, + printTable, +} from "../lib/utils.js"; + +interface SSOApp { + clientId: string; + clientSecret: string; + id: string; + name: string; + redirectUris: string[]; +} + +const SSO_APP_FIELDS = ` + id + name + clientId + clientSecret + redirectUris +`; + +const collect = (value: string, previous: string[]): string[] => [ + ...previous, + value, +]; + +export const ssoCommand = new Command("sso") + .usage(" [options]") + .description("Manage SSO apps"); + +ssoCommand + .command("list") + .description("List all SSO apps") + .action(async () => { + const spinner = yoctoSpinner({ text: "Fetching SSO apps..." }).start(); + try { + const result = await graphqlRequest<{ + getSSOApps: SSOApp[]; + }>({ + query: `query { getSSOApps { ${SSO_APP_FIELDS} } }`, + }); + spinner.stop(); + const rows = result.getSSOApps.map((app) => ({ + id: app.id, + name: app.name, + clientId: app.clientId, + })); + printTable(rows); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +ssoCommand + .command("create") + .description("Create an SSO app") + .requiredOption("--name ", "App name") + .requiredOption( + "--redirect-uri ", + "Redirect URI (repeatable)", + collect, + [] + ) + .action(async (opts: { name: string; redirectUri: string[] }) => { + if (opts.redirectUri.length === 0) { + printError("At least one --redirect-uri is required."); + process.exitCode = 1; + return; + } + const spinner = yoctoSpinner({ text: "Creating SSO app..." }).start(); + try { + const result = await graphqlRequest<{ + createSSOApp: SSOApp; + }>({ + query: `mutation($input: CreateSSOAppInput!) { + createSSOApp(input: $input) { + ${SSO_APP_FIELDS} + } +}`, + variables: { + input: { name: opts.name, redirectUris: opts.redirectUri }, + }, + }); + spinner.stop(); + printSuccess("SSO app created successfully."); + printRecord(result.createSSOApp); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +ssoCommand + .command("update") + .description("Update an SSO app") + .argument("", "SSO app ID") + .option("--name ", "App name") + .option("--redirect-uri ", "Redirect URI (repeatable)", collect, []) + .action( + async (id: string, opts: { name?: string; redirectUri: string[] }) => { + const input: Record = { id }; + if (opts.name) { + input.name = opts.name; + } + if (opts.redirectUri.length > 0) { + input.redirectUris = opts.redirectUri; + } + + if (Object.keys(input).length <= 1) { + printError( + "No update options provided. Use --help to see available options." + ); + process.exitCode = 1; + return; + } + + const spinner = yoctoSpinner({ text: "Updating SSO app..." }).start(); + try { + const result = await graphqlRequest<{ + updateSSOApp: SSOApp; + }>({ + query: `mutation($input: UpdateSSOAppInput!) { + updateSSOApp(input: $input) { + ${SSO_APP_FIELDS} + } +}`, + variables: { input }, + }); + spinner.stop(); + printSuccess("SSO app updated successfully."); + printRecord(result.updateSSOApp); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + } + ); + +ssoCommand + .command("delete") + .description("Delete an SSO app") + .argument("", "SSO app ID") + .action(async (id: string) => { + const spinner = yoctoSpinner({ text: "Deleting SSO app..." }).start(); + try { + const result = await graphqlRequest<{ deleteSSOApp: string }>({ + query: `mutation($input: DeleteSSOAppInput!) { + deleteSSOApp(input: $input) +}`, + variables: { input: { id } }, + }); + spinner.stop(); + printSuccess(`SSO app ${result.deleteSSOApp} deleted.`); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); diff --git a/src/index.ts b/src/index.ts index 4868cd9..9a29430 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { pricesCommand } from "./commands/prices.js"; import { providersCommand } from "./commands/providers.js"; import { recordsCommand } from "./commands/records.js"; import { skillsCommand } from "./commands/skills.js"; +import { ssoCommand } from "./commands/sso.js"; import { tablesCommand } from "./commands/tables.js"; import { usersCommand } from "./commands/users.js"; import { whoamiCommand } from "./commands/whoami.js"; @@ -71,5 +72,6 @@ program.addCommand(customFieldsCommand); program.addCommand(usersCommand); program.addCommand(providersCommand); program.addCommand(skillsCommand); +program.addCommand(ssoCommand); await program.parseAsync(); diff --git a/tests/commands/sso.test.ts b/tests/commands/sso.test.ts new file mode 100644 index 0000000..0860b33 --- /dev/null +++ b/tests/commands/sso.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMockSpinner, runCommand } from "./helpers.js"; + +vi.mock("yocto-spinner", () => ({ default: () => createMockSpinner() })); +vi.mock("../../src/lib/program.js", () => ({ + program: { opts: () => ({}) }, +})); + +const graphqlRequest = vi.fn(); +vi.mock("../../src/lib/graphql-client.js", () => ({ + graphqlRequest: (...args: unknown[]) => graphqlRequest(...args), +})); + +const { ssoCommand } = await import("../../src/commands/sso.js"); + +const mockSSOApp = { + id: "sso_app_1", + name: "My App", + clientId: "client_123", + clientSecret: "secret_456", + redirectUris: ["https://example.com/callback"], +}; + +describe("sso", () => { + it("list fetches SSO apps", async () => { + graphqlRequest.mockResolvedValueOnce({ + getSSOApps: [mockSSOApp], + }); + + await runCommand(ssoCommand, ["list"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.stringContaining("getSSOApps"), + }) + ); + }); + + it("create sends name and redirect URIs", async () => { + graphqlRequest.mockResolvedValueOnce({ + createSSOApp: mockSSOApp, + }); + + await runCommand(ssoCommand, [ + "create", + "--name", + "My App", + "--redirect-uri", + "https://example.com/callback", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input).toEqual({ + name: "My App", + redirectUris: ["https://example.com/callback"], + }); + }); + + it("create supports multiple redirect URIs", async () => { + graphqlRequest.mockResolvedValueOnce({ + createSSOApp: { + ...mockSSOApp, + redirectUris: [ + "https://example.com/callback", + "https://example.com/auth", + ], + }, + }); + + await runCommand(ssoCommand, [ + "create", + "--name", + "My App", + "--redirect-uri", + "https://example.com/callback", + "--redirect-uri", + "https://example.com/auth", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.redirectUris).toEqual([ + "https://example.com/callback", + "https://example.com/auth", + ]); + }); + + it("update sends id and name", async () => { + graphqlRequest.mockResolvedValueOnce({ + updateSSOApp: { ...mockSSOApp, name: "Renamed" }, + }); + + await runCommand(ssoCommand, ["update", "sso_app_1", "--name", "Renamed"]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.id).toBe("sso_app_1"); + expect(call.variables.input.name).toBe("Renamed"); + }); + + it("update sends id and redirect URIs", async () => { + graphqlRequest.mockResolvedValueOnce({ + updateSSOApp: mockSSOApp, + }); + + await runCommand(ssoCommand, [ + "update", + "sso_app_1", + "--redirect-uri", + "https://new.example.com/callback", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.id).toBe("sso_app_1"); + expect(call.variables.input.redirectUris).toEqual([ + "https://new.example.com/callback", + ]); + }); + + it("update rejects with no options", async () => { + const original = process.exitCode; + await runCommand(ssoCommand, ["update", "sso_app_1"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("create rejects with no redirect URIs", async () => { + const original = process.exitCode; + await runCommand(ssoCommand, ["create", "--name", "No URIs"]); + expect(process.exitCode).toBe(1); + expect(graphqlRequest).not.toHaveBeenCalled(); + process.exitCode = original; + }); + + it("delete sends id", async () => { + graphqlRequest.mockResolvedValueOnce({ deleteSSOApp: "sso_app_1" }); + + await runCommand(ssoCommand, ["delete", "sso_app_1"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { input: { id: "sso_app_1" } }, + }) + ); + }); + + it("list handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Unauthorized")); + + const original = process.exitCode; + await runCommand(ssoCommand, ["list"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("create handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Duplicate name")); + + const original = process.exitCode; + await runCommand(ssoCommand, [ + "create", + "--name", + "Bad", + "--redirect-uri", + "https://example.com", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("update handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Not found")); + + const original = process.exitCode; + await runCommand(ssoCommand, ["update", "sso_bad", "--name", "test"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("delete handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Not found")); + + const original = process.exitCode; + await runCommand(ssoCommand, ["delete", "sso_bad"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); +}); diff --git a/tests/core/index.test.ts b/tests/core/index.test.ts index 80c8b3b..37c4413 100644 --- a/tests/core/index.test.ts +++ b/tests/core/index.test.ts @@ -39,6 +39,7 @@ vi.mock("../../src/commands/records.js", () => ({ vi.mock("../../src/commands/skills.js", () => ({ skillsCommand: "skills", })); +vi.mock("../../src/commands/sso.js", () => ({ ssoCommand: "sso" })); vi.mock("../../src/commands/tables.js", () => ({ tablesCommand: "tables" })); vi.mock("../../src/commands/users.js", () => ({ usersCommand: "users" })); vi.mock("../../src/commands/whoami.js", () => ({ @@ -115,12 +116,12 @@ describe("index", () => { expect(process.env.NO_COLOR).toBe("1"); }); - it("registers all 13 commands", async () => { + it("registers all 14 commands", async () => { process.argv = ["node", "memberstack"]; await import("../../src/index.js"); - expect(mockAddCommand).toHaveBeenCalledTimes(13); + expect(mockAddCommand).toHaveBeenCalledTimes(14); }); it("calls parseAsync", async () => {