diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 007bd4f..f03c27c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,6 +24,7 @@ memberstack-cli/ │ │ ├── prices.ts # Price management (create, update, activate, deactivate, delete) │ │ ├── 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) │ │ ├── tables.ts # Data table CRUD, describe │ │ ├── users.ts # App user management (list, get, add, remove, update-role) │ │ └── whoami.ts # Show current app and user @@ -49,6 +50,7 @@ memberstack-cli/ │ │ ├── prices.test.ts │ │ ├── records.test.ts │ │ ├── skills.test.ts +│ │ ├── providers.test.ts │ │ ├── tables.test.ts │ │ ├── users.test.ts │ │ └── whoami.test.ts diff --git a/README.md b/README.md index af55ac1..496dad6 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ memberstack skills add memberstack-cli | `records` | CRUD, query, import/export, bulk ops | | `custom-fields` | List, create, update, and delete custom fields | | `users` | List, get, add, remove, and update roles for app users | +| `providers` | List, configure, and remove auth providers (e.g. Google) | | `skills` | Add/remove agent skills for Claude Code and Codex | For full command details and usage, see the [Command Reference](https://memberstack-cli.flashbrew.digital/docs/commands). diff --git a/src/commands/providers.ts b/src/commands/providers.ts new file mode 100644 index 0000000..51a71e6 --- /dev/null +++ b/src/commands/providers.ts @@ -0,0 +1,182 @@ +import { Command, Option } from "commander"; +import yoctoSpinner from "yocto-spinner"; +import { graphqlRequest } from "../lib/graphql-client.js"; +import { + printError, + printRecord, + printSuccess, + printTable, +} from "../lib/utils.js"; + +interface AuthProviderConfig { + clientId: string | null; + enabled: boolean; + id: string; + name: string; + provider: string; + providerType: string; +} + +const PROVIDER_FIELDS = ` + id + providerType + name + provider + enabled + clientId +`; + +const PROVIDER_TYPES = [ + "GOOGLE", + "FACEBOOK", + "GITHUB", + "LINKEDIN", + "SPOTIFY", + "DRIBBBLE", +]; + +export const providersCommand = new Command("providers") + .usage(" [options]") + .description("Manage auth providers (e.g. Google)"); + +providersCommand + .command("list") + .description("List configured auth providers") + .action(async () => { + const spinner = yoctoSpinner({ + text: "Fetching providers...", + }).start(); + try { + const result = await graphqlRequest<{ + getSSOClients: AuthProviderConfig[]; + }>({ + query: `query { getSSOClients { ${PROVIDER_FIELDS} } }`, + }); + spinner.stop(); + const rows = result.getSSOClients.map((p) => ({ + id: p.id, + type: p.providerType, + name: p.name, + enabled: p.enabled, + clientId: p.clientId ?? "", + })); + printTable(rows); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +providersCommand + .command("configure") + .description("Configure an auth provider") + .addOption( + new Option("--type ", "Provider type") + .choices(PROVIDER_TYPES) + .makeOptionMandatory() + ) + .option("--name ", "Display name") + .option("--client-id ", "OAuth client ID") + .option("--client-secret ", "OAuth client secret") + .addOption( + new Option("--status ", "Provider status") + .choices(["enabled", "disabled"]) + .makeOptionMandatory(false) + ) + .action( + async (opts: { + type: string; + name?: string; + clientId?: string; + clientSecret?: string; + status?: "enabled" | "disabled"; + }) => { + const isEnabling = opts.status === "enabled"; + const hasClientId = Boolean(opts.clientId); + const hasClientSecret = Boolean(opts.clientSecret); + + if (isEnabling && !(hasClientId && hasClientSecret)) { + printError( + "--status enabled requires both --client-id and --client-secret" + ); + process.exitCode = 1; + return; + } + + const spinner = yoctoSpinner({ + text: "Configuring provider...", + }).start(); + try { + const input: Record = { + provider: opts.type.toLowerCase(), + }; + if (opts.name) { + input.name = opts.name; + } + if (opts.clientId) { + input.clientId = opts.clientId; + } + if (opts.clientSecret) { + input.clientSecret = opts.clientSecret; + } + if (opts.status !== undefined) { + input.enabled = opts.status === "enabled"; + } + + const result = await graphqlRequest<{ + updateSSOClient: AuthProviderConfig; + }>({ + query: `mutation($input: UpdateSSOClientInput!) { + updateSSOClient(input: $input) { + ${PROVIDER_FIELDS} + } +}`, + variables: { input }, + }); + spinner.stop(); + printSuccess(`Provider "${opts.type}" configured.`); + printRecord({ + id: result.updateSSOClient.id, + type: result.updateSSOClient.providerType, + name: result.updateSSOClient.name, + enabled: result.updateSSOClient.enabled, + clientId: result.updateSSOClient.clientId ?? "", + }); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + } + ); + +providersCommand + .command("remove") + .description("Remove an auth provider") + .argument("", "Provider config ID") + .action(async (id: string) => { + const spinner = yoctoSpinner({ + text: "Removing provider...", + }).start(); + try { + await graphqlRequest<{ removeSSOClient: string }>({ + query: `mutation($input: RemoveSSOClientInput!) { + removeSSOClient(input: $input) +}`, + variables: { input: { id } }, + }); + spinner.stop(); + printSuccess(`Provider "${id}" removed.`); + } 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 57ee179..4868cd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { membersCommand } from "./commands/members.js"; import { permissionsCommand } from "./commands/permissions.js"; import { plansCommand } from "./commands/plans.js"; 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 { tablesCommand } from "./commands/tables.js"; @@ -68,6 +69,7 @@ program.addCommand(tablesCommand); program.addCommand(recordsCommand); program.addCommand(customFieldsCommand); program.addCommand(usersCommand); +program.addCommand(providersCommand); program.addCommand(skillsCommand); await program.parseAsync(); diff --git a/tests/commands/providers.test.ts b/tests/commands/providers.test.ts new file mode 100644 index 0000000..58c5206 --- /dev/null +++ b/tests/commands/providers.test.ts @@ -0,0 +1,161 @@ +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 { providersCommand } = await import("../../src/commands/providers.js"); + +const mockProvider = { + id: "sso_1", + providerType: "GOOGLE", + name: "Google", + provider: "google", + enabled: true, + clientId: "client_123", +}; + +describe("providers", () => { + it("list fetches providers", async () => { + graphqlRequest.mockResolvedValueOnce({ + getSSOClients: [mockProvider], + }); + + await runCommand(providersCommand, ["list"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.stringContaining("getSSOClients"), + }) + ); + }); + + it("configure sends type and options", async () => { + graphqlRequest.mockResolvedValueOnce({ + updateSSOClient: mockProvider, + }); + + await runCommand(providersCommand, [ + "configure", + "--type", + "GOOGLE", + "--client-id", + "my_client_id", + "--client-secret", + "my_secret", + "--status", + "enabled", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.provider).toBe("google"); + expect(call.variables.input.clientId).toBe("my_client_id"); + expect(call.variables.input.clientSecret).toBe("my_secret"); + expect(call.variables.input.enabled).toBe(true); + }); + + it("configure sends type only", async () => { + graphqlRequest.mockResolvedValueOnce({ + updateSSOClient: mockProvider, + }); + + await runCommand(providersCommand, ["configure", "--type", "GITHUB"]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.provider).toBe("github"); + expect(call.variables.input.clientId).toBeUndefined(); + }); + + it("configure can disable a provider", async () => { + graphqlRequest.mockResolvedValueOnce({ + updateSSOClient: { ...mockProvider, enabled: false }, + }); + + await runCommand(providersCommand, [ + "configure", + "--type", + "GOOGLE", + "--status", + "disabled", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.enabled).toBe(false); + }); + + it("configure rejects enabling without client id", async () => { + const original = process.exitCode; + const callCountBefore = graphqlRequest.mock.calls.length; + + await runCommand(providersCommand, [ + "configure", + "--type", + "GOOGLE", + "--status", + "enabled", + "--client-secret", + "my_secret", + ]); + + expect(process.exitCode).toBe(1); + expect(graphqlRequest.mock.calls.length).toBe(callCountBefore); + process.exitCode = original; + }); + + it("configure rejects enabling without client secret", async () => { + const original = process.exitCode; + const callCountBefore = graphqlRequest.mock.calls.length; + + await runCommand(providersCommand, [ + "configure", + "--type", + "GOOGLE", + "--status", + "enabled", + "--client-id", + "my_client_id", + ]); + + expect(process.exitCode).toBe(1); + expect(graphqlRequest.mock.calls.length).toBe(callCountBefore); + process.exitCode = original; + }); + + it("remove sends id", async () => { + graphqlRequest.mockResolvedValueOnce({ removeSSOClient: "sso_1" }); + + await runCommand(providersCommand, ["remove", "sso_1"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { input: { id: "sso_1" } }, + }) + ); + }); + + it("rejects invalid type", async () => { + const original = process.exitCode; + try { + await runCommand(providersCommand, ["configure", "--type", "INVALID"]); + } catch { + // Commander throws on invalid choices + } + process.exitCode = original; + }); + + it("handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Unauthorized")); + + const original = process.exitCode; + await runCommand(providersCommand, ["list"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); +});