Skip to content

Commit 9a59bd2

Browse files
authored
feat: add sso command for managing SSO apps (#18)
Add sso command with list, create, update, and delete subcommands for managing SSO app registrations, with repeatable redirect URI support and required-option validation. Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
1 parent ba7a81b commit 9a59bd2

5 files changed

Lines changed: 369 additions & 2 deletions

File tree

ARCHITECTURE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ memberstack-cli/
2525
│ │ ├── records.ts # Record CRUD, query, find, import/export, bulk ops
2626
│ │ ├── skills.ts # Agent skill add/remove (wraps npx skills)
2727
│ │ ├── providers.ts # Auth provider management (list, configure, remove)
28+
│ │ ├── sso.ts # SSO app management (list, create, update, delete)
2829
│ │ ├── tables.ts # Data table CRUD, describe
2930
│ │ ├── users.ts # App user management (list, get, add, remove, update-role)
3031
│ │ └── whoami.ts # Show current app and user
@@ -51,6 +52,7 @@ memberstack-cli/
5152
│ │ ├── records.test.ts
5253
│ │ ├── skills.test.ts
5354
│ │ ├── providers.test.ts
55+
│ │ ├── sso.test.ts
5456
│ │ ├── tables.test.ts
5557
│ │ ├── users.test.ts
5658
│ │ └── whoami.test.ts

src/commands/sso.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { Command } 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 SSOApp {
12+
clientId: string;
13+
clientSecret: string;
14+
id: string;
15+
name: string;
16+
redirectUris: string[];
17+
}
18+
19+
const SSO_APP_FIELDS = `
20+
id
21+
name
22+
clientId
23+
clientSecret
24+
redirectUris
25+
`;
26+
27+
const collect = (value: string, previous: string[]): string[] => [
28+
...previous,
29+
value,
30+
];
31+
32+
export const ssoCommand = new Command("sso")
33+
.usage("<command> [options]")
34+
.description("Manage SSO apps");
35+
36+
ssoCommand
37+
.command("list")
38+
.description("List all SSO apps")
39+
.action(async () => {
40+
const spinner = yoctoSpinner({ text: "Fetching SSO apps..." }).start();
41+
try {
42+
const result = await graphqlRequest<{
43+
getSSOApps: SSOApp[];
44+
}>({
45+
query: `query { getSSOApps { ${SSO_APP_FIELDS} } }`,
46+
});
47+
spinner.stop();
48+
const rows = result.getSSOApps.map((app) => ({
49+
id: app.id,
50+
name: app.name,
51+
clientId: app.clientId,
52+
}));
53+
printTable(rows);
54+
} catch (error) {
55+
spinner.stop();
56+
printError(
57+
error instanceof Error ? error.message : "An unknown error occurred"
58+
);
59+
process.exitCode = 1;
60+
}
61+
});
62+
63+
ssoCommand
64+
.command("create")
65+
.description("Create an SSO app")
66+
.requiredOption("--name <name>", "App name")
67+
.requiredOption(
68+
"--redirect-uri <uri>",
69+
"Redirect URI (repeatable)",
70+
collect,
71+
[]
72+
)
73+
.action(async (opts: { name: string; redirectUri: string[] }) => {
74+
if (opts.redirectUri.length === 0) {
75+
printError("At least one --redirect-uri is required.");
76+
process.exitCode = 1;
77+
return;
78+
}
79+
const spinner = yoctoSpinner({ text: "Creating SSO app..." }).start();
80+
try {
81+
const result = await graphqlRequest<{
82+
createSSOApp: SSOApp;
83+
}>({
84+
query: `mutation($input: CreateSSOAppInput!) {
85+
createSSOApp(input: $input) {
86+
${SSO_APP_FIELDS}
87+
}
88+
}`,
89+
variables: {
90+
input: { name: opts.name, redirectUris: opts.redirectUri },
91+
},
92+
});
93+
spinner.stop();
94+
printSuccess("SSO app created successfully.");
95+
printRecord(result.createSSOApp);
96+
} catch (error) {
97+
spinner.stop();
98+
printError(
99+
error instanceof Error ? error.message : "An unknown error occurred"
100+
);
101+
process.exitCode = 1;
102+
}
103+
});
104+
105+
ssoCommand
106+
.command("update")
107+
.description("Update an SSO app")
108+
.argument("<id>", "SSO app ID")
109+
.option("--name <name>", "App name")
110+
.option("--redirect-uri <uri>", "Redirect URI (repeatable)", collect, [])
111+
.action(
112+
async (id: string, opts: { name?: string; redirectUri: string[] }) => {
113+
const input: Record<string, unknown> = { id };
114+
if (opts.name) {
115+
input.name = opts.name;
116+
}
117+
if (opts.redirectUri.length > 0) {
118+
input.redirectUris = opts.redirectUri;
119+
}
120+
121+
if (Object.keys(input).length <= 1) {
122+
printError(
123+
"No update options provided. Use --help to see available options."
124+
);
125+
process.exitCode = 1;
126+
return;
127+
}
128+
129+
const spinner = yoctoSpinner({ text: "Updating SSO app..." }).start();
130+
try {
131+
const result = await graphqlRequest<{
132+
updateSSOApp: SSOApp;
133+
}>({
134+
query: `mutation($input: UpdateSSOAppInput!) {
135+
updateSSOApp(input: $input) {
136+
${SSO_APP_FIELDS}
137+
}
138+
}`,
139+
variables: { input },
140+
});
141+
spinner.stop();
142+
printSuccess("SSO app updated successfully.");
143+
printRecord(result.updateSSOApp);
144+
} catch (error) {
145+
spinner.stop();
146+
printError(
147+
error instanceof Error ? error.message : "An unknown error occurred"
148+
);
149+
process.exitCode = 1;
150+
}
151+
}
152+
);
153+
154+
ssoCommand
155+
.command("delete")
156+
.description("Delete an SSO app")
157+
.argument("<id>", "SSO app ID")
158+
.action(async (id: string) => {
159+
const spinner = yoctoSpinner({ text: "Deleting SSO app..." }).start();
160+
try {
161+
const result = await graphqlRequest<{ deleteSSOApp: string }>({
162+
query: `mutation($input: DeleteSSOAppInput!) {
163+
deleteSSOApp(input: $input)
164+
}`,
165+
variables: { input: { id } },
166+
});
167+
spinner.stop();
168+
printSuccess(`SSO app ${result.deleteSSOApp} deleted.`);
169+
} catch (error) {
170+
spinner.stop();
171+
printError(
172+
error instanceof Error ? error.message : "An unknown error occurred"
173+
);
174+
process.exitCode = 1;
175+
}
176+
});

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { pricesCommand } from "./commands/prices.js";
1313
import { providersCommand } from "./commands/providers.js";
1414
import { recordsCommand } from "./commands/records.js";
1515
import { skillsCommand } from "./commands/skills.js";
16+
import { ssoCommand } from "./commands/sso.js";
1617
import { tablesCommand } from "./commands/tables.js";
1718
import { usersCommand } from "./commands/users.js";
1819
import { whoamiCommand } from "./commands/whoami.js";
@@ -71,5 +72,6 @@ program.addCommand(customFieldsCommand);
7172
program.addCommand(usersCommand);
7273
program.addCommand(providersCommand);
7374
program.addCommand(skillsCommand);
75+
program.addCommand(ssoCommand);
7476

7577
await program.parseAsync();

tests/commands/sso.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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 { ssoCommand } = await import("../../src/commands/sso.js");
15+
16+
const mockSSOApp = {
17+
id: "sso_app_1",
18+
name: "My App",
19+
clientId: "client_123",
20+
clientSecret: "secret_456",
21+
redirectUris: ["https://example.com/callback"],
22+
};
23+
24+
describe("sso", () => {
25+
it("list fetches SSO apps", async () => {
26+
graphqlRequest.mockResolvedValueOnce({
27+
getSSOApps: [mockSSOApp],
28+
});
29+
30+
await runCommand(ssoCommand, ["list"]);
31+
32+
expect(graphqlRequest).toHaveBeenCalledWith(
33+
expect.objectContaining({
34+
query: expect.stringContaining("getSSOApps"),
35+
})
36+
);
37+
});
38+
39+
it("create sends name and redirect URIs", async () => {
40+
graphqlRequest.mockResolvedValueOnce({
41+
createSSOApp: mockSSOApp,
42+
});
43+
44+
await runCommand(ssoCommand, [
45+
"create",
46+
"--name",
47+
"My App",
48+
"--redirect-uri",
49+
"https://example.com/callback",
50+
]);
51+
52+
const call = graphqlRequest.mock.calls[0][0];
53+
expect(call.variables.input).toEqual({
54+
name: "My App",
55+
redirectUris: ["https://example.com/callback"],
56+
});
57+
});
58+
59+
it("create supports multiple redirect URIs", async () => {
60+
graphqlRequest.mockResolvedValueOnce({
61+
createSSOApp: {
62+
...mockSSOApp,
63+
redirectUris: [
64+
"https://example.com/callback",
65+
"https://example.com/auth",
66+
],
67+
},
68+
});
69+
70+
await runCommand(ssoCommand, [
71+
"create",
72+
"--name",
73+
"My App",
74+
"--redirect-uri",
75+
"https://example.com/callback",
76+
"--redirect-uri",
77+
"https://example.com/auth",
78+
]);
79+
80+
const call = graphqlRequest.mock.calls[0][0];
81+
expect(call.variables.input.redirectUris).toEqual([
82+
"https://example.com/callback",
83+
"https://example.com/auth",
84+
]);
85+
});
86+
87+
it("update sends id and name", async () => {
88+
graphqlRequest.mockResolvedValueOnce({
89+
updateSSOApp: { ...mockSSOApp, name: "Renamed" },
90+
});
91+
92+
await runCommand(ssoCommand, ["update", "sso_app_1", "--name", "Renamed"]);
93+
94+
const call = graphqlRequest.mock.calls[0][0];
95+
expect(call.variables.input.id).toBe("sso_app_1");
96+
expect(call.variables.input.name).toBe("Renamed");
97+
});
98+
99+
it("update sends id and redirect URIs", async () => {
100+
graphqlRequest.mockResolvedValueOnce({
101+
updateSSOApp: mockSSOApp,
102+
});
103+
104+
await runCommand(ssoCommand, [
105+
"update",
106+
"sso_app_1",
107+
"--redirect-uri",
108+
"https://new.example.com/callback",
109+
]);
110+
111+
const call = graphqlRequest.mock.calls[0][0];
112+
expect(call.variables.input.id).toBe("sso_app_1");
113+
expect(call.variables.input.redirectUris).toEqual([
114+
"https://new.example.com/callback",
115+
]);
116+
});
117+
118+
it("update rejects with no options", async () => {
119+
const original = process.exitCode;
120+
await runCommand(ssoCommand, ["update", "sso_app_1"]);
121+
expect(process.exitCode).toBe(1);
122+
process.exitCode = original;
123+
});
124+
125+
it("create rejects with no redirect URIs", async () => {
126+
const original = process.exitCode;
127+
await runCommand(ssoCommand, ["create", "--name", "No URIs"]);
128+
expect(process.exitCode).toBe(1);
129+
expect(graphqlRequest).not.toHaveBeenCalled();
130+
process.exitCode = original;
131+
});
132+
133+
it("delete sends id", async () => {
134+
graphqlRequest.mockResolvedValueOnce({ deleteSSOApp: "sso_app_1" });
135+
136+
await runCommand(ssoCommand, ["delete", "sso_app_1"]);
137+
138+
expect(graphqlRequest).toHaveBeenCalledWith(
139+
expect.objectContaining({
140+
variables: { input: { id: "sso_app_1" } },
141+
})
142+
);
143+
});
144+
145+
it("list handles errors gracefully", async () => {
146+
graphqlRequest.mockRejectedValueOnce(new Error("Unauthorized"));
147+
148+
const original = process.exitCode;
149+
await runCommand(ssoCommand, ["list"]);
150+
expect(process.exitCode).toBe(1);
151+
process.exitCode = original;
152+
});
153+
154+
it("create handles errors gracefully", async () => {
155+
graphqlRequest.mockRejectedValueOnce(new Error("Duplicate name"));
156+
157+
const original = process.exitCode;
158+
await runCommand(ssoCommand, [
159+
"create",
160+
"--name",
161+
"Bad",
162+
"--redirect-uri",
163+
"https://example.com",
164+
]);
165+
expect(process.exitCode).toBe(1);
166+
process.exitCode = original;
167+
});
168+
169+
it("update handles errors gracefully", async () => {
170+
graphqlRequest.mockRejectedValueOnce(new Error("Not found"));
171+
172+
const original = process.exitCode;
173+
await runCommand(ssoCommand, ["update", "sso_bad", "--name", "test"]);
174+
expect(process.exitCode).toBe(1);
175+
process.exitCode = original;
176+
});
177+
178+
it("delete handles errors gracefully", async () => {
179+
graphqlRequest.mockRejectedValueOnce(new Error("Not found"));
180+
181+
const original = process.exitCode;
182+
await runCommand(ssoCommand, ["delete", "sso_bad"]);
183+
expect(process.exitCode).toBe(1);
184+
process.exitCode = original;
185+
});
186+
});

0 commit comments

Comments
 (0)