@antasphere/chassis-cli 0.0.0-stage → 1.0.0

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,467 @@
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, envPrefix } = cli.identity;
7
+
8
+ /**
9
+ * The demo commands act as a SIGNED-IN OWNER (owner-session.ts): one
10
+ * password sign-in, the session cookie on every request and no
11
+ * authorization header, a sign-out whatever happened. The password and the
12
+ * cookie never reach an output, and the secret leaves only inside the links.
13
+ */
14
+
15
+ interface Canned {
16
+ status?: number;
17
+ body?: unknown;
18
+ setCookie?: string[];
19
+ }
20
+
21
+ interface Call {
22
+ method: string;
23
+ path: string;
24
+ headers: Record<string, string>;
25
+ body: unknown;
26
+ }
27
+
28
+ const PASSWORD = 'correct-horse-9f3a';
29
+ const COOKIE_VALUE = 'sessTOKEN.c0ffee';
30
+ const COOKIE = `x.session_token=${COOKIE_VALUE}`;
31
+ const SECRET = 'S3cretPassValue_abcdefghijklmnopqrstuvwxyz0';
32
+
33
+ function harness(responses: Canned[], env: Record<string, string> = {}) {
34
+ const calls: Call[] = [];
35
+ const out: string[] = [];
36
+ const err: string[] = [];
37
+ const waits: number[] = [];
38
+ const queue = [...responses];
39
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
40
+ const url = new URL(input.toString());
41
+ calls.push({
42
+ method: (init?.method ?? 'GET').toUpperCase(),
43
+ path: url.pathname + url.search,
44
+ headers: { ...(init?.headers as Record<string, string>) },
45
+ body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined
46
+ });
47
+ const r = queue.shift() ?? { status: 200, body: {} };
48
+ const headers = new Headers({ 'content-type': 'application/json' });
49
+ for (const c of r.setCookie ?? []) headers.append('set-cookie', c);
50
+ return new Response(JSON.stringify(r.body ?? {}), { status: r.status ?? 200, headers });
51
+ }) as typeof globalThis.fetch;
52
+ const io: CliIo = {
53
+ env: {
54
+ [`${envPrefix}_OWNER_EMAIL`]: 'owner@example.com',
55
+ [`${envPrefix}_OWNER_PASSWORD`]: PASSWORD,
56
+ ...env
57
+ },
58
+ out: { write: (s) => out.push(s) },
59
+ err: { write: (s) => err.push(s) },
60
+ fetch,
61
+ sleep: async (ms) => {
62
+ waits.push(ms);
63
+ }
64
+ };
65
+ return { io, calls, waits, out: () => out.join(''), err: () => err.join('') };
66
+ }
67
+
68
+ const URL_FLAGS = ['--url', 'http://demo.localhost:3000'];
69
+
70
+ const SIGNED_IN: Canned = {
71
+ body: { redirect: false, token: 'ignored', user: { id: 'o1' } },
72
+ setCookie: [
73
+ 'x.session_data=cache; Path=/; HttpOnly',
74
+ `${COOKIE}; Path=/; HttpOnly; SameSite=Lax`,
75
+ 'x.dont_remember=; Path=/'
76
+ ]
77
+ };
78
+ const SIGNED_OUT: Canned = { body: { success: true } };
79
+
80
+ const PASS = {
81
+ id: 'dp-1',
82
+ userId: 'u2',
83
+ email: 'ada@example.com',
84
+ name: 'Ada\u009b2K\u007fHIDDEN',
85
+ targetPath: '/decks',
86
+ createdBy: 'o1',
87
+ createdAt: '2026-09-27T20:00:00.000Z',
88
+ expiresAt: '2026-09-28T20:00:00.000Z',
89
+ revokedAt: null,
90
+ lastUsedAt: null,
91
+ useCount: 0
92
+ };
93
+ const MINTED: Canned = {
94
+ status: 201,
95
+ body: { pass: PASS, secret: SECRET, url: `http://demo.localhost:3000/demo#pass=${SECRET}&to=%2Fdecks` }
96
+ };
97
+
98
+ const refusal = (status: number, code: string, message: string): Canned => ({
99
+ status,
100
+ body: { error: { code, message } }
101
+ });
102
+
103
+ /** Neither secret of the owner appears in any output, whatever the outcome. */
104
+ function expectNoLeak(h: ReturnType<typeof harness>): void {
105
+ for (const text of [h.out(), h.err()]) {
106
+ expect(text).not.toContain(PASSWORD);
107
+ expect(text).not.toContain(COOKIE_VALUE);
108
+ }
109
+ }
110
+
111
+ describe(`${bin} demo`, () => {
112
+ it('link signs in with the origin and no key, mints with the cookie and no key, then signs out', async () => {
113
+ const h = harness([SIGNED_IN, MINTED, SIGNED_OUT]);
114
+ const code = await run(
115
+ ['demo', 'link', '--email', 'ada@example.com', '--path', '/decks', ...URL_FLAGS, '--api-key', 'k'],
116
+ h.io
117
+ );
118
+ expect(code).toBe(0);
119
+ expect(h.calls.map((c) => `${c.method} ${c.path}`)).toEqual([
120
+ 'POST /api/v1/auth/sign-in/email',
121
+ 'POST /api/v1/demo/passes',
122
+ 'POST /api/v1/auth/sign-out'
123
+ ]);
124
+ const [signIn, mint, signOut] = h.calls;
125
+ expect(signIn!.headers['origin']).toBe('http://demo.localhost:3000');
126
+ expect(signIn!.headers['content-type']).toBe('application/json');
127
+ expect(signIn!.headers).not.toHaveProperty('authorization');
128
+ expect(signIn!.body).toEqual({ email: 'owner@example.com', password: PASSWORD });
129
+ // Only the session cookie's name=value, never its attributes nor the other cookies.
130
+ expect(mint!.headers['cookie']).toBe(COOKIE);
131
+ expect(mint!.headers['origin']).toBe('http://demo.localhost:3000');
132
+ expect(mint!.headers).not.toHaveProperty('authorization');
133
+ expect(mint!.body).toEqual({ email: 'ada@example.com', path: '/decks' });
134
+ expect(signOut!.headers['cookie']).toBe(COOKIE);
135
+ expect(signOut!.headers['origin']).toBe('http://demo.localhost:3000');
136
+ expect(signOut!.headers).not.toHaveProperty('authorization');
137
+ expectNoLeak(h);
138
+ });
139
+
140
+ it('link: one pass, three --path, three links with the fragment and the encoded page', async () => {
141
+ const h = harness([SIGNED_IN, MINTED, SIGNED_OUT]);
142
+ const code = await run(
143
+ [
144
+ 'demo',
145
+ 'link',
146
+ '--email',
147
+ 'ada@example.com',
148
+ '--path',
149
+ '/decks',
150
+ '--path',
151
+ '/decks/d1?view=grid&x=1',
152
+ '--path',
153
+ '/settings/members',
154
+ ...URL_FLAGS
155
+ ],
156
+ h.io
157
+ );
158
+ expect(code).toBe(0);
159
+ expect(h.calls.filter((c) => c.path === '/api/v1/demo/passes')).toHaveLength(1);
160
+ const base = `http://demo.localhost:3000/demo#pass=${SECRET}&to=`;
161
+ const expected = [
162
+ `${base}%2Fdecks`,
163
+ `${base}%2Fdecks%2Fd1%3Fview%3Dgrid%26x%3D1`,
164
+ `${base}%2Fsettings%2Fmembers`
165
+ ];
166
+ const lines = h.out().trimEnd().split('\n');
167
+ expect(lines[0]).toBe(
168
+ 'Demo link for Ada2KHIDDEN <ada@example.com>, valid until 2026-09-28T20:00:00.000Z:'
169
+ );
170
+ expect(lines.slice(1)).toEqual(expected);
171
+ expectNoLeak(h);
172
+ });
173
+
174
+ it('link --json is { passes: [{ pass, links }], refused } byte-exact, the secret only inside the urls', async () => {
175
+ const h = harness([SIGNED_IN, MINTED, SIGNED_OUT]);
176
+ const code = await run(
177
+ [
178
+ 'demo',
179
+ 'link',
180
+ '--email',
181
+ 'ada@example.com',
182
+ '--path',
183
+ '/decks',
184
+ '--path',
185
+ '/',
186
+ ...URL_FLAGS,
187
+ '--json'
188
+ ],
189
+ h.io
190
+ );
191
+ expect(code).toBe(0);
192
+ const base = `http://demo.localhost:3000/demo#pass=${SECRET}&to=`;
193
+ const value = {
194
+ passes: [
195
+ {
196
+ pass: PASS,
197
+ links: [
198
+ { path: '/decks', url: `${base}%2Fdecks` },
199
+ { path: '/', url: `${base}%2F` }
200
+ ]
201
+ }
202
+ ],
203
+ refused: []
204
+ };
205
+ expect(h.out()).toBe(JSON.stringify(value, null, 2) + '\n');
206
+ // The raw name survives: --json never goes through the TTY sanitizer.
207
+ expect(h.out()).toContain('Ada\u009b2K\u007fHIDDEN');
208
+ expect(JSON.parse(h.out())).toEqual(value);
209
+ expect(h.out().split(SECRET)).toHaveLength(3);
210
+ expectNoLeak(h);
211
+ });
212
+
213
+ it('link refuses a bad --path before any request', async () => {
214
+ for (const bad of [
215
+ 'decks',
216
+ '//evil.example',
217
+ '/\\evil',
218
+ '/a#b',
219
+ '/a b',
220
+ '/a\u0007',
221
+ `/${'a'.repeat(2048)}`
222
+ ]) {
223
+ const h = harness([SIGNED_IN, MINTED, SIGNED_OUT]);
224
+ const code = await run(
225
+ ['demo', 'link', '--email', 'ada@example.com', '--path', '/ok', '--path', bad, ...URL_FLAGS],
226
+ h.io
227
+ );
228
+ expect(code).toBe(1);
229
+ expect(h.calls).toEqual([]);
230
+ expect(h.err()).toContain('is not a page of this instance');
231
+ }
232
+ });
233
+
234
+ it('link --hours 2 sends 120 minutes, --minutes 45 sends 45', async () => {
235
+ const h = harness([SIGNED_IN, MINTED, SIGNED_OUT]);
236
+ expect(
237
+ await run(['demo', 'link', '--email', 'ada@example.com', '--hours', '2', ...URL_FLAGS], h.io)
238
+ ).toBe(0);
239
+ expect(h.calls[1]!.body).toEqual({ email: 'ada@example.com', path: '/', expiresInMinutes: 120 });
240
+
241
+ const m = harness([SIGNED_IN, MINTED, SIGNED_OUT]);
242
+ expect(
243
+ await run(['demo', 'link', '--email', 'ada@example.com', '--minutes', '45', ...URL_FLAGS], m.io)
244
+ ).toBe(0);
245
+ expect(m.calls[1]!.body).toEqual({ email: 'ada@example.com', path: '/', expiresInMinutes: 45 });
246
+ });
247
+
248
+ it('link with both --hours and --minutes is a usage error, and so is a lifetime past a week', async () => {
249
+ const h = harness([SIGNED_IN, MINTED, SIGNED_OUT]);
250
+ const code = await run(
251
+ ['demo', 'link', '--email', 'ada@example.com', '--hours', '2', '--minutes', '5', ...URL_FLAGS],
252
+ h.io
253
+ );
254
+ expect(code).toBe(1);
255
+ expect(h.calls).toEqual([]);
256
+ expect(h.err()).toContain('either --hours or --minutes, not both');
257
+
258
+ const w = harness([SIGNED_IN, MINTED, SIGNED_OUT]);
259
+ expect(
260
+ await run(['demo', 'link', '--email', 'ada@example.com', '--hours', '169', ...URL_FLAGS], w.io)
261
+ ).toBe(1);
262
+ expect(w.calls).toEqual([]);
263
+ });
264
+
265
+ it('a missing password is a usage error naming both variables, and no request is made', async () => {
266
+ const h = harness([SIGNED_IN, MINTED, SIGNED_OUT], { [`${envPrefix}_OWNER_PASSWORD`]: '' });
267
+ const code = await run(['demo', 'link', '--email', 'ada@example.com', ...URL_FLAGS], h.io);
268
+ expect(code).toBe(1);
269
+ expect(h.calls).toEqual([]);
270
+ expect(h.err()).toContain(`${envPrefix}_OWNER_EMAIL`);
271
+ expect(h.err()).toContain(`${envPrefix}_OWNER_PASSWORD`);
272
+ });
273
+
274
+ it('--owner-email and --owner-password-stdin replace the variables', async () => {
275
+ const h = harness([SIGNED_IN, { body: { passes: [] } }, SIGNED_OUT], {
276
+ [`${envPrefix}_OWNER_EMAIL`]: '',
277
+ [`${envPrefix}_OWNER_PASSWORD`]: ''
278
+ });
279
+ h.io.readStdin = async () => `${PASSWORD}\n`;
280
+ const code = await run(
281
+ ['demo', 'list', '--owner-email', 'boss@example.com', '--owner-password-stdin', ...URL_FLAGS],
282
+ h.io
283
+ );
284
+ expect(code).toBe(0);
285
+ expect(h.calls[0]!.body).toEqual({ email: 'boss@example.com', password: PASSWORD });
286
+ expect(h.out()).toBe('No demo passes.\n');
287
+ expectNoLeak(h);
288
+ });
289
+
290
+ it('signs out after a refused mint too, and prints the server sentence', async () => {
291
+ const sentence = 'A demo link cannot open another owner’s account';
292
+ const h = harness([SIGNED_IN, refusal(403, 'owner_target', sentence), SIGNED_OUT]);
293
+ const code = await run(['demo', 'link', '--email', 'boss2@example.com', ...URL_FLAGS], h.io);
294
+ expect(code).toBe(1);
295
+ expect(h.calls.map((c) => c.path)).toEqual([
296
+ '/api/v1/auth/sign-in/email',
297
+ '/api/v1/demo/passes',
298
+ '/api/v1/auth/sign-out'
299
+ ]);
300
+ expect(h.calls[2]!.headers['cookie']).toBe(COOKIE);
301
+ expect(h.err()).toBe(`Error: boss2@example.com: ${sentence}\n`);
302
+ expectNoLeak(h);
303
+ });
304
+
305
+ it('each refusal code reads as the server sentence, without an API key hint', async () => {
306
+ for (const [status, code] of [
307
+ [404, 'no_such_member'],
308
+ [403, 'demo_address_required'],
309
+ [403, 'two_factor_enrolled'],
310
+ [403, 'guest_target'],
311
+ [403, 'cross_workspace_target'],
312
+ [400, 'invalid_demo_path'],
313
+ [403, 'sessions_only']
314
+ ] as const) {
315
+ const h = harness([SIGNED_IN, refusal(status, code, `the ${code} sentence`), SIGNED_OUT]);
316
+ expect(await run(['demo', 'link', '--email', 'ada@example.com', ...URL_FLAGS], h.io)).toBe(1);
317
+ expect(h.err()).toBe(`Error: ada@example.com: the ${code} sentence\n`);
318
+ expect(h.calls.at(-1)!.path).toBe('/api/v1/auth/sign-out');
319
+ }
320
+ });
321
+
322
+ it('an admin or a member who signs in reads that an owner is needed, with no API key hint', async () => {
323
+ const h = harness([SIGNED_IN, refusal(403, 'forbidden', 'Requires owner role'), SIGNED_OUT]);
324
+ expect(await run(['demo', 'list', ...URL_FLAGS], h.io)).toBe(1);
325
+ expect(h.err()).toBe(
326
+ 'Error: Only an owner of this workspace manages demo links: the account that signed in is not one.\n'
327
+ );
328
+ expect(h.calls.at(-1)!.path).toBe('/api/v1/auth/sign-out');
329
+ expectNoLeak(h);
330
+ });
331
+
332
+ it('several --email: one sign-in, one pass each, a refused member does not stop the others, exit 1', async () => {
333
+ const sentence = 'A demo link opens only a demonstration address';
334
+ const h = harness([
335
+ SIGNED_IN,
336
+ MINTED,
337
+ refusal(403, 'demo_address_required', sentence),
338
+ MINTED,
339
+ SIGNED_OUT
340
+ ]);
341
+ const code = await run(
342
+ [
343
+ 'demo',
344
+ 'link',
345
+ '--email',
346
+ 'ada@example.com',
347
+ '--email',
348
+ 'real@gmail.com',
349
+ '--email',
350
+ 'bob@example.com',
351
+ ...URL_FLAGS,
352
+ '--json'
353
+ ],
354
+ h.io
355
+ );
356
+ expect(code).toBe(1);
357
+ expect(h.calls.map((c) => c.path)).toEqual([
358
+ '/api/v1/auth/sign-in/email',
359
+ '/api/v1/demo/passes',
360
+ '/api/v1/demo/passes',
361
+ '/api/v1/demo/passes',
362
+ '/api/v1/auth/sign-out'
363
+ ]);
364
+ expect(h.calls.slice(1, 4).map((c) => (c.body as { email: string }).email)).toEqual([
365
+ 'ada@example.com',
366
+ 'real@gmail.com',
367
+ 'bob@example.com'
368
+ ]);
369
+ const printed = JSON.parse(h.out());
370
+ expect(printed.passes).toHaveLength(2);
371
+ expect(printed.refused).toEqual([
372
+ { email: 'real@gmail.com', code: 'demo_address_required', message: sentence }
373
+ ]);
374
+ expect(h.err()).toBe(`Error: real@gmail.com: ${sentence}\n`);
375
+ expectNoLeak(h);
376
+ });
377
+
378
+ it('the sign-in wall is waited out: 429, a wait, then the sign-in and the command go through', async () => {
379
+ const wall = { status: 429, body: { message: 'Too many requests. Please try again later.' } };
380
+ const h = harness([wall, wall, SIGNED_IN, { body: { passes: [] } }, SIGNED_OUT]);
381
+ expect(await run(['demo', 'list', ...URL_FLAGS], h.io)).toBe(0);
382
+ expect(h.waits).toEqual([4000, 8000]);
383
+ expect(h.calls.map((c) => c.path)).toEqual([
384
+ '/api/v1/auth/sign-in/email',
385
+ '/api/v1/auth/sign-in/email',
386
+ '/api/v1/auth/sign-in/email',
387
+ '/api/v1/demo/passes',
388
+ '/api/v1/auth/sign-out'
389
+ ]);
390
+ expect(h.err()).toBe(
391
+ 'The instance limits sign-ins: waiting 4 seconds.\nThe instance limits sign-ins: waiting 8 seconds.\n'
392
+ );
393
+ expectNoLeak(h);
394
+ });
395
+
396
+ it('the unknown-route 404 says the switch is off, on each of the three commands', async () => {
397
+ for (const argv of [
398
+ ['demo', 'link', '--email', 'ada@example.com'],
399
+ ['demo', 'list'],
400
+ ['demo', 'revoke', 'dp-1']
401
+ ]) {
402
+ const h = harness([SIGNED_IN, refusal(404, 'not_found', 'Not found'), SIGNED_OUT]);
403
+ expect(await run([...argv, ...URL_FLAGS], h.io)).toBe(1);
404
+ expect(h.err()).toBe(
405
+ 'Error: Demo sign-in is off on this instance (DEMO_SIGN_IN), or this is the cloud edition, where demo links are minted at the Antasphere hub.\n'
406
+ );
407
+ expect(h.calls.at(-1)!.path).toBe('/api/v1/auth/sign-out');
408
+ expectNoLeak(h);
409
+ }
410
+ });
411
+
412
+ it('revoke of an unknown pass keeps the server sentence', async () => {
413
+ const h = harness([SIGNED_IN, refusal(404, 'not_found', 'Demo pass not found'), SIGNED_OUT]);
414
+ expect(await run(['demo', 'revoke', 'dp-9', ...URL_FLAGS], h.io)).toBe(1);
415
+ expect(h.err()).toBe('Error: Demo pass not found\n');
416
+ });
417
+
418
+ it('an owner with a second factor is refused, and no session is left behind', async () => {
419
+ const h = harness([
420
+ { body: { twoFactorRedirect: true, twoFactorMethods: ['totp'] }, setCookie: ['x.two_factor=t; Path=/'] }
421
+ ]);
422
+ expect(await run(['demo', 'list', ...URL_FLAGS], h.io)).toBe(1);
423
+ expect(h.calls).toHaveLength(1);
424
+ expect(h.err()).toBe('Error: This owner has a second factor: mint demo links from the dashboard.\n');
425
+ expectNoLeak(h);
426
+ });
427
+
428
+ it('a refused sign-in prints the server message; the rate wall says to wait', async () => {
429
+ const bad = harness([
430
+ { status: 401, body: { code: 'INVALID_EMAIL_OR_PASSWORD', message: 'Invalid email or password' } }
431
+ ]);
432
+ expect(await run(['demo', 'list', ...URL_FLAGS], bad.io)).toBe(1);
433
+ expect(bad.err()).toBe('Error: Invalid email or password\n');
434
+ expect(bad.calls).toHaveLength(1);
435
+ expectNoLeak(bad);
436
+
437
+ const limited = { status: 429, body: {} };
438
+ const wall = harness([limited, limited, limited, limited]);
439
+ expect(await run(['demo', 'list', ...URL_FLAGS], wall.io)).toBe(1);
440
+ expect(wall.waits).toEqual([4000, 8000, 12000]);
441
+ expect(wall.calls).toHaveLength(4);
442
+ expect(wall.err()).toContain('wait a few minutes');
443
+ });
444
+
445
+ it('list prints the passes; revoke prints the pass; both sign out', async () => {
446
+ const used = { ...PASS, id: 'dp-2', lastUsedAt: '2026-09-27T21:00:00.000Z', useCount: 3 };
447
+ const h = harness([SIGNED_IN, { body: { passes: [used, PASS] } }, SIGNED_OUT]);
448
+ expect(await run(['demo', 'list', ...URL_FLAGS], h.io)).toBe(0);
449
+ const lines = h.out().trimEnd().split('\n');
450
+ expect(lines[0]).toMatch(/^ID\s+PERSON\s+PAGE\s+EXPIRES\s+REVOKED\s+LAST USED\s+USES$/);
451
+ expect(lines[1]).toContain('dp-2');
452
+ expect(lines[1]).toContain('2026-09-27T21:00:00.000Z');
453
+ expect(lines[1]).toMatch(/3$/);
454
+ expect(lines[2]).toContain('never');
455
+ expect(h.calls.map((c) => `${c.method} ${c.path}`)).toEqual([
456
+ 'POST /api/v1/auth/sign-in/email',
457
+ 'GET /api/v1/demo/passes',
458
+ 'POST /api/v1/auth/sign-out'
459
+ ]);
460
+
461
+ const r = harness([SIGNED_IN, { body: { ...PASS, revokedAt: '2026-09-27T22:00:00.000Z' } }, SIGNED_OUT]);
462
+ expect(await run(['demo', 'revoke', 'dp-1', ...URL_FLAGS, '--json'], r.io)).toBe(0);
463
+ expect(r.calls[1]).toMatchObject({ method: 'DELETE', path: '/api/v1/demo/passes/dp-1' });
464
+ expect(JSON.parse(r.out())).toMatchObject({ id: 'dp-1', revokedAt: '2026-09-27T22:00:00.000Z' });
465
+ expect(r.calls[2]!.path).toBe('/api/v1/auth/sign-out');
466
+ });
467
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ HUB_TOOL,
4
+ loadConfig as loadCoreConfig,
5
+ resolveBaseUrl,
6
+ saveConfig as saveCoreConfig,
7
+ selectProfile,
8
+ type CliEnv
9
+ } from '@antasphere/cli-core';
10
+ import { routedHarness, tempConfigEnv } from '@antasphere/chassis-cli/testing';
11
+ import { cli, run } from '@chassis-cli-test/host';
12
+ import {
13
+ cloudInstanceRoute,
14
+ exchangeRoutes,
15
+ HUB,
16
+ HUB_KEY,
17
+ key,
18
+ meRoute,
19
+ ORG,
20
+ otpRoutes
21
+ } from './signin-fixtures.js';
22
+
23
+ /**
24
+ * Two writers, one config home (verifier round 1, F4): the hub CLI
25
+ * (`antasphere login`) and the tool's inline hub sign-in must write and read
26
+ * the SAME hub profile, so either login signs the other in.
27
+ *
28
+ * `hubCliProfile` and `hubCliLogin` below mirror the hub CLI of lane B:
29
+ * `labs/products/antasphere/wt-onelogin-b-hub/packages/cli/src/context.ts`
30
+ * lines 171-188 (the profile selection and the URL resolution of
31
+ * `resolveContext`, with no --profile and no --api-url given) and
32
+ * `packages/cli/src/index.ts` lines 1171-1180 (the write of `login`: the
33
+ * spread row, then the profile made active). Keep them in step with it.
34
+ */
35
+
36
+ const { loadConfig } = cli;
37
+ const DEFAULT_HUB_URL = 'https://account.antasphere.com';
38
+
39
+ /** The hub CLI's `resolveContext` selection (context.ts 171-188). */
40
+ function hubCliProfile(env: CliEnv): { name: string; baseUrl: string; apiKey: string | undefined } {
41
+ const config = loadCoreConfig(env, HUB_TOOL);
42
+ const selected = selectProfile({
43
+ config,
44
+ requested: undefined,
45
+ apiUrl: env.ANTASPHERE_URL,
46
+ cloudUrl: DEFAULT_HUB_URL
47
+ });
48
+ const name = selected.name ?? 'cloud';
49
+ const baseUrl = resolveBaseUrl({
50
+ env,
51
+ envVar: 'ANTASPHERE_URL',
52
+ profile: selected.profile,
53
+ cloudUrl: DEFAULT_HUB_URL
54
+ });
55
+ return { name, baseUrl, apiKey: selected.profile?.apiKey };
56
+ }
57
+
58
+ /** The hub CLI's `login` write (index.ts 1171-1180), after its email code minted `apiKey`. */
59
+ function hubCliLogin(env: CliEnv, apiKey: string): void {
60
+ const ctx = hubCliProfile(env);
61
+ const config = loadCoreConfig(env, HUB_TOOL);
62
+ config.profiles[ctx.name] = {
63
+ ...config.profiles[ctx.name],
64
+ apiKey,
65
+ baseUrl: ctx.baseUrl,
66
+ email: 'ada@x.co',
67
+ workspaceId: ORG
68
+ };
69
+ config.activeProfile = ctx.name;
70
+ saveCoreConfig(env, HUB_TOOL, config);
71
+ }
72
+
73
+ const CASES = [
74
+ ['a clean machine', {}, 'cloud', DEFAULT_HUB_URL],
75
+ ['ANTASPHERE_URL set', { ANTASPHERE_URL: HUB }, 'hub', HUB]
76
+ ] as const;
77
+
78
+ describe('the hub profile is one profile for the hub CLI and the tool CLI', () => {
79
+ it.each(CASES)(
80
+ '%s: `antasphere login` first, then the tool login asks nothing and exchanges that key',
81
+ async (_label, extra, hubProfile, hubUrl) => {
82
+ const env: Record<string, string> = { ...(await tempConfigEnv()), ...extra };
83
+ hubCliLogin(env, HUB_KEY);
84
+ expect(loadCoreConfig(env, HUB_TOOL).activeProfile).toBe(hubProfile);
85
+
86
+ const minted = key('tool');
87
+ const h = routedHarness([cloudInstanceRoute, ...exchangeRoutes([minted]), meRoute()], env);
88
+ const asked: string[] = [];
89
+ h.io.prompt = async (q) => {
90
+ asked.push(q);
91
+ return '';
92
+ };
93
+ expect(await run(['login', '--api-url', 'http://tool'], h.io)).toBe(0);
94
+ expect(asked).toEqual([]);
95
+ expect(h.wire.some((c) => c.path.startsWith('/api/v1/cli/auth/'))).toBe(false);
96
+ expect(h.wire.find((c) => c.path.endsWith('/sso/tool-token'))).toMatchObject({
97
+ origin: hubUrl,
98
+ auth: `Bearer ${HUB_KEY}`
99
+ });
100
+ const connectKeys = loadConfig(env).profiles.tool?.connectKeys ?? {};
101
+ expect(Object.keys(connectKeys)).toEqual([hubProfile]);
102
+ expect(connectKeys[hubProfile]?.apiKey).toBe(minted);
103
+ }
104
+ );
105
+
106
+ it.each(CASES)(
107
+ '%s: the tool login signs in inline, and the hub CLI then finds that profile, its key and URL',
108
+ async (_label, extra, hubProfile, hubUrl) => {
109
+ const env: Record<string, string> = { ...(await tempConfigEnv()), ...extra };
110
+ const h = routedHarness(
111
+ [cloudInstanceRoute, ...otpRoutes(HUB_KEY, ORG), ...exchangeRoutes([key('tool')]), meRoute()],
112
+ env
113
+ );
114
+ h.io.prompt = async () => '123456';
115
+ expect(await run(['login', '--api-url', 'http://tool', '--email', 'ada@x.co'], h.io)).toBe(0);
116
+ expect(h.wire.find((c) => c.path === '/api/v1/cli/auth/request')?.origin).toBe(hubUrl);
117
+
118
+ expect(hubCliProfile(env)).toEqual({ name: hubProfile, baseUrl: hubUrl, apiKey: HUB_KEY });
119
+ expect(loadCoreConfig(env, HUB_TOOL).activeProfile).toBe(hubProfile);
120
+ expect(Object.keys(loadConfig(env).profiles.tool?.connectKeys ?? {})).toEqual([hubProfile]);
121
+ }
122
+ );
123
+ });