Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
182 changes: 182 additions & 0 deletions src/commands/providers.ts
Original file line number Diff line number Diff line change
@@ -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("<command> [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 <type>", "Provider type")
.choices(PROVIDER_TYPES)
.makeOptionMandatory()
)
.option("--name <name>", "Display name")
.option("--client-id <id>", "OAuth client ID")
.option("--client-secret <secret>", "OAuth client secret")
.addOption(
new Option("--status <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<string, unknown> = {
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("<id>", "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;
}
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
161 changes: 161 additions & 0 deletions tests/commands/providers.test.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});