Skip to content

Commit 5a4fe9b

Browse files
authored
feat: add providers command for managing auth providers (#16)
Add providers command with list, configure, and remove subcommands for managing auth providers, with status-based enable/disable support and credential validation when enabling. Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
1 parent 7f79150 commit 5a4fe9b

5 files changed

Lines changed: 348 additions & 0 deletions

File tree

ARCHITECTURE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ memberstack-cli/
2424
│ │ ├── prices.ts # Price management (create, update, activate, deactivate, delete)
2525
│ │ ├── records.ts # Record CRUD, query, find, import/export, bulk ops
2626
│ │ ├── skills.ts # Agent skill add/remove (wraps npx skills)
27+
│ │ ├── providers.ts # Auth provider management (list, configure, remove)
2728
│ │ ├── tables.ts # Data table CRUD, describe
2829
│ │ ├── users.ts # App user management (list, get, add, remove, update-role)
2930
│ │ └── whoami.ts # Show current app and user
@@ -49,6 +50,7 @@ memberstack-cli/
4950
│ │ ├── prices.test.ts
5051
│ │ ├── records.test.ts
5152
│ │ ├── skills.test.ts
53+
│ │ ├── providers.test.ts
5254
│ │ ├── tables.test.ts
5355
│ │ ├── users.test.ts
5456
│ │ └── whoami.test.ts

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ memberstack skills add memberstack-cli
6666
| `records` | CRUD, query, import/export, bulk ops |
6767
| `custom-fields` | List, create, update, and delete custom fields |
6868
| `users` | List, get, add, remove, and update roles for app users |
69+
| `providers` | List, configure, and remove auth providers (e.g. Google) |
6970
| `skills` | Add/remove agent skills for Claude Code and Codex |
7071

7172
For full command details and usage, see the [Command Reference](https://memberstack-cli.flashbrew.digital/docs/commands).

src/commands/providers.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { Command, Option } from "commander";
2+
import yoctoSpinner from "yocto-spinner";
3+
import { graphqlRequest } from "../lib/graphql-client.js";
4+
import {
5+
printError,
6+
printRecord,
7+
printSuccess,
8+
printTable,
9+
} from "../lib/utils.js";
10+
11+
interface AuthProviderConfig {
12+
clientId: string | null;
13+
enabled: boolean;
14+
id: string;
15+
name: string;
16+
provider: string;
17+
providerType: string;
18+
}
19+
20+
const PROVIDER_FIELDS = `
21+
id
22+
providerType
23+
name
24+
provider
25+
enabled
26+
clientId
27+
`;
28+
29+
const PROVIDER_TYPES = [
30+
"GOOGLE",
31+
"FACEBOOK",
32+
"GITHUB",
33+
"LINKEDIN",
34+
"SPOTIFY",
35+
"DRIBBBLE",
36+
];
37+
38+
export const providersCommand = new Command("providers")
39+
.usage("<command> [options]")
40+
.description("Manage auth providers (e.g. Google)");
41+
42+
providersCommand
43+
.command("list")
44+
.description("List configured auth providers")
45+
.action(async () => {
46+
const spinner = yoctoSpinner({
47+
text: "Fetching providers...",
48+
}).start();
49+
try {
50+
const result = await graphqlRequest<{
51+
getSSOClients: AuthProviderConfig[];
52+
}>({
53+
query: `query { getSSOClients { ${PROVIDER_FIELDS} } }`,
54+
});
55+
spinner.stop();
56+
const rows = result.getSSOClients.map((p) => ({
57+
id: p.id,
58+
type: p.providerType,
59+
name: p.name,
60+
enabled: p.enabled,
61+
clientId: p.clientId ?? "",
62+
}));
63+
printTable(rows);
64+
} catch (error) {
65+
spinner.stop();
66+
printError(
67+
error instanceof Error ? error.message : "An unknown error occurred"
68+
);
69+
process.exitCode = 1;
70+
}
71+
});
72+
73+
providersCommand
74+
.command("configure")
75+
.description("Configure an auth provider")
76+
.addOption(
77+
new Option("--type <type>", "Provider type")
78+
.choices(PROVIDER_TYPES)
79+
.makeOptionMandatory()
80+
)
81+
.option("--name <name>", "Display name")
82+
.option("--client-id <id>", "OAuth client ID")
83+
.option("--client-secret <secret>", "OAuth client secret")
84+
.addOption(
85+
new Option("--status <status>", "Provider status")
86+
.choices(["enabled", "disabled"])
87+
.makeOptionMandatory(false)
88+
)
89+
.action(
90+
async (opts: {
91+
type: string;
92+
name?: string;
93+
clientId?: string;
94+
clientSecret?: string;
95+
status?: "enabled" | "disabled";
96+
}) => {
97+
const isEnabling = opts.status === "enabled";
98+
const hasClientId = Boolean(opts.clientId);
99+
const hasClientSecret = Boolean(opts.clientSecret);
100+
101+
if (isEnabling && !(hasClientId && hasClientSecret)) {
102+
printError(
103+
"--status enabled requires both --client-id and --client-secret"
104+
);
105+
process.exitCode = 1;
106+
return;
107+
}
108+
109+
const spinner = yoctoSpinner({
110+
text: "Configuring provider...",
111+
}).start();
112+
try {
113+
const input: Record<string, unknown> = {
114+
provider: opts.type.toLowerCase(),
115+
};
116+
if (opts.name) {
117+
input.name = opts.name;
118+
}
119+
if (opts.clientId) {
120+
input.clientId = opts.clientId;
121+
}
122+
if (opts.clientSecret) {
123+
input.clientSecret = opts.clientSecret;
124+
}
125+
if (opts.status !== undefined) {
126+
input.enabled = opts.status === "enabled";
127+
}
128+
129+
const result = await graphqlRequest<{
130+
updateSSOClient: AuthProviderConfig;
131+
}>({
132+
query: `mutation($input: UpdateSSOClientInput!) {
133+
updateSSOClient(input: $input) {
134+
${PROVIDER_FIELDS}
135+
}
136+
}`,
137+
variables: { input },
138+
});
139+
spinner.stop();
140+
printSuccess(`Provider "${opts.type}" configured.`);
141+
printRecord({
142+
id: result.updateSSOClient.id,
143+
type: result.updateSSOClient.providerType,
144+
name: result.updateSSOClient.name,
145+
enabled: result.updateSSOClient.enabled,
146+
clientId: result.updateSSOClient.clientId ?? "",
147+
});
148+
} catch (error) {
149+
spinner.stop();
150+
printError(
151+
error instanceof Error ? error.message : "An unknown error occurred"
152+
);
153+
process.exitCode = 1;
154+
}
155+
}
156+
);
157+
158+
providersCommand
159+
.command("remove")
160+
.description("Remove an auth provider")
161+
.argument("<id>", "Provider config ID")
162+
.action(async (id: string) => {
163+
const spinner = yoctoSpinner({
164+
text: "Removing provider...",
165+
}).start();
166+
try {
167+
await graphqlRequest<{ removeSSOClient: string }>({
168+
query: `mutation($input: RemoveSSOClientInput!) {
169+
removeSSOClient(input: $input)
170+
}`,
171+
variables: { input: { id } },
172+
});
173+
spinner.stop();
174+
printSuccess(`Provider "${id}" removed.`);
175+
} catch (error) {
176+
spinner.stop();
177+
printError(
178+
error instanceof Error ? error.message : "An unknown error occurred"
179+
);
180+
process.exitCode = 1;
181+
}
182+
});

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { membersCommand } from "./commands/members.js";
1010
import { permissionsCommand } from "./commands/permissions.js";
1111
import { plansCommand } from "./commands/plans.js";
1212
import { pricesCommand } from "./commands/prices.js";
13+
import { providersCommand } from "./commands/providers.js";
1314
import { recordsCommand } from "./commands/records.js";
1415
import { skillsCommand } from "./commands/skills.js";
1516
import { tablesCommand } from "./commands/tables.js";
@@ -68,6 +69,7 @@ program.addCommand(tablesCommand);
6869
program.addCommand(recordsCommand);
6970
program.addCommand(customFieldsCommand);
7071
program.addCommand(usersCommand);
72+
program.addCommand(providersCommand);
7173
program.addCommand(skillsCommand);
7274

7375
await program.parseAsync();

tests/commands/providers.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { createMockSpinner, runCommand } from "./helpers.js";
3+
4+
vi.mock("yocto-spinner", () => ({ default: () => createMockSpinner() }));
5+
vi.mock("../../src/lib/program.js", () => ({
6+
program: { opts: () => ({}) },
7+
}));
8+
9+
const graphqlRequest = vi.fn();
10+
vi.mock("../../src/lib/graphql-client.js", () => ({
11+
graphqlRequest: (...args: unknown[]) => graphqlRequest(...args),
12+
}));
13+
14+
const { providersCommand } = await import("../../src/commands/providers.js");
15+
16+
const mockProvider = {
17+
id: "sso_1",
18+
providerType: "GOOGLE",
19+
name: "Google",
20+
provider: "google",
21+
enabled: true,
22+
clientId: "client_123",
23+
};
24+
25+
describe("providers", () => {
26+
it("list fetches providers", async () => {
27+
graphqlRequest.mockResolvedValueOnce({
28+
getSSOClients: [mockProvider],
29+
});
30+
31+
await runCommand(providersCommand, ["list"]);
32+
33+
expect(graphqlRequest).toHaveBeenCalledWith(
34+
expect.objectContaining({
35+
query: expect.stringContaining("getSSOClients"),
36+
})
37+
);
38+
});
39+
40+
it("configure sends type and options", async () => {
41+
graphqlRequest.mockResolvedValueOnce({
42+
updateSSOClient: mockProvider,
43+
});
44+
45+
await runCommand(providersCommand, [
46+
"configure",
47+
"--type",
48+
"GOOGLE",
49+
"--client-id",
50+
"my_client_id",
51+
"--client-secret",
52+
"my_secret",
53+
"--status",
54+
"enabled",
55+
]);
56+
57+
const call = graphqlRequest.mock.calls[0][0];
58+
expect(call.variables.input.provider).toBe("google");
59+
expect(call.variables.input.clientId).toBe("my_client_id");
60+
expect(call.variables.input.clientSecret).toBe("my_secret");
61+
expect(call.variables.input.enabled).toBe(true);
62+
});
63+
64+
it("configure sends type only", async () => {
65+
graphqlRequest.mockResolvedValueOnce({
66+
updateSSOClient: mockProvider,
67+
});
68+
69+
await runCommand(providersCommand, ["configure", "--type", "GITHUB"]);
70+
71+
const call = graphqlRequest.mock.calls[0][0];
72+
expect(call.variables.input.provider).toBe("github");
73+
expect(call.variables.input.clientId).toBeUndefined();
74+
});
75+
76+
it("configure can disable a provider", async () => {
77+
graphqlRequest.mockResolvedValueOnce({
78+
updateSSOClient: { ...mockProvider, enabled: false },
79+
});
80+
81+
await runCommand(providersCommand, [
82+
"configure",
83+
"--type",
84+
"GOOGLE",
85+
"--status",
86+
"disabled",
87+
]);
88+
89+
const call = graphqlRequest.mock.calls[0][0];
90+
expect(call.variables.input.enabled).toBe(false);
91+
});
92+
93+
it("configure rejects enabling without client id", async () => {
94+
const original = process.exitCode;
95+
const callCountBefore = graphqlRequest.mock.calls.length;
96+
97+
await runCommand(providersCommand, [
98+
"configure",
99+
"--type",
100+
"GOOGLE",
101+
"--status",
102+
"enabled",
103+
"--client-secret",
104+
"my_secret",
105+
]);
106+
107+
expect(process.exitCode).toBe(1);
108+
expect(graphqlRequest.mock.calls.length).toBe(callCountBefore);
109+
process.exitCode = original;
110+
});
111+
112+
it("configure rejects enabling without client secret", async () => {
113+
const original = process.exitCode;
114+
const callCountBefore = graphqlRequest.mock.calls.length;
115+
116+
await runCommand(providersCommand, [
117+
"configure",
118+
"--type",
119+
"GOOGLE",
120+
"--status",
121+
"enabled",
122+
"--client-id",
123+
"my_client_id",
124+
]);
125+
126+
expect(process.exitCode).toBe(1);
127+
expect(graphqlRequest.mock.calls.length).toBe(callCountBefore);
128+
process.exitCode = original;
129+
});
130+
131+
it("remove sends id", async () => {
132+
graphqlRequest.mockResolvedValueOnce({ removeSSOClient: "sso_1" });
133+
134+
await runCommand(providersCommand, ["remove", "sso_1"]);
135+
136+
expect(graphqlRequest).toHaveBeenCalledWith(
137+
expect.objectContaining({
138+
variables: { input: { id: "sso_1" } },
139+
})
140+
);
141+
});
142+
143+
it("rejects invalid type", async () => {
144+
const original = process.exitCode;
145+
try {
146+
await runCommand(providersCommand, ["configure", "--type", "INVALID"]);
147+
} catch {
148+
// Commander throws on invalid choices
149+
}
150+
process.exitCode = original;
151+
});
152+
153+
it("handles errors gracefully", async () => {
154+
graphqlRequest.mockRejectedValueOnce(new Error("Unauthorized"));
155+
156+
const original = process.exitCode;
157+
await runCommand(providersCommand, ["list"]);
158+
expect(process.exitCode).toBe(1);
159+
process.exitCode = original;
160+
});
161+
});

0 commit comments

Comments
 (0)