@antasphere/chassis-cli 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +82 -0
  2. package/dist/commands/auth.d.ts +6 -0
  3. package/dist/commands/auth.d.ts.map +1 -0
  4. package/dist/commands/auth.js +582 -0
  5. package/dist/commands/auth.js.map +1 -0
  6. package/dist/commands/completion.d.ts +5 -0
  7. package/dist/commands/completion.d.ts.map +1 -0
  8. package/dist/commands/completion.js +85 -0
  9. package/dist/commands/completion.js.map +1 -0
  10. package/dist/commands/demo.d.ts +12 -0
  11. package/dist/commands/demo.d.ts.map +1 -0
  12. package/dist/commands/demo.js +210 -0
  13. package/dist/commands/demo.js.map +1 -0
  14. package/dist/commands/files.d.ts +21 -0
  15. package/dist/commands/files.d.ts.map +1 -0
  16. package/dist/commands/files.js +178 -0
  17. package/dist/commands/files.js.map +1 -0
  18. package/dist/commands/projects.d.ts +38 -0
  19. package/dist/commands/projects.d.ts.map +1 -0
  20. package/dist/commands/projects.js +386 -0
  21. package/dist/commands/projects.js.map +1 -0
  22. package/dist/commands/teams.d.ts +46 -0
  23. package/dist/commands/teams.d.ts.map +1 -0
  24. package/dist/commands/teams.js +265 -0
  25. package/dist/commands/teams.js.map +1 -0
  26. package/dist/commands/workspaces.d.ts +22 -0
  27. package/dist/commands/workspaces.d.ts.map +1 -0
  28. package/dist/commands/workspaces.js +116 -0
  29. package/dist/commands/workspaces.js.map +1 -0
  30. package/dist/config.d.ts +29 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +54 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/context.d.ts +152 -0
  35. package/dist/context.d.ts.map +1 -0
  36. package/dist/context.js +456 -0
  37. package/dist/context.js.map +1 -0
  38. package/dist/identity.d.ts +36 -0
  39. package/dist/identity.d.ts.map +1 -0
  40. package/dist/identity.js +18 -0
  41. package/dist/identity.js.map +1 -0
  42. package/dist/index.d.ts +24 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +31 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/kit.d.ts +46 -0
  47. package/dist/kit.d.ts.map +1 -0
  48. package/dist/kit.js +2 -0
  49. package/dist/kit.js.map +1 -0
  50. package/dist/open.d.ts +27 -0
  51. package/dist/open.d.ts.map +1 -0
  52. package/dist/open.js +45 -0
  53. package/dist/open.js.map +1 -0
  54. package/dist/owner-session.d.ts +34 -0
  55. package/dist/owner-session.d.ts.map +1 -0
  56. package/dist/owner-session.js +106 -0
  57. package/dist/owner-session.js.map +1 -0
  58. package/dist/program.d.ts +5 -0
  59. package/dist/program.d.ts.map +1 -0
  60. package/dist/program.js +150 -0
  61. package/dist/program.js.map +1 -0
  62. package/dist/safe-write.d.ts +46 -0
  63. package/dist/safe-write.d.ts.map +1 -0
  64. package/dist/safe-write.js +127 -0
  65. package/dist/safe-write.js.map +1 -0
  66. package/dist/stdin.d.ts +4 -0
  67. package/dist/stdin.d.ts.map +1 -0
  68. package/dist/stdin.js +36 -0
  69. package/dist/stdin.js.map +1 -0
  70. package/dist/testing/index.d.ts +70 -0
  71. package/dist/testing/index.d.ts.map +1 -0
  72. package/dist/testing/index.js +67 -0
  73. package/dist/testing/index.js.map +1 -0
  74. package/dist/workspace.d.ts +79 -0
  75. package/dist/workspace.d.ts.map +1 -0
  76. package/dist/workspace.js +139 -0
  77. package/dist/workspace.js.map +1 -0
  78. package/package.json +43 -3
  79. package/src/commands/auth.ts +687 -0
  80. package/src/commands/completion.ts +102 -0
  81. package/src/commands/demo.ts +271 -0
  82. package/src/commands/files.ts +197 -0
  83. package/src/commands/projects.ts +460 -0
  84. package/src/commands/teams.ts +320 -0
  85. package/src/commands/workspaces.ts +140 -0
  86. package/src/config.ts +81 -0
  87. package/src/context.ts +616 -0
  88. package/src/identity.ts +48 -0
  89. package/src/index.ts +70 -0
  90. package/src/kit.ts +50 -0
  91. package/src/open.ts +48 -0
  92. package/src/owner-session.ts +152 -0
  93. package/src/program.ts +183 -0
  94. package/src/safe-write.ts +136 -0
  95. package/src/stdin.ts +39 -0
  96. package/src/testing/index.ts +129 -0
  97. package/src/workspace.ts +229 -0
  98. package/test/host/identity.test.ts +229 -0
  99. package/test/host/minimal-host.ts +16 -0
  100. package/test/host/minimal-tool.ts +40 -0
  101. package/test/suite/cli.test.ts +301 -0
  102. package/test/suite/config.test.ts +59 -0
  103. package/test/suite/demo.test.ts +467 -0
  104. package/test/suite/hub-profile-shared.test.ts +123 -0
  105. package/test/suite/login.test.ts +743 -0
  106. package/test/suite/logout.test.ts +247 -0
  107. package/test/suite/migration.test.ts +109 -0
  108. package/test/suite/open.test.ts +59 -0
  109. package/test/suite/profiles.test.ts +380 -0
  110. package/test/suite/projects.test.ts +550 -0
  111. package/test/suite/recovery.test.ts +348 -0
  112. package/test/suite/security.test.ts +199 -0
  113. package/test/suite/signin-fixtures.ts +204 -0
  114. package/test/suite/teams.test.ts +354 -0
  115. package/test/suite/whoami.test.ts +159 -0
  116. package/test/suite/workspace.test.ts +402 -0
  117. package/README.md +0 -3
