@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,550 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CliIo } from '@antasphere/chassis-cli';
3
+ import { cli, run } from '@chassis-cli-test/host';
4
+
5
+ // The literals that spell the tool come from the host (its identity).
6
+ const { bin } = cli.identity;
7
+
8
+ /**
9
+ * The project commands: the method + path each one hits (recording fake
10
+ * fetch), `--json` byte-exactness, the human rendering, the `@`-vs-user-id
11
+ * detection of `members add`, and one refusal per server error code — each
12
+ * one reading as a sentence rather than as the wire message.
13
+ */
14
+
15
+ interface Canned {
16
+ status?: number;
17
+ body?: unknown;
18
+ }
19
+
20
+ interface Call {
21
+ method: string;
22
+ path: string;
23
+ body: unknown;
24
+ }
25
+
26
+ function harness(responses: Canned[] = [{ status: 200, body: {} }]) {
27
+ const calls: Call[] = [];
28
+ const out: string[] = [];
29
+ const err: string[] = [];
30
+ const queue = [...responses];
31
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
32
+ const url = new URL(input.toString());
33
+ calls.push({
34
+ method: (init?.method ?? 'GET').toUpperCase(),
35
+ path: url.pathname + url.search,
36
+ body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined
37
+ });
38
+ const r = queue.shift() ?? { status: 200, body: {} };
39
+ return new Response(JSON.stringify(r.body ?? {}), {
40
+ status: r.status ?? 200,
41
+ headers: { 'content-type': 'application/json' }
42
+ });
43
+ }) as typeof globalThis.fetch;
44
+ const io: CliIo = {
45
+ env: {},
46
+ out: { write: (s) => out.push(s) },
47
+ err: { write: (s) => err.push(s) },
48
+ fetch
49
+ };
50
+ return { io, calls, out: () => out.join(''), err: () => err.join('') };
51
+ }
52
+
53
+ /** Every invocation carries the same instance + key; only the verb differs. */
54
+ const WIRED = ['--url', 'http://x', '--api-key', 'k'];
55
+
56
+ const PROJECT = {
57
+ id: 'p-1',
58
+ name: 'Atlas',
59
+ description: 'The launch work',
60
+ metadata: { tier: 'gold' },
61
+ archivedAt: null,
62
+ createdBy: 'u1',
63
+ createdAt: '2026-01-02T03:04:05.000Z',
64
+ updatedAt: '2026-01-02T03:04:05.000Z',
65
+ myRole: 'manager',
66
+ memberCount: 3
67
+ };
68
+ const ARCHIVED = { ...PROJECT, id: 'p-2', name: 'Borealis', archivedAt: '2026-02-01T00:00:00.000Z' };
69
+ const MEMBER = {
70
+ userId: 'u2',
71
+ email: 'ada@x.co',
72
+ name: 'Ada',
73
+ role: 'editor',
74
+ addedBy: 'u1',
75
+ createdAt: '2026-01-02T03:04:05.000Z'
76
+ };
77
+
78
+ /**
79
+ * A name the terminal must never see raw: a C1 CSI introducer and a DEL, the
80
+ * two kinds of byte `JSON.stringify` leaves as they are (it escapes C0 only,
81
+ * so an ESC in a fixture proves nothing about the sink). Every `--json` pin
82
+ * carries it: `--json` is byte-exact (PRDCT-1353), and a sink routed through
83
+ * the sanitizer by mistake goes red here instead of staying invisible.
84
+ */
85
+ const RAW_NAME = 'Atlas\u009b2K\u007fHIDDEN';
86
+ const RAW_PROJECT = { ...PROJECT, name: RAW_NAME };
87
+ const RAW_MEMBER = { ...MEMBER, name: RAW_NAME };
88
+
89
+ /** An error body in the server's shape. */
90
+ const refusal = (status: number, code: string, message = 'terse wire message'): Canned => ({
91
+ status,
92
+ body: { error: { code, message } }
93
+ });
94
+
95
+ describe(`${bin} projects`, () => {
96
+ // ── list ───────────────────────────────────────────────────────────────────
97
+
98
+ it('list → GET /api/v1/projects', async () => {
99
+ const h = harness([{ body: { projects: [PROJECT], nextCursor: null } }]);
100
+ const code = await run(['projects', 'list', ...WIRED], h.io);
101
+ expect(code).toBe(0);
102
+ expect(h.calls).toEqual([{ method: 'GET', path: '/api/v1/projects', body: undefined }]);
103
+ expect(h.out()).toContain('Atlas');
104
+ expect(h.out()).toContain('p-1');
105
+ expect(h.out()).toContain('3 members');
106
+ });
107
+
108
+ it('list says so when there is nothing', async () => {
109
+ const h = harness([{ body: { projects: [], nextCursor: null } }]);
110
+ expect(await run(['projects', 'list', ...WIRED], h.io)).toBe(0);
111
+ expect(h.out()).toBe('No projects.\n');
112
+ });
113
+
114
+ it('list threads --cursor, --limit and --archived onto the query string', async () => {
115
+ const h = harness([{ body: { projects: [], nextCursor: null } }]);
116
+ const code = await run(
117
+ ['projects', 'list', '--cursor', 'abc', '--limit', '2', '--archived', 'all', ...WIRED],
118
+ h.io
119
+ );
120
+ expect(code).toBe(0);
121
+ expect(h.calls[0]!.path).toBe('/api/v1/projects?cursor=abc&limit=2&archived=all');
122
+ });
123
+
124
+ it('list --archived true asks for the archived ones only', async () => {
125
+ const h = harness([{ body: { projects: [ARCHIVED], nextCursor: null } }]);
126
+ expect(await run(['projects', 'list', '--archived', 'true', ...WIRED], h.io)).toBe(0);
127
+ expect(h.calls[0]!.path).toBe('/api/v1/projects?archived=true');
128
+ expect(h.out()).toContain('[archived]');
129
+ });
130
+
131
+ it('list refuses an --archived value that is not false|true|all, before any request', async () => {
132
+ const h = harness();
133
+ expect(await run(['projects', 'list', '--archived', 'yes', ...WIRED], h.io)).toBe(1);
134
+ expect(h.calls).toEqual([]);
135
+ expect(h.err()).toContain('one of false, true, all');
136
+ });
137
+
138
+ it('list --all follows nextCursor and prints every page', async () => {
139
+ const h = harness([
140
+ { body: { projects: [PROJECT], nextCursor: 'p-1' } },
141
+ { body: { projects: [{ ...PROJECT, id: 'p-9', name: 'Zephyr' }], nextCursor: null } }
142
+ ]);
143
+ expect(await run(['projects', 'list', '--all', ...WIRED], h.io)).toBe(0);
144
+ expect(h.calls.map((c) => c.path)).toEqual(['/api/v1/projects', '/api/v1/projects?cursor=p-1']);
145
+ expect(h.out()).toContain('Atlas');
146
+ expect(h.out()).toContain('Zephyr');
147
+ });
148
+
149
+ it('list --json prints the wire shape { projects, nextCursor } byte-exactly', async () => {
150
+ const h = harness([{ body: { projects: [RAW_PROJECT], nextCursor: 'p-1' } }]);
151
+ expect(await run(['projects', 'list', '--json', ...WIRED], h.io)).toBe(0);
152
+ expect(h.out()).toBe(`${JSON.stringify({ projects: [RAW_PROJECT], nextCursor: 'p-1' }, null, 2)}\n`);
153
+ });
154
+
155
+ // ── get ────────────────────────────────────────────────────────────────────
156
+
157
+ it('get → GET /api/v1/projects/:id', async () => {
158
+ const h = harness([{ body: PROJECT }]);
159
+ expect(await run(['projects', 'get', 'p-1', ...WIRED], h.io)).toBe(0);
160
+ expect(h.calls).toEqual([{ method: 'GET', path: '/api/v1/projects/p-1', body: undefined }]);
161
+ expect(h.out()).toContain('your role: manager');
162
+ expect(h.out()).toContain('The launch work');
163
+ });
164
+
165
+ it('get --json prints the project payload byte-exactly', async () => {
166
+ const h = harness([{ body: RAW_PROJECT }]);
167
+ expect(await run(['projects', 'get', 'p-1', '--json', ...WIRED], h.io)).toBe(0);
168
+ expect(h.out()).toBe(`${JSON.stringify(RAW_PROJECT, null, 2)}\n`);
169
+ });
170
+
171
+ it('get escapes an id into the path', async () => {
172
+ const h = harness([{ body: PROJECT }]);
173
+ expect(await run(['projects', 'get', 'a/b c', ...WIRED], h.io)).toBe(0);
174
+ expect(h.calls[0]!.path).toBe('/api/v1/projects/a%2Fb%20c');
175
+ });
176
+
177
+ // ── create ─────────────────────────────────────────────────────────────────
178
+
179
+ it('create --json prints the project payload byte-exactly', async () => {
180
+ const h = harness([{ status: 201, body: RAW_PROJECT }]);
181
+ expect(await run(['projects', 'create', 'Atlas', '--json', ...WIRED], h.io)).toBe(0);
182
+ expect(h.out()).toBe(`${JSON.stringify(RAW_PROJECT, null, 2)}\n`);
183
+ });
184
+
185
+ it('create → POST /api/v1/projects with the name', async () => {
186
+ const h = harness([{ status: 201, body: PROJECT }]);
187
+ expect(await run(['projects', 'create', 'Atlas', ...WIRED], h.io)).toBe(0);
188
+ expect(h.calls).toEqual([{ method: 'POST', path: '/api/v1/projects', body: { name: 'Atlas' } }]);
189
+ expect(h.out()).toContain('Created project "Atlas".');
190
+ });
191
+
192
+ it('create carries --description and --metadata', async () => {
193
+ const h = harness([{ status: 201, body: PROJECT }]);
194
+ const code = await run(
195
+ [
196
+ 'projects',
197
+ 'create',
198
+ 'Atlas',
199
+ '--description',
200
+ 'The launch work',
201
+ '--metadata',
202
+ '{"tier":"gold"}',
203
+ ...WIRED
204
+ ],
205
+ h.io
206
+ );
207
+ expect(code).toBe(0);
208
+ expect(h.calls[0]!.body).toEqual({
209
+ name: 'Atlas',
210
+ description: 'The launch work',
211
+ metadata: { tier: 'gold' }
212
+ });
213
+ });
214
+
215
+ it('create refuses --metadata that is not a JSON object, before any request', async () => {
216
+ const h = harness();
217
+ expect(await run(['projects', 'create', 'Atlas', '--metadata', '[1,2]', ...WIRED], h.io)).toBe(1);
218
+ expect(h.calls).toEqual([]);
219
+ expect(h.err()).toContain('takes a JSON object');
220
+
221
+ const h2 = harness();
222
+ expect(await run(['projects', 'create', 'Atlas', '--metadata', 'nope{', ...WIRED], h2.io)).toBe(1);
223
+ expect(h2.calls).toEqual([]);
224
+ expect(h2.err()).toContain('did not parse as JSON');
225
+ });
226
+
227
+ // ── update ─────────────────────────────────────────────────────────────────
228
+
229
+ it('update → PATCH /api/v1/projects/:id with only the fields passed', async () => {
230
+ const h = harness([{ body: PROJECT }]);
231
+ expect(await run(['projects', 'update', 'p-1', '--name', 'Atlas II', ...WIRED], h.io)).toBe(0);
232
+ expect(h.calls).toEqual([{ method: 'PATCH', path: '/api/v1/projects/p-1', body: { name: 'Atlas II' } }]);
233
+ expect(h.out()).toContain('Updated project');
234
+ });
235
+
236
+ it('update --clear-description sends description: null', async () => {
237
+ const h = harness([{ body: { ...PROJECT, description: null } }]);
238
+ expect(await run(['projects', 'update', 'p-1', '--clear-description', ...WIRED], h.io)).toBe(0);
239
+ expect(h.calls[0]!.body).toEqual({ description: null });
240
+ });
241
+
242
+ it('update refuses --description together with --clear-description', async () => {
243
+ const h = harness();
244
+ const code = await run(
245
+ ['projects', 'update', 'p-1', '--description', 'x', '--clear-description', ...WIRED],
246
+ h.io
247
+ );
248
+ expect(code).toBe(1);
249
+ expect(h.calls).toEqual([]);
250
+ expect(h.err()).toContain('not both');
251
+ });
252
+
253
+ it('update with nothing to change never reaches the wire', async () => {
254
+ const h = harness();
255
+ expect(await run(['projects', 'update', 'p-1', ...WIRED], h.io)).toBe(1);
256
+ expect(h.calls).toEqual([]);
257
+ expect(h.err()).toContain('Nothing to change');
258
+ });
259
+
260
+ it('update --json prints the project payload byte-exactly', async () => {
261
+ const h = harness([{ body: RAW_PROJECT }]);
262
+ expect(await run(['projects', 'update', 'p-1', '--name', 'Atlas', '--json', ...WIRED], h.io)).toBe(0);
263
+ expect(h.out()).toBe(`${JSON.stringify(RAW_PROJECT, null, 2)}\n`);
264
+ });
265
+
266
+ // ── archive / unarchive ────────────────────────────────────────────────────
267
+
268
+ it('archive → POST /api/v1/projects/:id/archive', async () => {
269
+ const h = harness([{ body: ARCHIVED }]);
270
+ expect(await run(['projects', 'archive', 'p-2', ...WIRED], h.io)).toBe(0);
271
+ expect(h.calls).toEqual([{ method: 'POST', path: '/api/v1/projects/p-2/archive', body: undefined }]);
272
+ expect(h.out()).toContain('Archived "Borealis"');
273
+ });
274
+
275
+ it('unarchive --json prints the project payload byte-exactly', async () => {
276
+ const h = harness([{ body: RAW_PROJECT }]);
277
+ expect(await run(['projects', 'unarchive', 'p-1', '--json', ...WIRED], h.io)).toBe(0);
278
+ expect(h.out()).toBe(`${JSON.stringify(RAW_PROJECT, null, 2)}\n`);
279
+ });
280
+
281
+ it('unarchive → POST /api/v1/projects/:id/unarchive', async () => {
282
+ const h = harness([{ body: PROJECT }]);
283
+ expect(await run(['projects', 'unarchive', 'p-1', ...WIRED], h.io)).toBe(0);
284
+ expect(h.calls).toEqual([{ method: 'POST', path: '/api/v1/projects/p-1/unarchive', body: undefined }]);
285
+ expect(h.out()).toContain('writable again');
286
+ });
287
+
288
+ it('archive --json prints the project payload byte-exactly', async () => {
289
+ const archived = { ...ARCHIVED, name: RAW_NAME };
290
+ const h = harness([{ body: archived }]);
291
+ expect(await run(['projects', 'archive', 'p-2', '--json', ...WIRED], h.io)).toBe(0);
292
+ expect(h.out()).toBe(`${JSON.stringify(archived, null, 2)}\n`);
293
+ });
294
+
295
+ // ── members ────────────────────────────────────────────────────────────────
296
+
297
+ it('members list → GET /api/v1/projects/:id/members', async () => {
298
+ const h = harness([{ body: { members: [MEMBER], nextCursor: null } }]);
299
+ expect(await run(['projects', 'members', 'list', 'p-1', ...WIRED], h.io)).toBe(0);
300
+ expect(h.calls).toEqual([{ method: 'GET', path: '/api/v1/projects/p-1/members', body: undefined }]);
301
+ expect(h.out()).toContain('ada@x.co');
302
+ expect(h.out()).toContain('editor');
303
+ });
304
+
305
+ it('members list says so when there is nobody', async () => {
306
+ const h = harness([{ body: { members: [], nextCursor: null } }]);
307
+ expect(await run(['projects', 'members', 'list', 'p-1', ...WIRED], h.io)).toBe(0);
308
+ expect(h.out()).toBe('No members.\n');
309
+ });
310
+
311
+ it('members list threads --cursor / --limit and follows --all', async () => {
312
+ const h = harness([
313
+ { body: { members: [MEMBER], nextCursor: 'u2' } },
314
+ { body: { members: [{ ...MEMBER, userId: 'u3', email: 'bo@x.co' }], nextCursor: null } }
315
+ ]);
316
+ expect(await run(['projects', 'members', 'list', 'p-1', '--limit', '1', '--all', ...WIRED], h.io)).toBe(
317
+ 0
318
+ );
319
+ expect(h.calls.map((c) => c.path)).toEqual([
320
+ '/api/v1/projects/p-1/members?limit=1',
321
+ '/api/v1/projects/p-1/members?cursor=u2&limit=1'
322
+ ]);
323
+ expect(h.out()).toContain('bo@x.co');
324
+ });
325
+
326
+ it('members list --json prints { members, nextCursor } byte-exactly', async () => {
327
+ const h = harness([{ body: { members: [RAW_MEMBER], nextCursor: null } }]);
328
+ expect(await run(['projects', 'members', 'list', 'p-1', '--json', ...WIRED], h.io)).toBe(0);
329
+ expect(h.out()).toBe(`${JSON.stringify({ members: [RAW_MEMBER], nextCursor: null }, null, 2)}\n`);
330
+ });
331
+
332
+ it('members add sends { email } when the argument carries an @', async () => {
333
+ const h = harness([{ status: 201, body: MEMBER }]);
334
+ const code = await run(
335
+ ['projects', 'members', 'add', 'p-1', 'ada@x.co', '--role', 'editor', ...WIRED],
336
+ h.io
337
+ );
338
+ expect(code).toBe(0);
339
+ expect(h.calls).toEqual([
340
+ {
341
+ method: 'POST',
342
+ path: '/api/v1/projects/p-1/members',
343
+ body: { email: 'ada@x.co', role: 'editor' }
344
+ }
345
+ ]);
346
+ expect(h.out()).toContain('Added Ada <ada@x.co> is an editor');
347
+ });
348
+
349
+ it('members add sends { userId } when the argument carries no @', async () => {
350
+ const h = harness([{ status: 201, body: MEMBER }]);
351
+ const code = await run(['projects', 'members', 'add', 'p-1', 'u2', '--role', 'viewer', ...WIRED], h.io);
352
+ expect(code).toBe(0);
353
+ expect(h.calls[0]!.body).toEqual({ userId: 'u2', role: 'viewer' });
354
+ });
355
+
356
+ it('members add refuses an unknown role before any request', async () => {
357
+ const h = harness();
358
+ expect(await run(['projects', 'members', 'add', 'p-1', 'u2', '--role', 'admin', ...WIRED], h.io)).toBe(1);
359
+ expect(h.calls).toEqual([]);
360
+ expect(h.err()).toContain('Unknown role "admin"');
361
+ });
362
+
363
+ it('members add --json prints the member payload byte-exactly', async () => {
364
+ const h = harness([{ status: 201, body: RAW_MEMBER }]);
365
+ const code = await run(
366
+ ['projects', 'members', 'add', 'p-1', 'u2', '--role', 'editor', '--json', ...WIRED],
367
+ h.io
368
+ );
369
+ expect(code).toBe(0);
370
+ expect(h.out()).toBe(`${JSON.stringify(RAW_MEMBER, null, 2)}\n`);
371
+ });
372
+
373
+ it('members role --json prints the member payload byte-exactly', async () => {
374
+ const h = harness([{ body: { ...RAW_MEMBER, role: 'manager' } }]);
375
+ expect(await run(['projects', 'members', 'role', 'p-1', 'u2', 'manager', '--json', ...WIRED], h.io)).toBe(
376
+ 0
377
+ );
378
+ expect(h.out()).toBe(`${JSON.stringify({ ...RAW_MEMBER, role: 'manager' }, null, 2)}\n`);
379
+ });
380
+
381
+ it('members role → PATCH /api/v1/projects/:id/members/:userId', async () => {
382
+ const h = harness([{ body: { ...MEMBER, role: 'manager' } }]);
383
+ expect(await run(['projects', 'members', 'role', 'p-1', 'u2', 'manager', ...WIRED], h.io)).toBe(0);
384
+ expect(h.calls).toEqual([
385
+ {
386
+ method: 'PATCH',
387
+ path: '/api/v1/projects/p-1/members/u2',
388
+ body: { role: 'manager' }
389
+ }
390
+ ]);
391
+ expect(h.out()).toContain('Now Ada <ada@x.co> is a manager');
392
+ });
393
+
394
+ it('members role refuses an unknown role before any request', async () => {
395
+ const h = harness();
396
+ expect(await run(['projects', 'members', 'role', 'p-1', 'u2', 'boss', ...WIRED], h.io)).toBe(1);
397
+ expect(h.calls).toEqual([]);
398
+ expect(h.err()).toContain('Unknown role "boss"');
399
+ });
400
+
401
+ it('members remove → DELETE /api/v1/projects/:id/members/:userId', async () => {
402
+ const h = harness([{ body: MEMBER }]);
403
+ expect(await run(['projects', 'members', 'remove', 'p-1', 'u2', ...WIRED], h.io)).toBe(0);
404
+ expect(h.calls).toEqual([{ method: 'DELETE', path: '/api/v1/projects/p-1/members/u2', body: undefined }]);
405
+ expect(h.out()).toContain('Removed Ada <ada@x.co>');
406
+ });
407
+
408
+ it('members remove --json prints the member payload byte-exactly', async () => {
409
+ const h = harness([{ body: RAW_MEMBER }]);
410
+ expect(await run(['projects', 'members', 'remove', 'p-1', 'u2', '--json', ...WIRED], h.io)).toBe(0);
411
+ expect(h.out()).toBe(`${JSON.stringify(RAW_MEMBER, null, 2)}\n`);
412
+ });
413
+
414
+ // ── the refusals, one per server code, each one a sentence ─────────────────
415
+
416
+ const REFUSALS: Array<{ code: string; status: number; argv: string[]; says: string }> = [
417
+ {
418
+ code: 'not_found',
419
+ status: 404,
420
+ argv: ['projects', 'get', 'p-1'],
421
+ says: 'No such project, or it is not yours to read.'
422
+ },
423
+ {
424
+ code: 'insufficient_project_role',
425
+ status: 403,
426
+ argv: ['projects', 'update', 'p-1', '--name', 'x'],
427
+ says: 'You need the manager role on this project to do that.'
428
+ },
429
+ {
430
+ code: 'guest_forbidden',
431
+ status: 403,
432
+ argv: ['projects', 'list'],
433
+ says: 'guests do not take part in projects'
434
+ },
435
+ {
436
+ code: 'project_archived',
437
+ status: 409,
438
+ argv: ['projects', 'update', 'p-1', '--name', 'x'],
439
+ says: 'This project is archived and read-only. Unarchive it first to change it.'
440
+ },
441
+ {
442
+ code: 'project_not_archived',
443
+ status: 409,
444
+ argv: ['projects', 'unarchive', 'p-1'],
445
+ says: 'This project is not archived, so there is nothing to unarchive.'
446
+ },
447
+ {
448
+ code: 'already_member',
449
+ status: 409,
450
+ argv: ['projects', 'members', 'add', 'p-1', 'u2', '--role', 'viewer'],
451
+ says: 'already a member of this project'
452
+ },
453
+ {
454
+ code: 'member_not_found',
455
+ status: 404,
456
+ argv: ['projects', 'members', 'add', 'p-1', 'u9', '--role', 'viewer'],
457
+ says: 'No active member of this workspace matches'
458
+ },
459
+ {
460
+ code: 'guest_target',
461
+ status: 403,
462
+ argv: ['projects', 'members', 'add', 'p-1', 'g@x.co', '--role', 'viewer'],
463
+ says: 'a guest cannot be a project member'
464
+ }
465
+ ];
466
+
467
+ for (const { code, status, argv, says } of REFUSALS) {
468
+ it(`${code} (${status}) reads as a sentence, and exits 1`, async () => {
469
+ const h = harness([refusal(status, code)]);
470
+ expect(await run([...argv, ...WIRED], h.io)).toBe(1);
471
+ expect(h.err()).toContain(says);
472
+ // The terse wire message never reaches the human.
473
+ expect(h.err()).not.toContain('terse wire message');
474
+ });
475
+ }
476
+
477
+ it('insufficient_project_role names the role the wire names: a tool route may gate on editor', async () => {
478
+ const h = harness([
479
+ refusal(403, 'insufficient_project_role', 'This needs the editor role on the project')
480
+ ]);
481
+ expect(await run(['projects', 'update', 'p-1', '--name', 'x', ...WIRED], h.io)).toBe(1);
482
+ expect(h.err()).toContain('You need the editor role on this project to do that.');
483
+ });
484
+
485
+ it('member_not_found on a member route names the PROJECT, not the workspace', async () => {
486
+ const h = harness([refusal(404, 'member_not_found')]);
487
+ expect(await run(['projects', 'members', 'remove', 'p-1', 'u9', ...WIRED], h.io)).toBe(1);
488
+ expect(h.err()).toContain('That person is not a member of this project.');
489
+ });
490
+
491
+ it('an unmapped refusal keeps the server message', async () => {
492
+ const h = harness([refusal(400, 'validation_error', 'name must be at most 200 characters')]);
493
+ expect(await run(['projects', 'create', 'Atlas', ...WIRED], h.io)).toBe(1);
494
+ expect(h.err()).toContain('name must be at most 200 characters');
495
+ });
496
+
497
+ it('every project command needs an API key', async () => {
498
+ // With no key, the runner probes discovery once; a non-cloud answer keeps
499
+ // the classic error and nothing else is requested.
500
+ const h = harness([
501
+ {
502
+ body: {
503
+ name: 'Acme',
504
+ version: '1.0.0',
505
+ edition: 'oss',
506
+ setupRequired: false,
507
+ auth: { methods: ['api-key'], passwordReset: false }
508
+ }
509
+ }
510
+ ]);
511
+ expect(await run(['projects', 'list', '--url', 'http://x'], h.io)).toBe(1);
512
+ expect(h.calls.map((c) => c.path)).toEqual(['/api/v1/instance']);
513
+ expect(h.err()).toContain('API key is required');
514
+ });
515
+
516
+ it('a 404 under --workspace still says which workspace was asked, like every other command', async () => {
517
+ const h = harness([refusal(404, 'not_found')]);
518
+ const ws = 'aaaaaaaa-0000-4000-8000-000000000000';
519
+ expect(await run(['projects', 'get', 'p-1', '--workspace', ws, ...WIRED], h.io)).toBe(1);
520
+ expect(h.err()).toContain('No such project, or it is not yours to read.');
521
+ expect(h.err()).toContain(`looked in the workspace "${ws}"`);
522
+ });
523
+
524
+ // ── the human sinks are sanitized, `--json` is not ─────────────────────────
525
+
526
+ it('a project name carrying terminal control bytes is sanitized in the human output', async () => {
527
+ const nasty = { ...PROJECT, name: 'Atlas\u001b[2KHIDDEN' };
528
+ const h = harness([{ body: { projects: [nasty], nextCursor: null } }]);
529
+ expect(await run(['projects', 'list', ...WIRED], h.io)).toBe(0);
530
+ expect(h.out()).not.toContain('\u001b');
531
+ expect(h.out()).toContain('AtlasHIDDEN');
532
+ });
533
+
534
+ it('--json keeps the same bytes the server sent, escapes included', async () => {
535
+ const nasty = { ...PROJECT, name: 'Atlas\u001b[2KHIDDEN' };
536
+ const h = harness([{ body: { projects: [nasty], nextCursor: null } }]);
537
+ expect(await run(['projects', 'list', '--json', ...WIRED], h.io)).toBe(0);
538
+ expect(h.out()).toBe(`${JSON.stringify({ projects: [nasty], nextCursor: null }, null, 2)}\n`);
539
+ expect(h.out()).toContain('\\u001b');
540
+ });
541
+
542
+ // ── the command tree ───────────────────────────────────────────────────────
543
+
544
+ it('completion enumerates the new group from the live tree', async () => {
545
+ const h = harness();
546
+ expect(await run(['completion', 'zsh'], h.io)).toBe(0);
547
+ expect(h.out()).toContain('projects');
548
+ expect(h.out()).toContain('list get create update archive unarchive members');
549
+ });
550
+ });