diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c6438b6..007bd4f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -19,6 +19,7 @@ memberstack-cli/ │ │ ├── auth.ts # OAuth login, logout, status │ │ ├── custom-fields.ts # Custom field listing │ │ ├── members.ts # Member CRUD, search, pagination +│ │ ├── permissions.ts # Permission CRUD, link/unlink to plans and members │ │ ├── plans.ts # Plan CRUD, ordering, redirects, permissions │ │ ├── prices.ts # Price management (create, update, activate, deactivate, delete) │ │ ├── records.ts # Record CRUD, query, find, import/export, bulk ops @@ -43,6 +44,7 @@ memberstack-cli/ │ │ ├── apps.test.ts │ │ ├── custom-fields.test.ts │ │ ├── members.test.ts +│ │ ├── permissions.test.ts │ │ ├── plans.test.ts │ │ ├── prices.test.ts │ │ ├── records.test.ts diff --git a/README.md b/README.md index 02b5790..af55ac1 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ memberstack skills add memberstack-cli | `whoami` | Show current authenticated app and user | | `apps` | View, create, update, delete, and restore apps | | `members` | List, create, update, delete, import/export, bulk ops | +| `permissions` | Create, update, delete, and link/unlink to plans and members | | `plans` | List, create, update, delete, and reorder plans | | `prices` | Create, update, activate, deactivate, and delete prices | | `tables` | List, create, update, delete, and describe schema | diff --git a/src/commands/permissions.ts b/src/commands/permissions.ts new file mode 100644 index 0000000..c539a90 --- /dev/null +++ b/src/commands/permissions.ts @@ -0,0 +1,300 @@ +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 Permission { + description: string | null; + id: string; + name: string; +} + +const PERMISSION_FIELDS = ` + id + name + description +`; + +const collect = (value: string, previous: string[]): string[] => [ + ...previous, + value, +]; + +export const permissionsCommand = new Command("permissions") + .usage(" [options]") + .description("Manage permissions"); + +permissionsCommand + .command("list") + .description("List all permissions") + .action(async () => { + const spinner = yoctoSpinner({ text: "Fetching permissions..." }).start(); + try { + const result = await graphqlRequest<{ + getPermissions: Permission[]; + }>({ + query: `query { getPermissions { ${PERMISSION_FIELDS} } }`, + }); + spinner.stop(); + printTable(result.getPermissions); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +permissionsCommand + .command("create") + .description("Create a permission") + .requiredOption("--name ", "Permission name") + .option("--description ", "Permission description") + .action(async (opts: { name: string; description?: string }) => { + const spinner = yoctoSpinner({ text: "Creating permission..." }).start(); + try { + const input: Record = { name: opts.name }; + if (opts.description) { + input.description = opts.description; + } + const result = await graphqlRequest<{ + createPermission: Permission; + }>({ + query: `mutation($input: CreatePermissionInput!) { + createPermission(input: $input) { + ${PERMISSION_FIELDS} + } +}`, + variables: { input }, + }); + spinner.stop(); + printSuccess("Permission created successfully."); + printRecord(result.createPermission); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +permissionsCommand + .command("update") + .description("Update a permission") + .argument("", "Permission ID to update") + .option("--name ", "Permission name") + .option("--description ", "Permission description") + .action( + async ( + permissionId: string, + opts: { name?: string; description?: string } + ) => { + const input: Record = { permissionId }; + if (opts.name) { + input.name = opts.name; + } + if (opts.description) { + input.description = opts.description; + } + + 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 permission..." }).start(); + try { + const result = await graphqlRequest<{ + updatePermission: Permission; + }>({ + query: `mutation($input: UpdatePermissionInput!) { + updatePermission(input: $input) { + ${PERMISSION_FIELDS} + } +}`, + variables: { input }, + }); + spinner.stop(); + printSuccess("Permission updated successfully."); + printRecord(result.updatePermission); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + } + ); + +permissionsCommand + .command("delete") + .description("Delete a permission") + .argument("", "Permission ID to delete") + .action(async (permissionId: string) => { + const spinner = yoctoSpinner({ text: "Deleting permission..." }).start(); + try { + const result = await graphqlRequest<{ + deletePermission: Permission; + }>({ + query: `mutation($input: DeletePermissionInput!) { + deletePermission(input: $input) { + ${PERMISSION_FIELDS} + } +}`, + variables: { input: { permissionId } }, + }); + spinner.stop(); + printSuccess(`Permission "${result.deletePermission.name}" deleted.`); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +permissionsCommand + .command("link-plan") + .description("Link permissions to a plan") + .requiredOption("--plan-id ", "Plan ID") + .requiredOption( + "--permission-id ", + "Permission ID (repeatable)", + collect, + [] + ) + .action(async (opts: { planId: string; permissionId: string[] }) => { + const spinner = yoctoSpinner({ + text: "Linking permissions to plan...", + }).start(); + try { + await graphqlRequest({ + query: `mutation($input: LinkPermissionsToPlanInput!) { + linkPermissionsToPlan(input: $input) { id name } +}`, + variables: { + input: { planId: opts.planId, permissionIds: opts.permissionId }, + }, + }); + spinner.stop(); + printSuccess( + `Linked ${opts.permissionId.length} permission(s) to plan ${opts.planId}.` + ); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +permissionsCommand + .command("unlink-plan") + .description("Unlink a permission from a plan") + .requiredOption("--plan-id ", "Plan ID") + .requiredOption("--permission-id ", "Permission ID") + .action(async (opts: { planId: string; permissionId: string }) => { + const spinner = yoctoSpinner({ + text: "Unlinking permission from plan...", + }).start(); + try { + await graphqlRequest({ + query: `mutation($input: DetachPermissionFromPlanInput!) { + detachPermissionFromPlan(input: $input) { id name } +}`, + variables: { + input: { planId: opts.planId, permissionId: opts.permissionId }, + }, + }); + spinner.stop(); + printSuccess( + `Unlinked permission ${opts.permissionId} from plan ${opts.planId}.` + ); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +permissionsCommand + .command("link-member") + .description("Link permissions to a member") + .requiredOption("--member-id ", "Member ID") + .requiredOption( + "--permission-id ", + "Permission ID (repeatable)", + collect, + [] + ) + .action(async (opts: { memberId: string; permissionId: string[] }) => { + const spinner = yoctoSpinner({ + text: "Linking permissions to member...", + }).start(); + try { + await graphqlRequest({ + query: `mutation($input: LinkPermissionsToMemberInput!) { + linkPermissionsToMember(input: $input) { id } +}`, + variables: { + input: { memberId: opts.memberId, permissionIds: opts.permissionId }, + }, + }); + spinner.stop(); + printSuccess( + `Linked ${opts.permissionId.length} permission(s) to member ${opts.memberId}.` + ); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +permissionsCommand + .command("unlink-member") + .description("Unlink a permission from a member") + .requiredOption("--member-id ", "Member ID") + .requiredOption("--permission-id ", "Permission ID") + .action(async (opts: { memberId: string; permissionId: string }) => { + const spinner = yoctoSpinner({ + text: "Unlinking permission from member...", + }).start(); + try { + await graphqlRequest({ + query: `mutation($input: DetachPermissionFromMemberInput!) { + detachPermissionFromMember(input: $input) { id } +}`, + variables: { + input: { memberId: opts.memberId, permissionId: opts.permissionId }, + }, + }); + spinner.stop(); + printSuccess( + `Unlinked permission ${opts.permissionId} from member ${opts.memberId}.` + ); + } 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 c45d176..57ee179 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { appsCommand } from "./commands/apps.js"; import { authCommand } from "./commands/auth.js"; import { customFieldsCommand } from "./commands/custom-fields.js"; 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 { recordsCommand } from "./commands/records.js"; @@ -60,6 +61,7 @@ program.addCommand(appsCommand); program.addCommand(authCommand); program.addCommand(whoamiCommand); program.addCommand(membersCommand); +program.addCommand(permissionsCommand); program.addCommand(plansCommand); program.addCommand(pricesCommand); program.addCommand(tablesCommand); diff --git a/tests/commands/permissions.test.ts b/tests/commands/permissions.test.ts new file mode 100644 index 0000000..21f0256 --- /dev/null +++ b/tests/commands/permissions.test.ts @@ -0,0 +1,190 @@ +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 { permissionsCommand } = await import( + "../../src/commands/permissions.js" +); + +const mockPermission = { + id: "perm_1", + name: "can:edit", + description: "Can edit content", +}; + +describe("permissions", () => { + it("list fetches permissions", async () => { + graphqlRequest.mockResolvedValueOnce({ + getPermissions: [mockPermission], + }); + + await runCommand(permissionsCommand, ["list"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.stringContaining("getPermissions"), + }) + ); + }); + + it("create sends name and description", async () => { + graphqlRequest.mockResolvedValueOnce({ + createPermission: mockPermission, + }); + + await runCommand(permissionsCommand, [ + "create", + "--name", + "can:edit", + "--description", + "Can edit content", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input).toEqual({ + name: "can:edit", + description: "Can edit content", + }); + }); + + it("create sends name only", async () => { + graphqlRequest.mockResolvedValueOnce({ + createPermission: mockPermission, + }); + + await runCommand(permissionsCommand, ["create", "--name", "can:view"]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input).toEqual({ name: "can:view" }); + }); + + it("update sends permissionId and fields", async () => { + graphqlRequest.mockResolvedValueOnce({ + updatePermission: mockPermission, + }); + + await runCommand(permissionsCommand, [ + "update", + "perm_1", + "--name", + "can:write", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.permissionId).toBe("perm_1"); + expect(call.variables.input.name).toBe("can:write"); + }); + + it("update rejects with no options", async () => { + const original = process.exitCode; + await runCommand(permissionsCommand, ["update", "perm_1"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("delete sends permissionId", async () => { + graphqlRequest.mockResolvedValueOnce({ + deletePermission: mockPermission, + }); + + await runCommand(permissionsCommand, ["delete", "perm_1"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { input: { permissionId: "perm_1" } }, + }) + ); + }); + + it("link-plan sends planId and permissionIds", async () => { + graphqlRequest.mockResolvedValueOnce({ + linkPermissionsToPlan: { id: "pln_1", name: "Pro" }, + }); + + await runCommand(permissionsCommand, [ + "link-plan", + "--plan-id", + "pln_1", + "--permission-id", + "perm_1", + "--permission-id", + "perm_2", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.planId).toBe("pln_1"); + expect(call.variables.input.permissionIds).toEqual(["perm_1", "perm_2"]); + }); + + it("unlink-plan sends planId and permissionId", async () => { + graphqlRequest.mockResolvedValueOnce({ + detachPermissionFromPlan: { id: "pln_1", name: "Pro" }, + }); + + await runCommand(permissionsCommand, [ + "unlink-plan", + "--plan-id", + "pln_1", + "--permission-id", + "perm_1", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.planId).toBe("pln_1"); + expect(call.variables.input.permissionId).toBe("perm_1"); + }); + + it("link-member sends memberId and permissionIds", async () => { + graphqlRequest.mockResolvedValueOnce({ + linkPermissionsToMember: { id: "mem_1" }, + }); + + await runCommand(permissionsCommand, [ + "link-member", + "--member-id", + "mem_1", + "--permission-id", + "perm_1", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.memberId).toBe("mem_1"); + expect(call.variables.input.permissionIds).toEqual(["perm_1"]); + }); + + it("unlink-member sends memberId and permissionId", async () => { + graphqlRequest.mockResolvedValueOnce({ + detachPermissionFromMember: { id: "mem_1" }, + }); + + await runCommand(permissionsCommand, [ + "unlink-member", + "--member-id", + "mem_1", + "--permission-id", + "perm_1", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.memberId).toBe("mem_1"); + expect(call.variables.input.permissionId).toBe("perm_1"); + }); + + it("handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Unauthorized")); + + const original = process.exitCode; + await runCommand(permissionsCommand, ["list"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); +});