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 @@ -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
Expand All @@ -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
Expand Down
176 changes: 176 additions & 0 deletions src/commands/sso.ts
Original file line number Diff line number Diff line change
@@ -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("<command> [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 <name>", "App name")
.requiredOption(
"--redirect-uri <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("<id>", "SSO app ID")
.option("--name <name>", "App name")
.option("--redirect-uri <uri>", "Redirect URI (repeatable)", collect, [])
.action(
async (id: string, opts: { name?: string; redirectUri: string[] }) => {
const input: Record<string, unknown> = { 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("<id>", "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;
}
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,5 +72,6 @@ program.addCommand(customFieldsCommand);
program.addCommand(usersCommand);
program.addCommand(providersCommand);
program.addCommand(skillsCommand);
program.addCommand(ssoCommand);

await program.parseAsync();
186 changes: 186 additions & 0 deletions tests/commands/sso.test.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
Loading