@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,687 @@
1
+ import type { Command } from 'commander';
2
+ import {
3
+ activeHubProfile,
4
+ ask,
5
+ CliAuthClient,
6
+ CliAuthError,
7
+ CliUsageError as CoreUsageError,
8
+ CLOUD_PROFILE,
9
+ connectOnDemand,
10
+ HUB_TOOL,
11
+ loadConfig as loadCoreConfig,
12
+ normalizeUrl,
13
+ probeToolInstance,
14
+ resolveBaseUrl,
15
+ sameInstance,
16
+ selectProfile,
17
+ saveConfig as saveCoreConfig,
18
+ type CliProfile
19
+ } from '@antasphere/cli-core';
20
+ import type { ChassisClient } from '@antasphere/chassis-sdk';
21
+ import { redactKey } from '../config.js';
22
+ import {
23
+ CliUsageError,
24
+ printJson,
25
+ stdinApiKey,
26
+ table,
27
+ type CliContext,
28
+ type CliIo,
29
+ type CredentialSource
30
+ } from '../context.js';
31
+ import type { CliKit } from '../kit.js';
32
+ import type { MeResponse } from '../workspace.js';
33
+
34
+ /**
35
+ * Identity + profile commands (PRDCT-2947, PRDCT-2446): ONE `login` that
36
+ * ends in the tool, `logout` that revokes what it forgets, whoami/verify,
37
+ * and profile management (use / profiles / config show / config clear).
38
+ *
39
+ * A profile is an instance (cli-core 0.5.0): `login` with no URL signs in
40
+ * on the profile the run selects (the active one, else the implicit `cloud`
41
+ * profile at the tool's cloud URL); `--api-url <url>` selects or creates the
42
+ * profile of that instance, named for its host. On an Antasphere-cloud
43
+ * instance the login is the hub's email code (once, stored on the hub
44
+ * profile exactly as `antasphere login` stores it), then the exchange that
45
+ * mints the tool's own key; on a self-hosted instance it is the instance's
46
+ * email code, or a pasted key.
47
+ */
48
+
49
+ /** The hub a tool CLI signs in against inline: the same variable the hub CLI reads. */
50
+ const HUB_URL_ENV = 'ANTASPHERE_URL';
51
+ const DEFAULT_HUB_URL = 'https://account.antasphere.com';
52
+
53
+ interface AuthGlobals {
54
+ apiUrl?: string;
55
+ url?: string;
56
+ profile?: string;
57
+ json?: boolean;
58
+ }
59
+
60
+ interface LoginOpts {
61
+ email?: string;
62
+ keyName?: string;
63
+ expiresInDays?: number;
64
+ }
65
+
66
+ /** How a signed-in credential reads in a sentence. */
67
+ function describeCredential(source: CredentialSource, profileName: string | undefined): string {
68
+ switch (source) {
69
+ case 'flag':
70
+ return 'the --api-key flag';
71
+ case 'env':
72
+ return 'the API key variable';
73
+ case 'profile':
74
+ return profileName ? `the key of profile "${profileName}"` : 'the profile key';
75
+ case 'hub-cache':
76
+ return 'the Antasphere login (cached key)';
77
+ case 'hub-exchange':
78
+ return 'the Antasphere login (fresh exchange)';
79
+ case 'none':
80
+ return 'nothing';
81
+ }
82
+ }
83
+
84
+ /** `Signed in as Ada <ada@x.co> in Acme (organization …) on <url>`. */
85
+ function signedInLine(me: MeResponse, baseUrl: string): string {
86
+ const who = me.user.name ? `${me.user.name} <${me.user.email}>` : me.user.email;
87
+ const active = me.workspace ? me.workspaces.find((w) => w.id === me.workspace!.id) : undefined;
88
+ const org = active && (active as { centralAccountId?: string | null }).centralAccountId;
89
+ const where = me.workspace
90
+ ? ` in ${me.workspace.name}${org ? ` (organization ${org})` : ''}`
91
+ : ' with no workspace yet';
92
+ return `Signed in as ${who}${where} on ${baseUrl}.`;
93
+ }
94
+
95
+ /** The one line listing the other organizations, with the --org hint, or ''. */
96
+ function organizationsLine(me: MeResponse): string {
97
+ if (me.workspaces.length < 2) return '';
98
+ const names = me.workspaces.map((w) => w.name).join(', ');
99
+ return `Your workspaces: ${names}. Pass --org <name> (or --workspace <name>) to work in another.\n`;
100
+ }
101
+
102
+ export function registerAuthCommands<TClient extends ChassisClient<string>>(
103
+ kit: CliKit<TClient>,
104
+ program: Command,
105
+ io: CliIo
106
+ ): void {
107
+ const {
108
+ identity,
109
+ createClient,
110
+ clearConfig,
111
+ configPath,
112
+ connectOptions,
113
+ loadConfig,
114
+ profileUrl,
115
+ removeConnectKey,
116
+ requireApiKey,
117
+ resolveContext,
118
+ saveConfig,
119
+ workspaceSource
120
+ } = kit;
121
+ const { describeSource } = kit.workspace;
122
+
123
+ /** Save the tool's own key on a profile and make that profile the active one. */
124
+ function saveProfileKey(
125
+ profileName: string,
126
+ baseUrl: string,
127
+ apiKey: string,
128
+ email: string | undefined
129
+ ): string {
130
+ const config = loadConfig(io.env);
131
+ const previous = config.profiles[profileName];
132
+ config.profiles[profileName] = {
133
+ ...previous,
134
+ apiKey,
135
+ baseUrl: normalizeUrl(baseUrl),
136
+ ...(email ? { email } : {})
137
+ };
138
+ config.activeProfile = profileName;
139
+ return saveConfig(io.env, config);
140
+ }
141
+
142
+ /** `login` makes its profile the active one: a login is the choice of where the next command runs. */
143
+ function activate(profileName: string, baseUrl: string): string {
144
+ const config = loadConfig(io.env);
145
+ config.profiles[profileName] = { ...config.profiles[profileName], baseUrl: normalizeUrl(baseUrl) };
146
+ config.activeProfile = profileName;
147
+ return saveConfig(io.env, config);
148
+ }
149
+
150
+ /**
151
+ * The hub's email-code sign-in, inline: the wire `antasphere login` speaks
152
+ * (cli-core's instance auth client against the hub), the key written on the
153
+ * hub profile exactly as that CLI writes it, so the two logins are one.
154
+ */
155
+ async function hubLogin(opts: LoginOpts): Promise<{ email: string; profile: string }> {
156
+ // The hub profile the hub CLI would use, resolved its way (verifier
157
+ // round 1, F4): ANTASPHERE_URL selects the profile of that hub, else the
158
+ // active one, else the implicit `cloud` at account.antasphere.com; the
159
+ // URL is the variable, else that profile's own, else the default. A
160
+ // stored hub URL is never repointed at the production hub.
161
+ const hubConfig = loadCoreConfig(io.env, HUB_TOOL);
162
+ const selected = selectProfile({
163
+ config: hubConfig,
164
+ apiUrl: io.env[HUB_URL_ENV],
165
+ cloudUrl: DEFAULT_HUB_URL
166
+ });
167
+ const profile = selected.name ?? CLOUD_PROFILE;
168
+ const hubUrl = resolveBaseUrl({
169
+ env: io.env,
170
+ envVar: HUB_URL_ENV,
171
+ profile: selected.profile,
172
+ cloudUrl: DEFAULT_HUB_URL
173
+ });
174
+ const auth = new CliAuthClient({ baseUrl: hubUrl, ...(io.fetch ? { fetch: io.fetch } : {}) });
175
+ const email = opts.email ?? (await ask(io, 'Antasphere email: '));
176
+ await auth.request({ email });
177
+ io.err.write(`A sign-in code was sent to ${email} (if that Antasphere account can receive mail).\n`);
178
+ const otp = await ask(io, 'Code: ');
179
+ const completed = await auth.complete({
180
+ email,
181
+ otp,
182
+ ...(opts.keyName ? { keyName: opts.keyName } : {}),
183
+ ...(opts.expiresInDays ? { expiresInDays: opts.expiresInDays } : {})
184
+ });
185
+ // Only the key's own fields move, exactly as `antasphere login` writes
186
+ // them; whatever else the profile holds stays.
187
+ const fresh = loadCoreConfig(io.env, HUB_TOOL);
188
+ fresh.profiles[profile] = {
189
+ ...fresh.profiles[profile],
190
+ apiKey: completed.key,
191
+ baseUrl: hubUrl,
192
+ email: completed.user.email,
193
+ workspaceId: completed.workspaceId
194
+ };
195
+ fresh.activeProfile = profile;
196
+ saveCoreConfig(io.env, HUB_TOOL, fresh);
197
+ return { email: completed.user.email, profile };
198
+ }
199
+
200
+ /** The instance's own email-code sign-in (self-hosted): both legs in one command. */
201
+ async function instanceLogin(
202
+ baseUrl: string,
203
+ opts: LoginOpts
204
+ ): Promise<{ key: string; keyName: string; email: string }> {
205
+ const auth = new CliAuthClient({ baseUrl, ...(io.fetch ? { fetch: io.fetch } : {}) });
206
+ const email = opts.email ?? (await ask(io, 'Email: '));
207
+ await auth.request({ email });
208
+ io.err.write(`A sign-in code was sent to ${email} (if that account exists on ${baseUrl}).\n`);
209
+ const otp = await ask(io, 'Code: ');
210
+ const completed = await auth.complete({
211
+ email,
212
+ otp,
213
+ ...(opts.keyName ? { keyName: opts.keyName } : {}),
214
+ ...(opts.expiresInDays ? { expiresInDays: opts.expiresInDays } : {})
215
+ });
216
+ return { key: completed.key, keyName: completed.apiKey.name, email: completed.user.email };
217
+ }
218
+
219
+ program
220
+ .command('login')
221
+ .description(
222
+ `Sign in: on the cloud through your Antasphere account, on a self-hosted instance with an email code ` +
223
+ `or a pasted ${identity.keyPrefix}_ key; saves the profile and makes it the active one`
224
+ )
225
+ .option('--email <email>', 'the account email (prompted when omitted)')
226
+ .option('--key-name <name>', 'display name for the key the sign-in mints')
227
+ .option('--expires-in-days <n>', 'TTL in days of that key (default: never expires)', (v: string) =>
228
+ parseInt(v, 10)
229
+ )
230
+ .action(async (opts: LoginOpts, cmd: Command) => {
231
+ const globals = cmd.optsWithGlobals() as AuthGlobals & { apiKey?: string };
232
+ const ctx = resolveContext(cmd, io);
233
+ const profileName = ctx.profileName ?? CLOUD_PROFILE;
234
+ const { baseUrl } = ctx;
235
+ // A profile is an instance, and a login never repoints one (verifier
236
+ // round 2, F9): a saved profile named with --profile while --api-url
237
+ // (or the URL variable) names another instance is refused before any
238
+ // request, so no key of that profile ever travels to the other origin.
239
+ const saved = ctx.config.profiles[profileName];
240
+ if (saved && !sameInstance(profileUrl(saved), baseUrl)) {
241
+ throw new CliUsageError(
242
+ `Profile "${profileName}" is the instance ${profileUrl(saved)}, and this login targets ${baseUrl}. ` +
243
+ `Drop --profile to sign in on the profile of ${baseUrl}, or drop --api-url to sign in on "${profileName}".`
244
+ );
245
+ }
246
+
247
+ // A pasted key: `--api-key`, `--api-key-stdin` (already spent in
248
+ // index.ts; reuse that read), or the key variable. Verified before it
249
+ // is stored. A profile key is NOT a paste: `login` on a profile that
250
+ // already holds one re-runs the sign-in the person asked for.
251
+ // A paste is an act: `--api-key` or `--api-key-stdin`, never the key
252
+ // variable a shell or a CI job happens to carry (verifier round 1, F8).
253
+ const pasted = globals.apiKey ?? stdinApiKey(io);
254
+ if (pasted !== undefined) {
255
+ if (!pasted.startsWith(`${identity.keyPrefix}_`)) {
256
+ throw new CliUsageError(
257
+ `That is not a ${identity.displayName} key: it should start with ${identity.keyPrefix}_.`
258
+ );
259
+ }
260
+ const fetchImpl = io.fetch ?? globalThis.fetch.bind(globalThis);
261
+ const me = await createClient({ baseUrl, apiKey: pasted, fetch: fetchImpl }).me();
262
+ const path = saveProfileKey(profileName, baseUrl, pasted, me.user.email);
263
+ if (globals.json) {
264
+ return printJson(io, {
265
+ profile: profileName,
266
+ baseUrl,
267
+ via: 'key',
268
+ user: me.user,
269
+ workspace: me.workspace,
270
+ configPath: path
271
+ });
272
+ }
273
+ io.out.write(
274
+ `${signedInLine(me, baseUrl)}\nKey saved to profile "${profileName}" (${path}).\n${organizationsLine(me)}`
275
+ );
276
+ return;
277
+ }
278
+
279
+ // Cloud or self-hosted? Discovery says (the same probe as connect-on-demand).
280
+ const probe = await probeToolInstance(baseUrl, io.fetch);
281
+ if (probe?.cloud) {
282
+ // The hub login, once: stored on the hub profile as `antasphere login`
283
+ // stores it, so every other tool CLI is signed in too. `--email`
284
+ // naming ANOTHER account than the stored login signs that account in
285
+ // afresh, replacing the stored login (PRDCT-3032): the person who
286
+ // signed in with the wrong address switches from here.
287
+ let hub = activeHubProfile(io.env);
288
+ const stored = hub.profile?.apiKey ? hub.profile.email : undefined;
289
+ const switching =
290
+ Boolean(stored) && opts.email !== undefined && opts.email.toLowerCase() !== stored!.toLowerCase();
291
+ if (switching) {
292
+ io.err.write(`Replacing the Antasphere login ${stored} with ${opts.email}.\n`);
293
+ }
294
+ if (!hub.profile?.apiKey || switching) await hubLogin(opts);
295
+ // A login is an explicit re-sign-in (verifier round 1, F3): the key
296
+ // cached on this profile is revoked on its instance (best effort: a
297
+ // dead one answers 401) and forgotten, and the exchange runs afresh,
298
+ // so a stale cached key never survives a login.
299
+ hub = activeHubProfile(io.env);
300
+ const stale = hub.name
301
+ ? loadConfig(io.env).profiles[profileName]?.connectKeys?.[hub.name]
302
+ : undefined;
303
+ if (stale && hub.name) {
304
+ // Revoked on the instance the key was minted for, the profile's own
305
+ // (the same rule as logout), never on a flag URL.
306
+ const auth = new CliAuthClient({
307
+ baseUrl: profileUrl(loadConfig(io.env).profiles[profileName]),
308
+ ...(io.fetch ? { fetch: io.fetch } : {})
309
+ });
310
+ await auth.revoke(stale.apiKey).catch(() => undefined);
311
+ removeConnectKey(io.env, profileName, hub.name);
312
+ }
313
+ // The exchange, then the tool's own answer. A stored hub key the hub
314
+ // no longer accepts is replaced by a fresh sign-in, once.
315
+ let outcome;
316
+ try {
317
+ outcome = await connectOnDemand({ ...connectOptions(ctx), profileName, cached: false });
318
+ } catch (e) {
319
+ if (!(e instanceof CoreUsageError) || !/rejected/.test(e.message)) throw e;
320
+ io.err.write('The stored Antasphere login was rejected: signing in again.\n');
321
+ await hubLogin(opts);
322
+ hub = activeHubProfile(io.env);
323
+ outcome = await connectOnDemand({ ...connectOptions(ctx), profileName, cached: false });
324
+ }
325
+ if (outcome.outcome === 'not_cloud') {
326
+ throw new CliUsageError(`${baseUrl} stopped answering as an Antasphere-cloud instance; try again.`);
327
+ }
328
+ const fetchImpl = io.fetch ?? globalThis.fetch.bind(globalThis);
329
+ const me = await createClient({ baseUrl, apiKey: outcome.key, fetch: fetchImpl }).me();
330
+ const path = activate(profileName, baseUrl);
331
+ if (globals.json) {
332
+ return printJson(io, {
333
+ profile: profileName,
334
+ baseUrl,
335
+ via: 'antasphere',
336
+ hubProfile: hub.name ?? CLOUD_PROFILE,
337
+ user: me.user,
338
+ workspace: me.workspace,
339
+ workspaces: me.workspaces,
340
+ configPath: path
341
+ });
342
+ }
343
+ io.out.write(`${signedInLine(me, baseUrl)}\n${organizationsLine(me)}`);
344
+ return;
345
+ }
346
+
347
+ // Self-hosted: the instance's own email code, then its key on the profile.
348
+ const minted = await instanceLogin(baseUrl, opts);
349
+ const fetchImpl = io.fetch ?? globalThis.fetch.bind(globalThis);
350
+ const me = await createClient({ baseUrl, apiKey: minted.key, fetch: fetchImpl }).me();
351
+ const path = saveProfileKey(profileName, baseUrl, minted.key, minted.email);
352
+ if (globals.json) {
353
+ // The full key is deliberately NOT echoed — it is already stored.
354
+ return printJson(io, {
355
+ profile: profileName,
356
+ baseUrl,
357
+ via: 'otp',
358
+ user: me.user,
359
+ workspace: me.workspace,
360
+ keyName: minted.keyName,
361
+ configPath: path
362
+ });
363
+ }
364
+ io.out.write(
365
+ `${signedInLine(me, baseUrl)}\nAPI key "${minted.keyName}" (${redactKey(minted.key)}) saved to profile ` +
366
+ `"${profileName}" (${path}).\n${organizationsLine(me)}`
367
+ );
368
+ });
369
+
370
+ /** The hub login `--all` ends: `antasphere logout` from here (the key revoked on the hub, the profile cleared). */
371
+ async function hubLogout(): Promise<{ email: string | undefined; revoked: boolean } | null> {
372
+ const hub = activeHubProfile(io.env);
373
+ if (!hub.name || !hub.profile?.apiKey) return null;
374
+ const auth = new CliAuthClient({
375
+ baseUrl: profileUrl(hub.profile),
376
+ ...(io.fetch ? { fetch: io.fetch } : {})
377
+ });
378
+ let revoked = false;
379
+ try {
380
+ await auth.revoke(hub.profile.apiKey);
381
+ revoked = true;
382
+ } catch (e) {
383
+ if (!(e instanceof CliAuthError && (e.status === 401 || e.status === 403))) throw e;
384
+ io.err.write('The stored Antasphere login was already unusable; forgetting it.\n');
385
+ }
386
+ const fresh = loadCoreConfig(io.env, HUB_TOOL);
387
+ delete fresh.profiles[hub.name];
388
+ if (fresh.activeProfile === hub.name) delete fresh.activeProfile;
389
+ saveCoreConfig(io.env, HUB_TOOL, fresh);
390
+ return { email: hub.profile.email, revoked };
391
+ }
392
+
393
+ program
394
+ .command('logout')
395
+ .description(
396
+ 'Sign out of the active (or named) profile: revokes every key it holds on the instance, then forgets them; ' +
397
+ 'with --all, also ends the Antasphere login of this machine (what `antasphere logout` does)'
398
+ )
399
+ .option('--all', 'also revoke the Antasphere login and clear its profile', false)
400
+ .action(async (opts: { all: boolean }, cmd: Command) => {
401
+ const globals = cmd.optsWithGlobals() as AuthGlobals;
402
+ const ctx = resolveContext(cmd, io);
403
+ const profileName = ctx.profileName;
404
+ const profile = profileName ? ctx.config.profiles[profileName] : undefined;
405
+ const hubLogin = activeHubProfile(io.env).profile;
406
+ if (!profileName || !profile) {
407
+ // Nothing of this tool's on the machine. The Antasphere login, when
408
+ // there is one, is the family's: it is ended here on --all, else
409
+ // named with the two commands that end it (PRDCT-3032).
410
+ if (hubLogin?.apiKey) {
411
+ if (opts.all) {
412
+ const ended = await hubLogout();
413
+ if (globals.json) return printJson(io, { profile: null, revoked: 0, refused: 0, hub: ended });
414
+ io.out.write(
415
+ `Nothing of ${identity.displayName}'s to sign out of on this machine. ` +
416
+ `The Antasphere login ${ended?.email ?? ''} is ${ended?.revoked ? 'revoked and ' : ''}forgotten.\n`
417
+ );
418
+ return;
419
+ }
420
+ throw new CliUsageError(
421
+ `Nothing of ${identity.displayName}'s to sign out of on this machine. The Antasphere login ` +
422
+ `${hubLogin.email ?? ''} stays for the whole tool family: \`${identity.bin} logout --all\` or ` +
423
+ '`antasphere logout` ends it.'
424
+ );
425
+ }
426
+ throw new CliUsageError('No profile to log out of.');
427
+ }
428
+ // Every key is revoked on the instance the PROFILE names, never on a
429
+ // flag/env URL: a key must not travel to a different instance, not
430
+ // even to die (a foreign host would see the key AND the real one would
431
+ // survive while we report it gone).
432
+ const baseUrl = profileUrl(profile);
433
+ const auth = new CliAuthClient({ baseUrl, ...(io.fetch ? { fetch: io.fetch } : {}) });
434
+ let revoked = 0;
435
+ let refused = 0;
436
+ const revoke = async (key: string, what: string): Promise<void> => {
437
+ try {
438
+ await auth.revoke(key);
439
+ revoked += 1;
440
+ } catch (e) {
441
+ if (e instanceof CliAuthError && e.status === 401) {
442
+ io.err.write(`${what} was already unusable; forgetting it.\n`);
443
+ } else if (e instanceof CliAuthError && e.status === 403) {
444
+ // The key WORKS but the instance refused the self-revoke (an
445
+ // older instance whose machine allowlist predates DELETE
446
+ // /cli/auth/key). Never report that as gone.
447
+ refused += 1;
448
+ io.err.write(
449
+ `The instance refused to revoke ${what} (${e.code}): it STAYS VALID server-side; revoke it from ` +
450
+ 'the dashboard. Forgetting the local copy.\n'
451
+ );
452
+ } else {
453
+ throw e;
454
+ }
455
+ }
456
+ };
457
+ const slots = Object.entries(profile.connectKeys ?? {});
458
+ for (const [slot, entry] of slots) {
459
+ await revoke(entry.apiKey, `the key of the Antasphere login "${slot}"`);
460
+ removeConnectKey(io.env, profileName, slot);
461
+ }
462
+ if (profile.apiKey) await revoke(profile.apiKey, 'the profile key');
463
+ const config = loadConfig(io.env);
464
+ const current = config.profiles[profileName];
465
+ if (current) {
466
+ delete current.apiKey;
467
+ delete current.email;
468
+ saveConfig(io.env, config);
469
+ }
470
+ const ended = opts.all ? await hubLogout() : null;
471
+ if (globals.json) {
472
+ return printJson(io, {
473
+ profile: profileName,
474
+ baseUrl,
475
+ revoked,
476
+ refused,
477
+ hubProfiles: slots.map(([slot]) => slot),
478
+ forgotten: true,
479
+ ...(opts.all ? { hub: ended } : {})
480
+ });
481
+ }
482
+ const hubLine = opts.all
483
+ ? ended
484
+ ? ` The Antasphere login ${ended.email ?? ''} is ${ended.revoked ? 'revoked and ' : ''}forgotten.`
485
+ : ' No Antasphere login to end.'
486
+ : hubLogin?.apiKey
487
+ ? ` The Antasphere login ${hubLogin.email ?? ''} stays for the whole tool family (\`${identity.bin} logout --all\` ends it too).`
488
+ : '';
489
+ if (revoked === 0 && slots.length === 0 && !profile.apiKey) {
490
+ io.out.write(`Profile "${profileName}" held no key.${hubLine}\n`);
491
+ return;
492
+ }
493
+ io.out.write(
494
+ `Logged out of profile "${profileName}" on ${baseUrl}` +
495
+ (revoked > 0 ? ` (${revoked} key${revoked === 1 ? '' : 's'} revoked server-side)` : '') +
496
+ (refused > 0
497
+ ? ` — ${refused} key${refused === 1 ? '' : 's'} could NOT be revoked and stay valid`
498
+ : '') +
499
+ `.${hubLine}\n`
500
+ );
501
+ });
502
+
503
+ program
504
+ .command('whoami')
505
+ .description('Show the identity behind the resolved API key, and where it came from')
506
+ .action(async (_opts, cmd: Command) => {
507
+ const ctx = resolveContext(cmd, io);
508
+ await requireApiKey(ctx);
509
+ const me = await ctx.client.me();
510
+ // `me` is the answer WITH the selection applied, so its workspace is
511
+ // the one this command (and any other, same flags) really ran in.
512
+ const source = workspaceSource(ctx);
513
+ const hub = activeHubProfile(io.env);
514
+ const hubLine = hubLoginLine(ctx, hub.profile);
515
+ if (ctx.json) {
516
+ return printJson(io, {
517
+ ...me,
518
+ workspaceSource: source,
519
+ credentialSource: ctx.credentialSource,
520
+ profile: ctx.profileName ?? null,
521
+ hubLogin: hub.profile?.apiKey ? { profile: hub.name, email: hub.profile.email ?? null } : null
522
+ });
523
+ }
524
+ const active = me.workspace ? me.workspaces.find((w) => w.id === me.workspace!.id) : undefined;
525
+ const org = active && (active as { centralAccountId?: string | null }).centralAccountId;
526
+ io.out.write(
527
+ `${me.user.name} <${me.user.email}>\n` +
528
+ ` instance: ${ctx.baseUrl} (profile "${ctx.profileName ?? '-'}")\n` +
529
+ // A key is a USER credential: with zero memberships /me can carry
530
+ // no active workspace (the CLI only ever sees this for sessions,
531
+ // but the wire shape is honest about it).
532
+ ` workspace: ${me.workspace ? `${me.workspace.name} (${me.workspace.id})` : '(none)'}\n` +
533
+ (org ? ` organization: ${org}\n` : '') +
534
+ ` chosen by: ${describeSource(source, ctx.workspaceSelection?.kind)}\n` +
535
+ ` signed in with: ${describeCredential(ctx.credentialSource, ctx.profileName)}\n` +
536
+ (hubLine ? ` Antasphere login: ${hubLine}\n` : '') +
537
+ ` role: ${me.role ?? '(none)'} (via ${me.via})\n` +
538
+ ` scopes: ${me.scopes ? me.scopes.join(', ') : 'full (session)'}\n` +
539
+ (me.via === 'api_key' ? ` key expires: ${me.apiKeyExpiresAt ?? 'never'}\n` : '')
540
+ );
541
+ });
542
+
543
+ /**
544
+ * `whoami`'s line about the hub login: which one served, or why the stored
545
+ * one was NOT used (a direct key won, the profile names another instance),
546
+ * or that none exists where one would serve (the cloud profile).
547
+ */
548
+ function hubLoginLine(ctx: CliContext<TClient>, hub: CliProfile | undefined): string | null {
549
+ const stored = hub?.apiKey ? (hub.email ?? 'stored') : null;
550
+ if (ctx.credentialSource === 'hub-cache' || ctx.credentialSource === 'hub-exchange') {
551
+ return stored ?? 'used';
552
+ }
553
+ if (stored) {
554
+ const reason =
555
+ ctx.credentialSource === 'flag' ||
556
+ ctx.credentialSource === 'env' ||
557
+ ctx.credentialSource === 'profile'
558
+ ? `not used: ${describeCredential(ctx.credentialSource, ctx.profileName)} wins`
559
+ : 'not used';
560
+ return `${stored} (${reason})`;
561
+ }
562
+ const onCloud = ctx.profileName === CLOUD_PROFILE || ctx.baseUrl === normalizeUrl(identity.cloudUrl);
563
+ return onCloud ? `not connected (run \`${identity.bin} login\`)` : null;
564
+ }
565
+
566
+ program
567
+ .command('verify')
568
+ .description('Check that the resolved instance + key work (exit 0 = ok)')
569
+ .action(async (_opts, cmd: Command) => {
570
+ const ctx = resolveContext(cmd, io);
571
+ await requireApiKey(ctx);
572
+ const me = await ctx.client.me();
573
+ if (ctx.json) return printJson(io, { ok: true, baseUrl: ctx.baseUrl, user: me.user });
574
+ io.out.write(`ok — ${me.user.email} (${me.role}) @ ${ctx.baseUrl}\n`);
575
+ });
576
+
577
+ program
578
+ .command('use <profile>')
579
+ .description('Switch the active profile (the instance the commands run against)')
580
+ .action(async (name: string, _opts, cmd: Command) => {
581
+ const globals = cmd.optsWithGlobals() as AuthGlobals;
582
+ const config = loadConfig(io.env);
583
+ if (!config.profiles[name]) {
584
+ if (name !== CLOUD_PROFILE) {
585
+ throw new CliUsageError(`Unknown profile "${name}" — run \`${identity.bin} profiles\`.`);
586
+ }
587
+ // The implicit cloud profile exists before anything is written: `use
588
+ // cloud` makes it a row so it can be the active one.
589
+ config.profiles[name] = { baseUrl: normalizeUrl(identity.cloudUrl) };
590
+ }
591
+ config.activeProfile = name;
592
+ saveConfig(io.env, config);
593
+ if (globals.json) return printJson(io, { activeProfile: name });
594
+ io.out.write(`Active profile: ${name} (${profileUrl(config.profiles[name])})\n`);
595
+ });
596
+
597
+ program
598
+ .command('profiles')
599
+ .description('List the profiles (one per instance) and how each signs in')
600
+ .action(async (_opts, cmd: Command) => {
601
+ const globals = cmd.optsWithGlobals() as AuthGlobals;
602
+ const config = loadConfig(io.env);
603
+ const hub = activeHubProfile(io.env);
604
+ // The implicit cloud profile is listed even before it is on disk: it is
605
+ // where a clean machine runs, and where `login` with no URL signs in.
606
+ const rows: Array<[string, CliProfile]> = Object.entries(config.profiles);
607
+ if (!config.profiles[CLOUD_PROFILE])
608
+ rows.unshift([CLOUD_PROFILE, { baseUrl: normalizeUrl(identity.cloudUrl) }]);
609
+ const activeName = config.activeProfile ?? CLOUD_PROFILE;
610
+ const credentialOf = (p: CliProfile): { kind: 'key' | 'hub' | 'none'; text: string } => {
611
+ if (p.apiKey) return { kind: 'key', text: redactKey(p.apiKey) };
612
+ const entry = hub.name ? p.connectKeys?.[hub.name] : undefined;
613
+ if (entry) return { kind: 'hub', text: `via Antasphere${entry.email ? ` (${entry.email})` : ''}` };
614
+ const others = Object.keys(p.connectKeys ?? {});
615
+ if (others.length > 0)
616
+ return { kind: 'hub', text: `via Antasphere (login "${others.join('", "')}", not the active one)` };
617
+ return { kind: 'none', text: '(not signed in)' };
618
+ };
619
+ if (globals.json) {
620
+ return printJson(io, {
621
+ activeProfile: activeName,
622
+ hubProfile: hub.name ?? null,
623
+ profiles: Object.fromEntries(
624
+ rows.map(([n, p]) => {
625
+ const cred = credentialOf(p);
626
+ return [
627
+ n,
628
+ {
629
+ baseUrl: profileUrl(p),
630
+ saved: Boolean(config.profiles[n]),
631
+ credential: cred.kind,
632
+ apiKey: p.apiKey ? redactKey(p.apiKey) : null,
633
+ email: p.email ?? (hub.name ? (p.connectKeys?.[hub.name]?.email ?? null) : null),
634
+ hubProfiles: Object.keys(p.connectKeys ?? {})
635
+ }
636
+ ];
637
+ })
638
+ )
639
+ });
640
+ }
641
+ io.out.write(
642
+ table(rows.map(([n, p]) => [activeName === n ? '*' : ' ', n, profileUrl(p), credentialOf(p).text]))
643
+ );
644
+ });
645
+
646
+ const config = program.command('config').description('Inspect or reset the CLI config file');
647
+
648
+ config
649
+ .command('show')
650
+ .description('Show the config path, active profile, and redacted keys')
651
+ .action(async (_opts, cmd: Command) => {
652
+ const globals = cmd.optsWithGlobals() as AuthGlobals;
653
+ const cfg = loadConfig(io.env);
654
+ const path = configPath(io.env);
655
+ const redacted = {
656
+ path,
657
+ activeProfile: cfg.activeProfile ?? null,
658
+ profiles: Object.fromEntries(
659
+ Object.entries(cfg.profiles).map(([n, p]) => [
660
+ n,
661
+ {
662
+ baseUrl: profileUrl(p),
663
+ apiKey: p.apiKey ? redactKey(p.apiKey) : null,
664
+ email: p.email ?? null,
665
+ // Hub-connected keys (one per hub profile), redacted like the rest.
666
+ connectKeys: Object.fromEntries(
667
+ Object.entries(p.connectKeys ?? {}).map(([slot, entry]) => [slot, redactKey(entry.apiKey)])
668
+ )
669
+ }
670
+ ])
671
+ )
672
+ };
673
+ if (globals.json) return printJson(io, redacted);
674
+ io.out.write(`config: ${path ?? '(no HOME/XDG_CONFIG_HOME — config disabled)'}\n`);
675
+ io.out.write(`${JSON.stringify(redacted, null, 2)}\n`);
676
+ });
677
+
678
+ config
679
+ .command('clear')
680
+ .description('Delete the config file (all profiles and stored keys)')
681
+ .action(async (_opts, cmd: Command) => {
682
+ const globals = cmd.optsWithGlobals() as AuthGlobals;
683
+ clearConfig(io.env);
684
+ if (globals.json) return printJson(io, { cleared: true });
685
+ io.out.write('Config cleared.\n');
686
+ });
687
+ }