Skip to content

Commit 839c193

Browse files
authored
feat: add users command for managing app users (#9)
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. Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
1 parent d9c3526 commit 839c193

File tree

5 files changed

+416
-0
lines changed

5 files changed

+416
-0
lines changed

ARCHITECTURE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ memberstack-cli/
2323
│ │ ├── records.ts # Record CRUD, query, find, import/export, bulk ops
2424
│ │ ├── skills.ts # Agent skill add/remove (wraps npx skills)
2525
│ │ ├── tables.ts # Data table CRUD, describe
26+
│ │ ├── users.ts # App user management (list, get, add, remove, update-role)
2627
│ │ └── whoami.ts # Show current app and user
2728
│ │
2829
│ └── lib/ # Shared utilities
@@ -45,6 +46,7 @@ memberstack-cli/
4546
│ │ ├── records.test.ts
4647
│ │ ├── skills.test.ts
4748
│ │ ├── tables.test.ts
49+
│ │ ├── users.test.ts
4850
│ │ └── whoami.test.ts
4951
│ │
5052
│ └── core/ # Core library tests

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ memberstack skills add memberstack-cli
6363
| `tables` | List, create, update, delete, and describe schema |
6464
| `records` | CRUD, query, import/export, bulk ops |
6565
| `custom-fields` | List, create, update, and delete custom fields |
66+
| `users` | List, get, add, remove, and update roles for app users |
6667
| `skills` | Add/remove agent skills for Claude Code and Codex |
6768

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

src/commands/users.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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 AppUser {
12+
role: string;
13+
user: {
14+
id: string;
15+
auth: { email: string };
16+
profile: { firstName: string | null; lastName: string | null };
17+
};
18+
}
19+
20+
interface UserAppConnection {
21+
app: { id: string; name: string };
22+
role: string;
23+
}
24+
25+
const USER_FIELDS = `
26+
user {
27+
id
28+
auth { email }
29+
profile { firstName lastName }
30+
}
31+
role
32+
`;
33+
34+
const ROLES = ["ADMIN", "OWNER", "MEMBERS_WRITE", "MEMBERS_READ"];
35+
36+
export const usersCommand = new Command("users")
37+
.usage("<command> [options]")
38+
.description("Manage users");
39+
40+
usersCommand
41+
.command("list")
42+
.description("List users with access to the app")
43+
.action(async () => {
44+
const spinner = yoctoSpinner({ text: "Fetching users..." }).start();
45+
try {
46+
const allUsers: AppUser[] = [];
47+
let cursor: string | undefined;
48+
const pageSize = 200;
49+
50+
do {
51+
const result = await graphqlRequest<{
52+
getUsers: {
53+
edges: { cursor: string; node: AppUser }[];
54+
};
55+
}>({
56+
query: `query($first: Int, $after: String) {
57+
getUsers(first: $first, after: $after) {
58+
edges { cursor node { ${USER_FIELDS} } }
59+
}
60+
}`,
61+
variables: { first: pageSize, after: cursor },
62+
});
63+
64+
const { edges } = result.getUsers;
65+
allUsers.push(...edges.map((e) => e.node));
66+
67+
if (edges.length === pageSize) {
68+
cursor = edges.at(-1)?.cursor;
69+
spinner.text = `Fetching users... (${allUsers.length} so far)`;
70+
} else {
71+
cursor = undefined;
72+
}
73+
} while (cursor);
74+
75+
spinner.stop();
76+
const rows = allUsers.map((u) => ({
77+
id: u.user.id,
78+
email: u.user.auth.email,
79+
firstName: u.user.profile.firstName ?? "",
80+
lastName: u.user.profile.lastName ?? "",
81+
role: u.role,
82+
}));
83+
printTable(rows);
84+
} catch (error) {
85+
spinner.stop();
86+
printError(
87+
error instanceof Error ? error.message : "An unknown error occurred"
88+
);
89+
process.exitCode = 1;
90+
}
91+
});
92+
93+
usersCommand
94+
.command("get")
95+
.description("Get a user by ID or email")
96+
.argument("<id_or_email>", "User ID or email address")
97+
.action(async (idOrEmail: string) => {
98+
const spinner = yoctoSpinner({ text: "Fetching users..." }).start();
99+
try {
100+
const result = await graphqlRequest<{
101+
getUsers: {
102+
edges: { cursor: string; node: AppUser }[];
103+
};
104+
}>({
105+
query: `query {
106+
getUsers {
107+
edges { node { ${USER_FIELDS} } }
108+
}
109+
}`,
110+
});
111+
spinner.stop();
112+
113+
const isEmail = idOrEmail.includes("@");
114+
const match = result.getUsers.edges.find((e) =>
115+
isEmail
116+
? e.node.user.auth.email === idOrEmail
117+
: e.node.user.id === idOrEmail
118+
);
119+
120+
if (!match) {
121+
printError(`User not found: ${idOrEmail}`);
122+
process.exitCode = 1;
123+
return;
124+
}
125+
126+
printRecord({
127+
id: match.node.user.id,
128+
email: match.node.user.auth.email,
129+
firstName: match.node.user.profile.firstName ?? "",
130+
lastName: match.node.user.profile.lastName ?? "",
131+
role: match.node.role,
132+
});
133+
} catch (error) {
134+
spinner.stop();
135+
printError(
136+
error instanceof Error ? error.message : "An unknown error occurred"
137+
);
138+
process.exitCode = 1;
139+
}
140+
});
141+
142+
usersCommand
143+
.command("add")
144+
.description("Add a user to the app")
145+
.requiredOption("--email <email>", "Email address of the user to add")
146+
.addOption(new Option("--role <role>", "Role to assign").choices(ROLES))
147+
.action(async (opts: { email: string; role?: string }) => {
148+
const spinner = yoctoSpinner({ text: "Adding user..." }).start();
149+
try {
150+
const input: Record<string, unknown> = { email: opts.email };
151+
if (opts.role) {
152+
input.role = opts.role;
153+
}
154+
const result = await graphqlRequest<{
155+
addUserToApp: UserAppConnection;
156+
}>({
157+
query: `mutation($input: AddUserToAppInput!) {
158+
addUserToApp(input: $input) {
159+
app { id name }
160+
role
161+
}
162+
}`,
163+
variables: { input },
164+
});
165+
spinner.stop();
166+
printSuccess(`User "${opts.email}" added to app.`);
167+
printRecord(result.addUserToApp);
168+
} catch (error) {
169+
spinner.stop();
170+
printError(
171+
error instanceof Error ? error.message : "An unknown error occurred"
172+
);
173+
process.exitCode = 1;
174+
}
175+
});
176+
177+
usersCommand
178+
.command("remove")
179+
.description("Remove a user from the app")
180+
.argument("<userId>", "User ID to remove")
181+
.action(async (userId: string) => {
182+
const spinner = yoctoSpinner({ text: "Removing user..." }).start();
183+
try {
184+
await graphqlRequest<{ removeUserFromApp: UserAppConnection }>({
185+
query: `mutation($input: RemoveUserFromAppInput!) {
186+
removeUserFromApp(input: $input) {
187+
app { id name }
188+
role
189+
}
190+
}`,
191+
variables: { input: { userId } },
192+
});
193+
spinner.stop();
194+
printSuccess(`User "${userId}" removed from app.`);
195+
} catch (error) {
196+
spinner.stop();
197+
printError(
198+
error instanceof Error ? error.message : "An unknown error occurred"
199+
);
200+
process.exitCode = 1;
201+
}
202+
});
203+
204+
usersCommand
205+
.command("update-role")
206+
.description("Update a user's role")
207+
.argument("<userId>", "User ID to update")
208+
.addOption(
209+
new Option("--role <role>", "New role to assign")
210+
.choices(ROLES)
211+
.makeOptionMandatory()
212+
)
213+
.action(async (userId: string, opts: { role: string }) => {
214+
const spinner = yoctoSpinner({ text: "Updating user role..." }).start();
215+
try {
216+
const result = await graphqlRequest<{
217+
updateUserRole: UserAppConnection;
218+
}>({
219+
query: `mutation($input: UpdateUserRoleInput!) {
220+
updateUserRole(input: $input) {
221+
app { id name }
222+
role
223+
}
224+
}`,
225+
variables: { input: { userId, role: opts.role } },
226+
});
227+
spinner.stop();
228+
printSuccess(`User role updated to "${opts.role}".`);
229+
printRecord(result.updateUserRole);
230+
} catch (error) {
231+
spinner.stop();
232+
printError(
233+
error instanceof Error ? error.message : "An unknown error occurred"
234+
);
235+
process.exitCode = 1;
236+
}
237+
});

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { plansCommand } from "./commands/plans.js";
1111
import { recordsCommand } from "./commands/records.js";
1212
import { skillsCommand } from "./commands/skills.js";
1313
import { tablesCommand } from "./commands/tables.js";
14+
import { usersCommand } from "./commands/users.js";
1415
import { whoamiCommand } from "./commands/whoami.js";
1516
import { program } from "./lib/program.js";
1617

@@ -62,6 +63,7 @@ program.addCommand(plansCommand);
6263
program.addCommand(tablesCommand);
6364
program.addCommand(recordsCommand);
6465
program.addCommand(customFieldsCommand);
66+
program.addCommand(usersCommand);
6567
program.addCommand(skillsCommand);
6668

6769
await program.parseAsync();

0 commit comments

Comments
 (0)