@@ -0,0 +1,460 @@
1
+ import type { Command } from 'commander';
2
+ import { PlatformApiError, type ChassisClient, type ListParams } from '@antasphere/chassis-sdk';
3
+ import type {
4
+ Project,
5
+ ProjectCreate,
6
+ ProjectMember,
7
+ ProjectMemberAdd,
8
+ ProjectRole,
9
+ ProjectsArchivedFilter,
10
+ ProjectUpdate
11
+ } from '@antasphere/chassis-contract';
12
+ import { CliApiRefusal, CliUsageError, drainPages, printJson, table, type CliIo } from '../context.js';
13
+ import type { CliKit } from '../kit.js';
14
+ import { resolveTeam, teamName } from './teams.js';
15
+
16
+ /**
17
+ * The project commands: a project is a subgroup of the workspace with its own
18
+ * members and three roles (viewer < editor < manager). The chassis owns the
19
+ * concept, so these commands spell no tool and no resource — what a project
20
+ * HOLDS is the tool's own group's business.
21
+ *
22
+ * Every refusal the server has a code for is turned into a sentence here
23
+ * (`explainProjectRefusal`): the wire messages are correct but terse, and the
24
+ * tiered answer (404 for a project the caller cannot read, 403 for a proven
25
+ * reader who may not act, 409 for an archived one) only reads as an
26
+ * explanation once it says WHICH of the three happened. Anything else is
27
+ * rethrown and the runner prints it unchanged.
28
+ *
29
+ * `--json` is the API payload verbatim through `printJson` (the raw sink);
30
+ * every human line goes through the sanitizing sinks the runner installed —
31
+ * a project name, a description and a member's display name are all somebody
32
+ * else's text heading for a terminal.
33
+ */
34
+
35
+ const ROLES: readonly ProjectRole[] = ['viewer', 'editor', 'manager'];
36
+
37
+ /** `--role`, validated here so a typo is a usage error, never a 400. */
38
+ function parseRole(value: string): ProjectRole {
39
+ if ((ROLES as readonly string[]).includes(value)) return value as ProjectRole;
40
+ throw new CliUsageError(`Unknown role "${value}" — one of ${ROLES.join(', ')}.`);
41
+ }
42
+
43
+ /** `--archived <false|true|all>`; `false` (the live projects) is the server's default. */
44
+ function parseArchivedFilter(value: string): ProjectsArchivedFilter {
45
+ if (value === 'false' || value === 'true' || value === 'all') return value;
46
+ throw new CliUsageError(`Unknown --archived value "${value}" — one of false, true, all.`);
47
+ }
48
+
49
+ /** `--metadata <json>`: a JSON OBJECT, the opaque seam the contract defines. */
50
+ function parseMetadata(value: string): Record<string, unknown> {
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(value);
54
+ } catch {
55
+ throw new CliUsageError('--metadata takes a JSON object, and this did not parse as JSON.');
56
+ }
57
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
58
+ throw new CliUsageError('--metadata takes a JSON object (not an array, a string, or null).');
59
+ }
60
+ return parsed as Record<string, unknown>;
61
+ }
62
+
63
+ /**
64
+ * The membership argument: an `@` makes it an email, anything else a user id.
65
+ * Better Auth ids are opaque text, so there is no id shape to test for — but
66
+ * an email always carries the `@`, and a user id never may (the server reads
67
+ * exactly one of the two fields, so guessing wrong is a `member_not_found`
68
+ * rather than a silent mismatch).
69
+ */
70
+ export function memberRef(value: string): { email: string } | { userId: string } {
71
+ return value.includes('@') ? { email: value } : { userId: value };
72
+ }
73
+
74
+ /**
75
+ * What the command was looking for, which is the ONE thing the wire cannot
76
+ * say: `member_not_found` means "nobody in this workspace matches" on `add`
77
+ * (the lookup is workspace-wide) and "not a member of this project" on
78
+ * `role` / `remove` (the lookup is inside the project). Same code, two
79
+ * different things to do about it.
80
+ */
81
+ export type ProjectLookup =
82
+ 'project' | 'workspace-member' | 'project-member' | 'workspace-team' | 'project-team';
83
+
84
+ /**
85
+ * The role an `insufficient_project_role` answer names (`This needs the
86
+ * editor role on the project`), or null when the wire says none: the one
87
+ * place the sentence is parsed, for the chassis verbs and a tool's own.
88
+ */
89
+ export function roleNamedBy(e: PlatformApiError): string | null {
90
+ return /needs the (\w+) role/.exec(e.message)?.[1] ?? null;
91
+ }
92
+
93
+ /**
94
+ * The server's project refusals as sentences. Returns the line, or null when
95
+ * the error is not one of them (the runner's generic handling is then the
96
+ * honest answer).
97
+ */
98
+ export function explainProjectRefusal(e: PlatformApiError, what: ProjectLookup): string | null {
99
+ switch (e.code) {
100
+ case 'not_found':
101
+ return 'No such project, or it is not yours to read. (A project you are not a member of answers the same way: its existence is not probeable.)';
102
+ case 'insufficient_project_role':
103
+ // The chassis routes all gate on `manager`; a tool's route may gate on
104
+ // `editor` (linking a resource). The wire sentence names the role.
105
+ return `You need the ${roleNamedBy(e) ?? 'manager'} role on this project to do that.`;
106
+ case 'guest_forbidden':
107
+ return 'You are a guest of this workspace, and guests do not take part in projects.';
108
+ case 'project_archived':
109
+ return 'This project is archived and read-only. Unarchive it first to change it.';
110
+ case 'project_not_archived':
111
+ return 'This project is not archived, so there is nothing to unarchive.';
112
+ case 'already_member':
113
+ return what === 'workspace-team'
114
+ ? 'That team is already a member of this project. Change its role with `members role --team` instead.'
115
+ : 'That person is already a member of this project. Change their role with `members role` instead.';
116
+ case 'team_not_found':
117
+ return what === 'project-team'
118
+ ? 'That team is not a member of this project.'
119
+ : 'No team of this workspace matches — check the slug or id.';
120
+ case 'member_not_found':
121
+ return what === 'workspace-member'
122
+ ? 'No active member of this workspace matches — check the user id or email. Only a member of the workspace can join a project.'
123
+ : 'That person is not a member of this project.';
124
+ case 'guest_target':
125
+ return 'That person is a guest of the workspace, and a guest cannot be a project member. Invite them as a workspace member first.';
126
+ default:
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /** Run a project call, turning the known refusals into sentences. */
132
+ async function explained<T>(what: ProjectLookup, run: () => Promise<T>): Promise<T> {
133
+ try {
134
+ return await run();
135
+ } catch (e) {
136
+ if (e instanceof PlatformApiError) {
137
+ const line = explainProjectRefusal(e, what);
138
+ if (line) throw new CliApiRefusal(line, e.status);
139
+ }
140
+ throw e;
141
+ }
142
+ }
143
+
144
+ /** One project, as the human sees it. */
145
+ function projectLines(p: Project): string {
146
+ const metadataKeys = Object.keys(p.metadata);
147
+ return (
148
+ `${p.name}${p.archivedAt ? ' [archived]' : ''}\n` +
149
+ ` id: ${p.id}\n` +
150
+ ` your role: ${p.myRole}\n` +
151
+ ` members: ${p.memberCount}\n` +
152
+ (p.description ? ` description: ${p.description}\n` : '') +
153
+ (metadataKeys.length > 0 ? ` metadata: ${metadataKeys.join(', ')}\n` : '') +
154
+ (p.archivedAt ? ` archived: ${p.archivedAt}\n` : '') +
155
+ ` created: ${p.createdAt}\n`
156
+ );
157
+ }
158
+
159
+ /** `a manager`, `an editor`: the article a role takes. */
160
+ const article = (role: string) => (/^[aeiou]/i.test(role) ? 'an' : 'a');
161
+
162
+ /** One member, a person or a team, as the human sees it. */
163
+ function memberLine(m: ProjectMember): string {
164
+ if (m.kind === 'team') {
165
+ return `team ${teamName(m)} (${m.slug}) is ${article(m.role)} ${m.role} of this project for its ${m.membersCount} member${m.membersCount === 1 ? '' : 's'} (team ${m.teamId}).\n`;
166
+ }
167
+ return `${m.name} <${m.email}> is ${article(m.role)} ${m.role} of this project (user ${m.userId}).\n`;
168
+ }
169
+
170
+ export function registerProjectCommands<TClient extends ChassisClient<string>>(
171
+ kit: CliKit<TClient>,
172
+ program: Command,
173
+ io: CliIo
174
+ ): void {
175
+ const { requireApiKey, resolveContext } = kit;
176
+
177
+ const projects = program
178
+ .command('projects')
179
+ .description('Manage the projects of the workspace (subgroups with their own members)');
180
+
181
+ // ── projects list ─────────────────────────────────────────────────────────
182
+ projects
183
+ .command('list')
184
+ .description('List the projects you belong to (newest first, cursor-paginated)')
185
+ .option('--cursor <cursor>', 'resume from a previous nextCursor')
186
+ .option('--limit <n>', 'page size (1-100)', (v: string) => parseInt(v, 10))
187
+ .option('--all', 'follow nextCursor until every page is fetched', false)
188
+ .option('--archived <false|true|all>', 'which projects to list (default: false, the live ones)')
189
+ .action(
190
+ async (opts: { cursor?: string; limit?: number; all: boolean; archived?: string }, cmd: Command) => {
191
+ const ctx = resolveContext(cmd, io);
192
+ await requireApiKey(ctx);
193
+ const params: ListParams & { archived?: ProjectsArchivedFilter } = {};
194
+ if (opts.cursor) params.cursor = opts.cursor;
195
+ if (opts.limit !== undefined) params.limit = opts.limit;
196
+ if (opts.archived !== undefined) params.archived = parseArchivedFilter(opts.archived);
197
+ const first = await explained('project', () => ctx.client.projects(params));
198
+ const rows = opts.all
199
+ ? await drainPages({ rows: first.projects, nextCursor: first.nextCursor }, async (cursor) => {
200
+ const page = await explained('project', () => ctx.client.projects({ ...params, cursor }));
201
+ return { rows: page.projects, nextCursor: page.nextCursor };
202
+ })
203
+ : first.projects;
204
+ const nextCursor = opts.all ? null : first.nextCursor;
205
+ // The wire shape, so scripts can thread nextCursor (--all drains it to null).
206
+ if (ctx.json) return printJson(io, { projects: rows, nextCursor });
207
+ if (rows.length === 0) {
208
+ io.out.write('No projects.\n');
209
+ return;
210
+ }
211
+ io.out.write(
212
+ table(
213
+ rows.map((p) => [
214
+ p.id,
215
+ p.myRole,
216
+ `${p.memberCount} member${p.memberCount === 1 ? '' : 's'}`,
217
+ `${p.name}${p.archivedAt ? ' [archived]' : ''}`
218
+ ])
219
+ )
220
+ );
221
+ if (nextCursor) {
222
+ io.out.write(`More available: rerun with --cursor ${nextCursor} or --all\n`);
223
+ }
224
+ }
225
+ );
226
+
227
+ // ── projects get ──────────────────────────────────────────────────────────
228
+ projects
229
+ .command('get <id>')
230
+ .description('Show one project, with your own role on it')
231
+ .action(async (id: string, _opts, cmd: Command) => {
232
+ const ctx = resolveContext(cmd, io);
233
+ await requireApiKey(ctx);
234
+ const project = await explained('project', () => ctx.client.project(id));
235
+ if (ctx.json) return printJson(io, project);
236
+ io.out.write(projectLines(project));
237
+ });
238
+
239
+ // ── projects create ───────────────────────────────────────────────────────
240
+ projects
241
+ .command('create <name>')
242
+ .description('Create a project; you become its first manager')
243
+ .option('--description <text>', 'what the project is for')
244
+ .option('--metadata <json>', 'a JSON object of your own, opaque to the server')
245
+ .action(async (name: string, opts: { description?: string; metadata?: string }, cmd: Command) => {
246
+ const ctx = resolveContext(cmd, io);
247
+ await requireApiKey(ctx);
248
+ const body: ProjectCreate = {
249
+ name,
250
+ ...(opts.description !== undefined ? { description: opts.description } : {}),
251
+ ...(opts.metadata !== undefined ? { metadata: parseMetadata(opts.metadata) } : {})
252
+ };
253
+ const project = await explained('project', () => ctx.client.createProject(body));
254
+ if (ctx.json) return printJson(io, project);
255
+ io.out.write(`Created project "${project.name}".\n${projectLines(project)}`);
256
+ });
257
+
258
+ // ── projects update ───────────────────────────────────────────────────────
259
+ projects
260
+ .command('update <id>')
261
+ .description("Change a project's name, description or metadata (manager)")
262
+ .option('--name <name>', 'rename the project')
263
+ .option('--description <text>', 'replace the description')
264
+ .option('--clear-description', 'remove the description', false)
265
+ .option('--metadata <json>', 'replace the whole metadata object (a JSON object)')
266
+ .action(
267
+ async (
268
+ id: string,
269
+ opts: {
270
+ name?: string;
271
+ description?: string;
272
+ clearDescription: boolean;
273
+ metadata?: string;
274
+ },
275
+ cmd: Command
276
+ ) => {
277
+ if (opts.description !== undefined && opts.clearDescription) {
278
+ throw new CliUsageError('Pass either --description <text> or --clear-description, not both.');
279
+ }
280
+ const patch: ProjectUpdate = {
281
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
282
+ ...(opts.clearDescription
283
+ ? { description: null }
284
+ : opts.description !== undefined
285
+ ? { description: opts.description }
286
+ : {}),
287
+ ...(opts.metadata !== undefined ? { metadata: parseMetadata(opts.metadata) } : {})
288
+ } as ProjectUpdate;
289
+ if (Object.keys(patch).length === 0) {
290
+ throw new CliUsageError(
291
+ 'Nothing to change — pass --name, --description, --clear-description or --metadata.'
292
+ );
293
+ }
294
+ const ctx = resolveContext(cmd, io);
295
+ await requireApiKey(ctx);
296
+ const project = await explained('project', () => ctx.client.updateProject(id, patch));
297
+ if (ctx.json) return printJson(io, project);
298
+ io.out.write(`Updated project "${project.name}".\n${projectLines(project)}`);
299
+ }
300
+ );
301
+
302
+ // ── projects archive / unarchive ──────────────────────────────────────────
303
+ projects
304
+ .command('archive <id>')
305
+ .description('Archive a project: out of the default list and read-only (manager). Never deleted')
306
+ .action(async (id: string, _opts, cmd: Command) => {
307
+ const ctx = resolveContext(cmd, io);
308
+ await requireApiKey(ctx);
309
+ const project = await explained('project', () => ctx.client.archiveProject(id));
310
+ if (ctx.json) return printJson(io, project);
311
+ io.out.write(
312
+ `Archived "${project.name}". It is read-only and out of \`projects list\` until you unarchive it.\n`
313
+ );
314
+ });
315
+
316
+ projects
317
+ .command('unarchive <id>')
318
+ .description('Bring an archived project back (manager)')
319
+ .action(async (id: string, _opts, cmd: Command) => {
320
+ const ctx = resolveContext(cmd, io);
321
+ await requireApiKey(ctx);
322
+ const project = await explained('project', () => ctx.client.unarchiveProject(id));
323
+ if (ctx.json) return printJson(io, project);
324
+ io.out.write(`Unarchived "${project.name}". It is writable again.\n`);
325
+ });
326
+
327
+ // ── projects members ──────────────────────────────────────────────────────
328
+ const members = projects.command('members').description("Manage a project's members");
329
+
330
+ members
331
+ .command('list <project>')
332
+ .description('List the members of a project')
333
+ .option('--cursor <cursor>', 'resume from a previous nextCursor')
334
+ .option('--limit <n>', 'page size (1-100)', (v: string) => parseInt(v, 10))
335
+ .option('--all', 'follow nextCursor until every page is fetched', false)
336
+ .action(
337
+ async (project: string, opts: { cursor?: string; limit?: number; all: boolean }, cmd: Command) => {
338
+ const ctx = resolveContext(cmd, io);
339
+ await requireApiKey(ctx);
340
+ const params: ListParams = {};
341
+ if (opts.cursor) params.cursor = opts.cursor;
342
+ if (opts.limit !== undefined) params.limit = opts.limit;
343
+ const first = await explained('project', () => ctx.client.projectMembers(project, params));
344
+ const rows = opts.all
345
+ ? await drainPages({ rows: first.members, nextCursor: first.nextCursor }, async (cursor) => {
346
+ const page = await explained('project', () =>
347
+ ctx.client.projectMembers(project, { ...params, cursor })
348
+ );
349
+ return { rows: page.members, nextCursor: page.nextCursor };
350
+ })
351
+ : first.members;
352
+ const nextCursor = opts.all ? null : first.nextCursor;
353
+ if (ctx.json) return printJson(io, { members: rows, nextCursor });
354
+ if (rows.length === 0) {
355
+ io.out.write('No members.\n');
356
+ return;
357
+ }
358
+ io.out.write(
359
+ table(
360
+ rows.map((m) =>
361
+ m.kind === 'team'
362
+ ? [
363
+ 'team',
364
+ m.slug,
365
+ m.role,
366
+ `${m.membersCount} member${m.membersCount === 1 ? '' : 's'}`,
367
+ teamName(m)
368
+ ]
369
+ : ['person', m.userId, m.role, m.email, m.name]
370
+ )
371
+ )
372
+ );
373
+ if (nextCursor) {
374
+ io.out.write(`More available: rerun with --cursor ${nextCursor} or --all\n`);
375
+ }
376
+ }
377
+ );
378
+
379
+ members
380
+ .command('add <project> [userIdOrEmail]')
381
+ .description('Add a member of the workspace, or one of its teams with --team, to the project (manager)')
382
+ .requiredOption('--role <viewer|editor|manager>', 'the role they get on this project')
383
+ .option('--team <team>', 'add this team (a slug or an id) instead of a person: its members hold the role')
384
+ .action(
385
+ async (
386
+ project: string,
387
+ who: string | undefined,
388
+ opts: { role: string; team?: string },
389
+ cmd: Command
390
+ ) => {
391
+ if ((who === undefined) === (opts.team === undefined)) {
392
+ throw new CliUsageError('Name exactly one: a person (<userIdOrEmail>) or a team (--team <team>).');
393
+ }
394
+ const role = parseRole(opts.role);
395
+ const ctx = resolveContext(cmd, io);
396
+ await requireApiKey(ctx);
397
+ if (opts.team !== undefined) {
398
+ const team = await resolveTeam(ctx.client, opts.team);
399
+ const body: ProjectMemberAdd = { teamId: team.id, role };
400
+ const entry = await explained('workspace-team', () => ctx.client.addProjectMember(project, body));
401
+ if (ctx.json) return printJson(io, entry);
402
+ io.out.write(`Added ${memberLine(entry)}`);
403
+ return;
404
+ }
405
+ const body: ProjectMemberAdd = { ...memberRef(who!), role };
406
+ // `add` looks the person up across the WORKSPACE, so its
407
+ // `member_not_found` is about the workspace, not about the project.
408
+ const member = await explained('workspace-member', () => ctx.client.addProjectMember(project, body));
409
+ if (ctx.json) return printJson(io, member);
410
+ io.out.write(`Added ${memberLine(member)}`);
411
+ }
412
+ );
413
+
414
+ members
415
+ .command('role <project> <userIdOrTeam> <role>')
416
+ .description("Change a member's role on the project, or a team's with --team (manager)")
417
+ .option('--team', 'the second argument names a team (a slug or an id), not a person', false)
418
+ .action(async (project: string, target: string, role: string, opts: { team: boolean }, cmd: Command) => {
419
+ const parsed = parseRole(role);
420
+ const ctx = resolveContext(cmd, io);
421
+ await requireApiKey(ctx);
422
+ if (opts.team) {
423
+ const team = await resolveTeam(ctx.client, target);
424
+ const entry = await explained('project-team', () =>
425
+ ctx.client.setProjectTeamRole(project, team.id, parsed)
426
+ );
427
+ if (ctx.json) return printJson(io, entry);
428
+ io.out.write(`Now ${memberLine(entry)}`);
429
+ return;
430
+ }
431
+ const member = await explained('project-member', () =>
432
+ ctx.client.setProjectMemberRole(project, target, parsed)
433
+ );
434
+ if (ctx.json) return printJson(io, member);
435
+ io.out.write(`Now ${memberLine(member)}`);
436
+ });
437
+
438
+ members
439
+ .command('remove <project> <userIdOrTeam>')
440
+ .description(
441
+ 'Remove a member from the project, or a team with --team (a manager removes anyone; anyone removes themselves)'
442
+ )
443
+ .option('--team', 'the second argument names a team (a slug or an id), not a person', false)
444
+ .action(async (project: string, target: string, opts: { team: boolean }, cmd: Command) => {
445
+ const ctx = resolveContext(cmd, io);
446
+ await requireApiKey(ctx);
447
+ if (opts.team) {
448
+ const team = await resolveTeam(ctx.client, target);
449
+ const entry = await explained('project-team', () => ctx.client.removeProjectTeam(project, team.id));
450
+ if (ctx.json) return printJson(io, entry);
451
+ io.out.write(
452
+ `Removed team ${teamName(entry)} (${entry.slug}) from this project. Its people keep their own entries.\n`
453
+ );
454
+ return;
455
+ }
456
+ const member = await explained('project-member', () => ctx.client.removeProjectMember(project, target));
457
+ if (ctx.json) return printJson(io, member);
458
+ io.out.write(`Removed ${member.name} <${member.email}> from this project.\n`);
459
+ });
460
+ }