@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.
- package/LICENSE +82 -0
- package/dist/commands/auth.d.ts +6 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +582 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/completion.d.ts +5 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +85 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/demo.d.ts +12 -0
- package/dist/commands/demo.d.ts.map +1 -0
- package/dist/commands/demo.js +210 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/files.d.ts +21 -0
- package/dist/commands/files.d.ts.map +1 -0
- package/dist/commands/files.js +178 -0
- package/dist/commands/files.js.map +1 -0
- package/dist/commands/projects.d.ts +38 -0
- package/dist/commands/projects.d.ts.map +1 -0
- package/dist/commands/projects.js +386 -0
- package/dist/commands/projects.js.map +1 -0
- package/dist/commands/teams.d.ts +46 -0
- package/dist/commands/teams.d.ts.map +1 -0
- package/dist/commands/teams.js +265 -0
- package/dist/commands/teams.js.map +1 -0
- package/dist/commands/workspaces.d.ts +22 -0
- package/dist/commands/workspaces.d.ts.map +1 -0
- package/dist/commands/workspaces.js +116 -0
- package/dist/commands/workspaces.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +54 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +152 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +456 -0
- package/dist/context.js.map +1 -0
- package/dist/identity.d.ts +36 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +18 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/kit.d.ts +46 -0
- package/dist/kit.d.ts.map +1 -0
- package/dist/kit.js +2 -0
- package/dist/kit.js.map +1 -0
- package/dist/open.d.ts +27 -0
- package/dist/open.d.ts.map +1 -0
- package/dist/open.js +45 -0
- package/dist/open.js.map +1 -0
- package/dist/owner-session.d.ts +34 -0
- package/dist/owner-session.d.ts.map +1 -0
- package/dist/owner-session.js +106 -0
- package/dist/owner-session.js.map +1 -0
- package/dist/program.d.ts +5 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +150 -0
- package/dist/program.js.map +1 -0
- package/dist/safe-write.d.ts +46 -0
- package/dist/safe-write.d.ts.map +1 -0
- package/dist/safe-write.js +127 -0
- package/dist/safe-write.js.map +1 -0
- package/dist/stdin.d.ts +4 -0
- package/dist/stdin.d.ts.map +1 -0
- package/dist/stdin.js +36 -0
- package/dist/stdin.js.map +1 -0
- package/dist/testing/index.d.ts +70 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +67 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/workspace.d.ts +79 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +139 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +43 -3
- package/src/commands/auth.ts +687 -0
- package/src/commands/completion.ts +102 -0
- package/src/commands/demo.ts +271 -0
- package/src/commands/files.ts +197 -0
- package/src/commands/projects.ts +460 -0
- package/src/commands/teams.ts +320 -0
- package/src/commands/workspaces.ts +140 -0
- package/src/config.ts +81 -0
- package/src/context.ts +616 -0
- package/src/identity.ts +48 -0
- package/src/index.ts +70 -0
- package/src/kit.ts +50 -0
- package/src/open.ts +48 -0
- package/src/owner-session.ts +152 -0
- package/src/program.ts +183 -0
- package/src/safe-write.ts +136 -0
- package/src/stdin.ts +39 -0
- package/src/testing/index.ts +129 -0
- package/src/workspace.ts +229 -0
- package/test/host/identity.test.ts +229 -0
- package/test/host/minimal-host.ts +16 -0
- package/test/host/minimal-tool.ts +40 -0
- package/test/suite/cli.test.ts +301 -0
- package/test/suite/config.test.ts +59 -0
- package/test/suite/demo.test.ts +467 -0
- package/test/suite/hub-profile-shared.test.ts +123 -0
- package/test/suite/login.test.ts +743 -0
- package/test/suite/logout.test.ts +247 -0
- package/test/suite/migration.test.ts +109 -0
- package/test/suite/open.test.ts +59 -0
- package/test/suite/profiles.test.ts +380 -0
- package/test/suite/projects.test.ts +550 -0
- package/test/suite/recovery.test.ts +348 -0
- package/test/suite/security.test.ts +199 -0
- package/test/suite/signin-fixtures.ts +204 -0
- package/test/suite/teams.test.ts +354 -0
- package/test/suite/whoami.test.ts +159 -0
- package/test/suite/workspace.test.ts +402 -0
- 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
|
+
}
|