From cfee9c49dddc7bdb9032388e0e7c2cc3b551137b Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Fri, 20 Feb 2026 14:27:36 +1100 Subject: [PATCH] feat: add users command for managing app users Add users command with list, get, add, remove, and update-role subcommands. Supports looking up users by ID or email, role validation with choices (ADMIN, OWNER, MEMBERS_WRITE, MEMBERS_READ), and paginated listing. Includes unit tests. --- ARCHITECTURE.md | 2 + README.md | 1 + src/commands/users.ts | 237 +++++++++++++++++++++++++++++++++++ src/index.ts | 2 + tests/commands/users.test.ts | 174 +++++++++++++++++++++++++ 5 files changed, 416 insertions(+) create mode 100644 src/commands/users.ts create mode 100644 tests/commands/users.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index db63900..d6dffb0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,6 +23,7 @@ memberstack-cli/ │ │ ├── records.ts # Record CRUD, query, find, import/export, bulk ops │ │ ├── skills.ts # Agent skill add/remove (wraps npx skills) │ │ ├── tables.ts # Data table CRUD, describe +│ │ ├── users.ts # App user management (list, get, add, remove, update-role) │ │ └── whoami.ts # Show current app and user │ │ │ └── lib/ # Shared utilities @@ -45,6 +46,7 @@ memberstack-cli/ │ │ ├── records.test.ts │ │ ├── skills.test.ts │ │ ├── tables.test.ts +│ │ ├── users.test.ts │ │ └── whoami.test.ts │ │ │ └── core/ # Core library tests diff --git a/README.md b/README.md index ed1ef0d..bebf07f 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ memberstack skills add memberstack-cli | `tables` | List, create, update, delete, and describe schema | | `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 | | `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/users.ts b/src/commands/users.ts new file mode 100644 index 0000000..67eb95a --- /dev/null +++ b/src/commands/users.ts @@ -0,0 +1,237 @@ +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 AppUser { + role: string; + user: { + id: string; + auth: { email: string }; + profile: { firstName: string | null; lastName: string | null }; + }; +} + +interface UserAppConnection { + app: { id: string; name: string }; + role: string; +} + +const USER_FIELDS = ` + user { + id + auth { email } + profile { firstName lastName } + } + role +`; + +const ROLES = ["ADMIN", "OWNER", "MEMBERS_WRITE", "MEMBERS_READ"]; + +export const usersCommand = new Command("users") + .usage(" [options]") + .description("Manage users"); + +usersCommand + .command("list") + .description("List users with access to the app") + .action(async () => { + const spinner = yoctoSpinner({ text: "Fetching users..." }).start(); + try { + const allUsers: AppUser[] = []; + let cursor: string | undefined; + const pageSize = 200; + + do { + const result = await graphqlRequest<{ + getUsers: { + edges: { cursor: string; node: AppUser }[]; + }; + }>({ + query: `query($first: Int, $after: String) { + getUsers(first: $first, after: $after) { + edges { cursor node { ${USER_FIELDS} } } + } +}`, + variables: { first: pageSize, after: cursor }, + }); + + const { edges } = result.getUsers; + allUsers.push(...edges.map((e) => e.node)); + + if (edges.length === pageSize) { + cursor = edges.at(-1)?.cursor; + spinner.text = `Fetching users... (${allUsers.length} so far)`; + } else { + cursor = undefined; + } + } while (cursor); + + spinner.stop(); + const rows = allUsers.map((u) => ({ + id: u.user.id, + email: u.user.auth.email, + firstName: u.user.profile.firstName ?? "", + lastName: u.user.profile.lastName ?? "", + role: u.role, + })); + printTable(rows); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +usersCommand + .command("get") + .description("Get a user by ID or email") + .argument("", "User ID or email address") + .action(async (idOrEmail: string) => { + const spinner = yoctoSpinner({ text: "Fetching users..." }).start(); + try { + const result = await graphqlRequest<{ + getUsers: { + edges: { cursor: string; node: AppUser }[]; + }; + }>({ + query: `query { + getUsers { + edges { node { ${USER_FIELDS} } } + } +}`, + }); + spinner.stop(); + + const isEmail = idOrEmail.includes("@"); + const match = result.getUsers.edges.find((e) => + isEmail + ? e.node.user.auth.email === idOrEmail + : e.node.user.id === idOrEmail + ); + + if (!match) { + printError(`User not found: ${idOrEmail}`); + process.exitCode = 1; + return; + } + + printRecord({ + id: match.node.user.id, + email: match.node.user.auth.email, + firstName: match.node.user.profile.firstName ?? "", + lastName: match.node.user.profile.lastName ?? "", + role: match.node.role, + }); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +usersCommand + .command("add") + .description("Add a user to the app") + .requiredOption("--email ", "Email address of the user to add") + .addOption(new Option("--role ", "Role to assign").choices(ROLES)) + .action(async (opts: { email: string; role?: string }) => { + const spinner = yoctoSpinner({ text: "Adding user..." }).start(); + try { + const input: Record = { email: opts.email }; + if (opts.role) { + input.role = opts.role; + } + const result = await graphqlRequest<{ + addUserToApp: UserAppConnection; + }>({ + query: `mutation($input: AddUserToAppInput!) { + addUserToApp(input: $input) { + app { id name } + role + } +}`, + variables: { input }, + }); + spinner.stop(); + printSuccess(`User "${opts.email}" added to app.`); + printRecord(result.addUserToApp); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +usersCommand + .command("remove") + .description("Remove a user from the app") + .argument("", "User ID to remove") + .action(async (userId: string) => { + const spinner = yoctoSpinner({ text: "Removing user..." }).start(); + try { + await graphqlRequest<{ removeUserFromApp: UserAppConnection }>({ + query: `mutation($input: RemoveUserFromAppInput!) { + removeUserFromApp(input: $input) { + app { id name } + role + } +}`, + variables: { input: { userId } }, + }); + spinner.stop(); + printSuccess(`User "${userId}" removed from app.`); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +usersCommand + .command("update-role") + .description("Update a user's role") + .argument("", "User ID to update") + .addOption( + new Option("--role ", "New role to assign") + .choices(ROLES) + .makeOptionMandatory() + ) + .action(async (userId: string, opts: { role: string }) => { + const spinner = yoctoSpinner({ text: "Updating user role..." }).start(); + try { + const result = await graphqlRequest<{ + updateUserRole: UserAppConnection; + }>({ + query: `mutation($input: UpdateUserRoleInput!) { + updateUserRole(input: $input) { + app { id name } + role + } +}`, + variables: { input: { userId, role: opts.role } }, + }); + spinner.stop(); + printSuccess(`User role updated to "${opts.role}".`); + printRecord(result.updateUserRole); + } 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 8011396..d591778 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { plansCommand } from "./commands/plans.js"; import { recordsCommand } from "./commands/records.js"; import { skillsCommand } from "./commands/skills.js"; import { tablesCommand } from "./commands/tables.js"; +import { usersCommand } from "./commands/users.js"; import { whoamiCommand } from "./commands/whoami.js"; import { program } from "./lib/program.js"; @@ -62,6 +63,7 @@ program.addCommand(plansCommand); program.addCommand(tablesCommand); program.addCommand(recordsCommand); program.addCommand(customFieldsCommand); +program.addCommand(usersCommand); program.addCommand(skillsCommand); await program.parseAsync(); diff --git a/tests/commands/users.test.ts b/tests/commands/users.test.ts new file mode 100644 index 0000000..79c41bf --- /dev/null +++ b/tests/commands/users.test.ts @@ -0,0 +1,174 @@ +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 { usersCommand } = await import("../../src/commands/users.js"); + +const mockAppUser = { + user: { + id: "usr_1", + auth: { email: "admin@example.com" }, + profile: { firstName: "Jane", lastName: "Doe" }, + }, + role: "ADMIN", +}; + +describe("users", () => { + it("list fetches users with pagination", async () => { + graphqlRequest.mockResolvedValueOnce({ + getUsers: { + edges: [{ cursor: "c1", node: mockAppUser }], + }, + }); + + await runCommand(usersCommand, ["list"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.stringContaining("getUsers"), + }) + ); + }); + + it("get finds user by ID", async () => { + graphqlRequest.mockResolvedValueOnce({ + getUsers: { + edges: [{ node: mockAppUser }], + }, + }); + + await runCommand(usersCommand, ["get", "usr_1"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.stringContaining("getUsers"), + }) + ); + }); + + it("get finds user by email", async () => { + graphqlRequest.mockResolvedValueOnce({ + getUsers: { + edges: [{ node: mockAppUser }], + }, + }); + + await runCommand(usersCommand, ["get", "admin@example.com"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.stringContaining("getUsers"), + }) + ); + }); + + it("get sets exit code 1 when user not found", async () => { + graphqlRequest.mockResolvedValueOnce({ + getUsers: { + edges: [{ node: mockAppUser }], + }, + }); + + const original = process.exitCode; + await runCommand(usersCommand, ["get", "nonexistent@example.com"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("add sends email and role", async () => { + graphqlRequest.mockResolvedValueOnce({ + addUserToApp: { app: { id: "app_1", name: "My App" }, role: "ADMIN" }, + }); + + await runCommand(usersCommand, [ + "add", + "--email", + "new@example.com", + "--role", + "ADMIN", + ]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { email: "new@example.com", role: "ADMIN" }, + }, + }) + ); + }); + + it("add sends email without role", async () => { + graphqlRequest.mockResolvedValueOnce({ + addUserToApp: { app: { id: "app_1", name: "My App" }, role: "ADMIN" }, + }); + + await runCommand(usersCommand, ["add", "--email", "new@example.com"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { email: "new@example.com" }, + }, + }) + ); + }); + + it("remove sends userId", async () => { + graphqlRequest.mockResolvedValueOnce({ + removeUserFromApp: { + app: { id: "app_1", name: "My App" }, + role: "ADMIN", + }, + }); + + await runCommand(usersCommand, ["remove", "usr_1"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { input: { userId: "usr_1" } }, + }) + ); + }); + + it("update-role sends userId and role", async () => { + graphqlRequest.mockResolvedValueOnce({ + updateUserRole: { + app: { id: "app_1", name: "My App" }, + role: "MEMBERS_READ", + }, + }); + + await runCommand(usersCommand, [ + "update-role", + "usr_1", + "--role", + "MEMBERS_READ", + ]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { userId: "usr_1", role: "MEMBERS_READ" }, + }, + }) + ); + }); + + it("handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Unauthorized")); + + const original = process.exitCode; + await runCommand(usersCommand, ["list"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); +});