@cantinasecurity/apex-cli 0.1.17 → 0.1.18
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/.claude/skills/apex-cli/SKILL.md +30 -11
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.claude.json +1 -1
- package/.mcp.codex.json +1 -1
- package/MARKETPLACE.md +1 -1
- package/README.md +102 -5
- package/dist/apex.js +15 -1
- package/dist/api-client.js +255 -35
- package/dist/args.js +8 -1
- package/dist/auth.js +171 -54
- package/dist/claim-proof.js +65 -0
- package/dist/commands.js +253 -19
- package/dist/config.js +357 -23
- package/dist/findings.js +50 -9
- package/dist/help.js +14 -1
- package/dist/local-source-scan.js +200 -24
- package/dist/mcp.js +128 -19
- package/dist/onboarding.js +661 -0
- package/dist/redaction.js +34 -0
- package/dist/session.js +10 -1
- package/dist/shell.js +10 -3
- package/dist/telemetry.js +83 -12
- package/dist/workspace-binding.js +16 -6
- package/package.json +3 -1
- package/skills/apex-cli/SKILL.md +43 -11
package/dist/api-client.js
CHANGED
|
@@ -1,13 +1,60 @@
|
|
|
1
|
-
import { clearCredentials, loadConfig, loadCredentials,
|
|
1
|
+
import { bindCredentialIssuerOriginIfCurrent, clearCredentials, clearCredentialsIfRefreshTokenMatches, loadConfig, loadCredentials, saveRefreshedCredentialsIfCurrent, } from "./config.js";
|
|
2
2
|
import { getTelemetryRequestHeaders } from "./telemetry.js";
|
|
3
|
+
import { redactSensitiveText, redactSensitiveValue, } from "./redaction.js";
|
|
4
|
+
export { redactSensitiveText } from "./redaction.js";
|
|
5
|
+
const CREDENTIAL_GENERATION_CHANGED_MESSAGE = "Apex credentials changed while this request was refreshing. The newer credentials were kept; retry the command.";
|
|
6
|
+
const HOSTED_APEX_ORIGIN = "https://ai.cantina.xyz";
|
|
7
|
+
const CREDENTIAL_ORIGIN_MISMATCH_MESSAGE = "Refusing to send stored Apex credentials because their issuer origin does not match the configured APEX_BASE_URL. Restore the origin used to sign in, or run `apex logout` before signing in to this origin.";
|
|
8
|
+
export class CredentialGenerationChangedError extends Error {
|
|
9
|
+
constructor() {
|
|
10
|
+
super(CREDENTIAL_GENERATION_CHANGED_MESSAGE);
|
|
11
|
+
this.name = "CredentialGenerationChangedError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class CredentialOriginMismatchError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(CREDENTIAL_ORIGIN_MISMATCH_MESSAGE);
|
|
17
|
+
this.name = "CredentialOriginMismatchError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function parseHttpOrigin(value) {
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(value);
|
|
23
|
+
return ["http:", "https:"].includes(url.protocol) ? url.origin : null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function getApiErrorCode(error) {
|
|
30
|
+
if (!(error instanceof ApiError) || !error.body || typeof error.body !== "object") {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const code = error.body.code;
|
|
34
|
+
return typeof code === "string" && code.trim().length > 0 ? code.trim() : null;
|
|
35
|
+
}
|
|
36
|
+
const TERMINAL_GUEST_SESSION_CODES = new Set([
|
|
37
|
+
"GUEST_SESSION_EXPIRED",
|
|
38
|
+
"GUEST_SESSION_CLAIMED",
|
|
39
|
+
"GUEST_SESSION_REVOKED",
|
|
40
|
+
]);
|
|
41
|
+
export function isTerminalGuestSessionError(error) {
|
|
42
|
+
const code = getApiErrorCode(error);
|
|
43
|
+
return code !== null && TERMINAL_GUEST_SESSION_CODES.has(code);
|
|
44
|
+
}
|
|
3
45
|
export class ApiError extends Error {
|
|
4
46
|
status;
|
|
5
47
|
body;
|
|
48
|
+
code;
|
|
6
49
|
constructor(message, status, body) {
|
|
7
|
-
super(message);
|
|
50
|
+
super(redactSensitiveText(message));
|
|
8
51
|
this.name = "ApiError";
|
|
9
52
|
this.status = status;
|
|
10
|
-
this.body = body;
|
|
53
|
+
this.body = redactSensitiveValue(body);
|
|
54
|
+
const code = this.body && typeof this.body === "object"
|
|
55
|
+
? this.body.code
|
|
56
|
+
: null;
|
|
57
|
+
this.code = typeof code === "string" && code.trim().length > 0 ? code.trim() : null;
|
|
11
58
|
}
|
|
12
59
|
}
|
|
13
60
|
function getApiErrorBodyMessage(body) {
|
|
@@ -17,11 +64,51 @@ function getApiErrorBodyMessage(body) {
|
|
|
17
64
|
const error = body.error;
|
|
18
65
|
return typeof error === "string" && error.trim().length > 0 ? error : null;
|
|
19
66
|
}
|
|
67
|
+
function getFriendlyApiErrorMessage(error) {
|
|
68
|
+
switch (error.code) {
|
|
69
|
+
case "ACTIVE_GUEST_SESSION":
|
|
70
|
+
return "A different guest trial is already active on this machine. Re-run `apex redeem <code> --replace` to revoke it first.";
|
|
71
|
+
case "TRIAL_ALREADY_REDEEMED":
|
|
72
|
+
return "This trial code has already funded a guest scan on this machine. Run `apex claim` to keep an unclaimed result, or use your Cantina account.";
|
|
73
|
+
case "GUEST_SESSION_EXPIRED":
|
|
74
|
+
return "Your guest trial session has expired. Run `apex claim` on this machine to keep its results.";
|
|
75
|
+
case "GUEST_SESSION_CLAIMED":
|
|
76
|
+
return "This guest trial was already claimed. Run `apex login` to use the claimed account.";
|
|
77
|
+
case "GUEST_SESSION_REVOKED":
|
|
78
|
+
return "This guest trial was revoked and can no longer be claimed. Run `apex login` to continue with your Cantina account, or redeem a different trial code.";
|
|
79
|
+
case "GUEST_TRIAL_CREDITS_EXHAUSTED":
|
|
80
|
+
return "Your guest trial credits have been used. Run `apex claim` to keep the results and continue with your account.";
|
|
81
|
+
case "GUEST_SCAN_REFUND_PENDING":
|
|
82
|
+
return "Your guest scan failed and its retry credit is still being restored. Retry shortly.";
|
|
83
|
+
case "GUEST_MODE_UNSUPPORTED":
|
|
84
|
+
return "Guest trials support standard scans only. Run `apex claim` before starting another scan mode.";
|
|
85
|
+
case "GUEST_WORKSPACE_LIMIT":
|
|
86
|
+
return "This guest trial has reached its workspace limit. Run `apex claim` to continue with your account.";
|
|
87
|
+
case "GUEST_CLAIM_REQUIRED":
|
|
88
|
+
return "This action requires an account. Run `apex claim`, then retry it.";
|
|
89
|
+
case "CLAIM_EXPIRED":
|
|
90
|
+
return "The trial claim window has expired. Run `apex login` to continue with your Cantina account.";
|
|
91
|
+
case "CLAIM_INVALID":
|
|
92
|
+
return "This trial claim proof is invalid or already consumed. Local trial state was cleared; run `apex login` to use the claimed Cantina account, or contact Cantina support if the results are missing.";
|
|
93
|
+
case "CLAIM_PERSONAL_TENANT_EXISTS":
|
|
94
|
+
return "This Cantina account already owns a personal Apex tenant, so CLI claim linking is not available yet. Contact Cantina support before the trial claim deadline.";
|
|
95
|
+
case "EMAIL_UNVERIFIED":
|
|
96
|
+
return "Verify your email, then run `apex claim` again. Your guest trial remains recoverable on this machine.";
|
|
97
|
+
case "FINGERPRINT_MISMATCH":
|
|
98
|
+
return "Run `apex claim` on the same machine that redeemed and scanned with this trial.";
|
|
99
|
+
case "TRIAL_CAPACITY":
|
|
100
|
+
return "Apex trial capacity is temporarily full. Retry later; no trial credentials were saved.";
|
|
101
|
+
default:
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
20
105
|
export function formatApiError(error) {
|
|
21
106
|
if (error instanceof ApiError) {
|
|
22
|
-
return
|
|
107
|
+
return redactSensitiveText(getFriendlyApiErrorMessage(error) ??
|
|
108
|
+
getApiErrorBodyMessage(error.body) ??
|
|
109
|
+
error.message);
|
|
23
110
|
}
|
|
24
|
-
return error instanceof Error ? error.message : String(error);
|
|
111
|
+
return redactSensitiveText(error instanceof Error ? error.message : String(error));
|
|
25
112
|
}
|
|
26
113
|
function expiresSoon(timestamp) {
|
|
27
114
|
if (!timestamp)
|
|
@@ -46,24 +133,110 @@ export class ApexApiClient {
|
|
|
46
133
|
async getCredentials() {
|
|
47
134
|
return loadCredentials();
|
|
48
135
|
}
|
|
49
|
-
async
|
|
50
|
-
|
|
51
|
-
|
|
136
|
+
async ensureCredentialIssuerOrigin(credentials, baseUrl) {
|
|
137
|
+
if (!credentials ||
|
|
138
|
+
(!credentials.accessToken.trim() && !credentials.refreshToken.trim())) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const configuredOrigin = parseHttpOrigin(baseUrl);
|
|
142
|
+
if (!configuredOrigin) {
|
|
143
|
+
throw new CredentialOriginMismatchError();
|
|
144
|
+
}
|
|
145
|
+
const storedIssuer = credentials.issuerOrigin?.trim() ?? "";
|
|
146
|
+
if (storedIssuer) {
|
|
147
|
+
if (parseHttpOrigin(storedIssuer) !== configuredOrigin) {
|
|
148
|
+
throw new CredentialOriginMismatchError();
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Credentials written before issuer binding shipped can be attributed only
|
|
153
|
+
// to the canonical hosted origin without trusting a newly supplied custom
|
|
154
|
+
// APEX_BASE_URL. Persist that one safe migration before any bearer leaves.
|
|
155
|
+
if (configuredOrigin !== HOSTED_APEX_ORIGIN) {
|
|
156
|
+
throw new CredentialOriginMismatchError();
|
|
157
|
+
}
|
|
158
|
+
const migrated = await bindCredentialIssuerOriginIfCurrent(credentials, HOSTED_APEX_ORIGIN);
|
|
159
|
+
if (!migrated.saved) {
|
|
160
|
+
throw new CredentialGenerationChangedError();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async getAccessCredentials(snapshot) {
|
|
164
|
+
const credentials = snapshot === undefined
|
|
165
|
+
? await loadCredentials()
|
|
166
|
+
: snapshot;
|
|
167
|
+
try {
|
|
168
|
+
const baseUrl = await this.getBaseUrl();
|
|
169
|
+
return await this.refreshIfNeeded(credentials, baseUrl);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// A generation change is an identity boundary, not an ordinary refresh
|
|
173
|
+
// failure that callers may silently replace with another auth flow.
|
|
174
|
+
if (error instanceof CredentialGenerationChangedError)
|
|
175
|
+
throw error;
|
|
176
|
+
if (error instanceof CredentialOriginMismatchError)
|
|
177
|
+
throw error;
|
|
178
|
+
if (isTerminalGuestSessionError(error)) {
|
|
179
|
+
await this.clearCredentialsForGeneration(credentials?.refreshToken ?? "", error);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async getAccessToken(snapshot) {
|
|
186
|
+
return (await this.getAccessCredentials(snapshot))?.accessToken ?? null;
|
|
52
187
|
}
|
|
53
188
|
async clearCredentials() {
|
|
54
|
-
await clearCredentials();
|
|
189
|
+
await clearCredentials({ preserveClaimProof: true });
|
|
55
190
|
}
|
|
56
|
-
|
|
57
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Clears only the credential generation that received an unauthorized result.
|
|
193
|
+
* Typed terminal guest errors demote or remove the guest using the same policy
|
|
194
|
+
* as ordinary authenticated requests.
|
|
195
|
+
*/
|
|
196
|
+
async clearCredentialsForGeneration(expectedRefreshToken, error) {
|
|
197
|
+
if (!expectedRefreshToken) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
const code = getApiErrorCode(error);
|
|
201
|
+
const guestSessionIsTerminal = isTerminalGuestSessionError(error);
|
|
202
|
+
const claimProofIsConsumed = code === "GUEST_SESSION_CLAIMED" ||
|
|
203
|
+
code === "GUEST_SESSION_REVOKED";
|
|
204
|
+
return clearCredentialsIfRefreshTokenMatches(expectedRefreshToken, {
|
|
205
|
+
preserveClaimProof: !claimProofIsConsumed,
|
|
206
|
+
preserveGuestSession: !guestSessionIsTerminal,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async clearCredentialsAfterUnauthorized(expectedRefreshToken, error) {
|
|
210
|
+
if (!expectedRefreshToken) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await this.clearCredentialsForGeneration(expectedRefreshToken, error);
|
|
214
|
+
}
|
|
215
|
+
async refreshIfNeeded(snapshot, requestBaseUrl) {
|
|
216
|
+
const credentials = snapshot === undefined
|
|
217
|
+
? await loadCredentials()
|
|
218
|
+
: snapshot;
|
|
58
219
|
if (!credentials)
|
|
59
220
|
return null;
|
|
221
|
+
const baseUrl = requestBaseUrl ?? await this.getBaseUrl();
|
|
222
|
+
await this.ensureCredentialIssuerOrigin(credentials, baseUrl);
|
|
60
223
|
if (!expiresSoon(credentials.accessTokenExpiresAt)) {
|
|
61
224
|
return credentials;
|
|
62
225
|
}
|
|
63
|
-
return this.refreshSession(credentials.refreshToken
|
|
226
|
+
return this.refreshSession(credentials.refreshToken, {
|
|
227
|
+
expectedAccessToken: credentials.accessToken,
|
|
228
|
+
requestBaseUrl: baseUrl,
|
|
229
|
+
});
|
|
64
230
|
}
|
|
65
|
-
async refreshSession(refreshToken) {
|
|
231
|
+
async refreshSession(refreshToken, options = {}) {
|
|
232
|
+
const baseUrl = options.requestBaseUrl ?? await this.getBaseUrl();
|
|
66
233
|
const existing = await loadCredentials();
|
|
234
|
+
await this.ensureCredentialIssuerOrigin(existing, baseUrl);
|
|
235
|
+
if ((refreshToken !== undefined && existing?.refreshToken !== refreshToken) ||
|
|
236
|
+
(options.expectedAccessToken !== undefined &&
|
|
237
|
+
existing?.accessToken !== options.expectedAccessToken)) {
|
|
238
|
+
throw new CredentialGenerationChangedError();
|
|
239
|
+
}
|
|
67
240
|
const effectiveRefreshToken = refreshToken ?? existing?.refreshToken ?? null;
|
|
68
241
|
if (!effectiveRefreshToken)
|
|
69
242
|
return null;
|
|
@@ -72,6 +245,8 @@ export class ApexApiClient {
|
|
|
72
245
|
auth: false,
|
|
73
246
|
json: { refreshToken: effectiveRefreshToken },
|
|
74
247
|
retryOnUnauthorized: false,
|
|
248
|
+
telemetryAttribution: false,
|
|
249
|
+
requestBaseUrl: baseUrl,
|
|
75
250
|
}));
|
|
76
251
|
const nextCredentials = {
|
|
77
252
|
accessToken: payload.accessToken,
|
|
@@ -79,55 +254,100 @@ export class ApexApiClient {
|
|
|
79
254
|
accessTokenExpiresAt: payload.accessTokenExpiresAt,
|
|
80
255
|
refreshTokenExpiresAt: payload.refreshTokenExpiresAt,
|
|
81
256
|
};
|
|
82
|
-
await
|
|
83
|
-
|
|
257
|
+
const persisted = await saveRefreshedCredentialsIfCurrent(effectiveRefreshToken, nextCredentials);
|
|
258
|
+
if (!persisted.saved) {
|
|
259
|
+
throw new CredentialGenerationChangedError();
|
|
260
|
+
}
|
|
261
|
+
return persisted.credentials;
|
|
84
262
|
}
|
|
85
263
|
async request(path, options = {}) {
|
|
86
|
-
const
|
|
264
|
+
const { auth, bearerToken, json, retryOnUnauthorized, telemetryAttribution, requestBaseUrl, ...requestInit } = options;
|
|
265
|
+
const baseUrl = requestBaseUrl ?? await this.getBaseUrl();
|
|
266
|
+
const requestUrl = new URL(path, baseUrl);
|
|
267
|
+
if (requestUrl.origin !== parseHttpOrigin(baseUrl)) {
|
|
268
|
+
throw new CredentialOriginMismatchError();
|
|
269
|
+
}
|
|
87
270
|
const headers = new Headers(options.headers ?? {});
|
|
88
|
-
const telemetryHeaders =
|
|
89
|
-
|
|
271
|
+
const telemetryHeaders = telemetryAttribution === false
|
|
272
|
+
? {}
|
|
273
|
+
: await getTelemetryRequestHeaders();
|
|
274
|
+
const hasExplicitBearer = bearerToken !== undefined;
|
|
275
|
+
const usesStoredCredentials = auth !== false && !hasExplicitBearer;
|
|
276
|
+
let credentials = null;
|
|
277
|
+
if (usesStoredCredentials) {
|
|
278
|
+
const snapshot = await loadCredentials();
|
|
279
|
+
try {
|
|
280
|
+
credentials = await this.refreshIfNeeded(snapshot, baseUrl);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (!(error instanceof CredentialGenerationChangedError) &&
|
|
284
|
+
!(error instanceof CredentialOriginMismatchError)) {
|
|
285
|
+
await this.clearCredentialsAfterUnauthorized(snapshot?.refreshToken, error);
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
90
290
|
for (const [key, value] of Object.entries(telemetryHeaders)) {
|
|
91
291
|
headers.set(key, value);
|
|
92
292
|
}
|
|
93
|
-
if (
|
|
293
|
+
if (json !== undefined) {
|
|
94
294
|
headers.set("Content-Type", "application/json");
|
|
95
295
|
}
|
|
96
|
-
if (
|
|
296
|
+
if (hasExplicitBearer && bearerToken) {
|
|
297
|
+
headers.set("Authorization", `Bearer ${bearerToken}`);
|
|
298
|
+
}
|
|
299
|
+
else if (credentials?.accessToken) {
|
|
97
300
|
headers.set("Authorization", `Bearer ${credentials.accessToken}`);
|
|
98
301
|
}
|
|
99
|
-
const response = await fetch(
|
|
100
|
-
...
|
|
302
|
+
const response = await fetch(requestUrl, {
|
|
303
|
+
...requestInit,
|
|
101
304
|
headers,
|
|
102
|
-
body:
|
|
305
|
+
body: json !== undefined ? JSON.stringify(json) : requestInit.body,
|
|
103
306
|
});
|
|
104
307
|
if (response.status === 401 &&
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
308
|
+
usesStoredCredentials &&
|
|
309
|
+
retryOnUnauthorized !== false) {
|
|
310
|
+
const attemptedRefreshToken = credentials?.refreshToken ?? null;
|
|
311
|
+
if (attemptedRefreshToken) {
|
|
312
|
+
try {
|
|
313
|
+
credentials = await this.refreshSession(attemptedRefreshToken, {
|
|
314
|
+
expectedAccessToken: credentials?.accessToken,
|
|
315
|
+
requestBaseUrl: baseUrl,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (!(error instanceof CredentialGenerationChangedError) &&
|
|
320
|
+
!(error instanceof CredentialOriginMismatchError)) {
|
|
321
|
+
await this.clearCredentialsAfterUnauthorized(attemptedRefreshToken, error);
|
|
322
|
+
}
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (attemptedRefreshToken && credentials?.accessToken) {
|
|
109
327
|
headers.set("Authorization", `Bearer ${credentials.accessToken}`);
|
|
110
|
-
const retryResponse = await fetch(
|
|
111
|
-
...
|
|
328
|
+
const retryResponse = await fetch(requestUrl, {
|
|
329
|
+
...requestInit,
|
|
112
330
|
headers,
|
|
113
|
-
body:
|
|
331
|
+
body: json !== undefined ? JSON.stringify(json) : requestInit.body,
|
|
114
332
|
});
|
|
115
333
|
const retryBody = await parseResponse(retryResponse);
|
|
116
334
|
if (!retryResponse.ok) {
|
|
335
|
+
const retryError = new ApiError(`Request failed with ${retryResponse.status}`, retryResponse.status, retryBody);
|
|
117
336
|
if (retryResponse.status === 401) {
|
|
118
|
-
await
|
|
337
|
+
await this.clearCredentialsAfterUnauthorized(credentials.refreshToken, retryError);
|
|
119
338
|
}
|
|
120
|
-
throw
|
|
339
|
+
throw retryError;
|
|
121
340
|
}
|
|
122
341
|
return retryBody;
|
|
123
342
|
}
|
|
124
343
|
}
|
|
125
344
|
const body = await parseResponse(response);
|
|
126
345
|
if (!response.ok) {
|
|
127
|
-
|
|
128
|
-
|
|
346
|
+
const requestError = new ApiError(`Request failed with ${response.status}`, response.status, body);
|
|
347
|
+
if (response.status === 401 && usesStoredCredentials) {
|
|
348
|
+
await this.clearCredentialsAfterUnauthorized(credentials?.refreshToken, requestError);
|
|
129
349
|
}
|
|
130
|
-
throw
|
|
350
|
+
throw requestError;
|
|
131
351
|
}
|
|
132
352
|
return body;
|
|
133
353
|
}
|
package/dist/args.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
const BOOLEAN_FLAGS = new Set([
|
|
1
|
+
const BOOLEAN_FLAGS = new Set([
|
|
2
|
+
"force",
|
|
3
|
+
"non-interactive",
|
|
4
|
+
"json",
|
|
5
|
+
"no-open",
|
|
6
|
+
"replace",
|
|
7
|
+
"help",
|
|
8
|
+
]);
|
|
2
9
|
const REPEATABLE_VALUE_FLAGS = new Set(["repo", "pr", "pr-path", "fix-pr-url", "label"]);
|
|
3
10
|
export function parseArgs(argv) {
|
|
4
11
|
const flags = {};
|
package/dist/auth.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { openInBrowser } from "./browser.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { getClaimProofState } from "./claim-proof.js";
|
|
3
|
+
import { getCredentialGeneration, saveDeviceLoginCredentialsIfGenerationMatches, withCredentialLifecycleLock, } from "./config.js";
|
|
4
|
+
import { ApiError, formatApiError } from "./api-client.js";
|
|
4
5
|
import { APEX_CLI_VERSION } from "./version.js";
|
|
5
6
|
const DEVICE_CLIENT_NAME = "apex";
|
|
7
|
+
const DEVICE_LOGIN_PREFLIGHT_ATTEMPTS = 3;
|
|
6
8
|
function sleep(ms) {
|
|
7
9
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
10
|
}
|
|
@@ -19,44 +21,18 @@ function toCredentials(poll) {
|
|
|
19
21
|
refreshTokenExpiresAt: poll.refreshTokenExpiresAt,
|
|
20
22
|
};
|
|
21
23
|
}
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
return await client.request("/api/cli/v1/me");
|
|
25
|
-
}
|
|
26
|
-
catch (error) {
|
|
27
|
-
if (error instanceof ApiError && error.status === 401) {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
throw error;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
export async function startDeviceLogin(client) {
|
|
34
|
-
return client.request("/api/cli/v1/auth/device/start", {
|
|
35
|
-
method: "POST",
|
|
36
|
-
auth: false,
|
|
37
|
-
json: {
|
|
38
|
-
clientName: DEVICE_CLIENT_NAME,
|
|
39
|
-
clientVersion: APEX_CLI_VERSION,
|
|
40
|
-
},
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
export async function pollDeviceLogin(client, deviceCode) {
|
|
44
|
-
return client.request("/api/cli/v1/auth/device/poll", {
|
|
45
|
-
method: "POST",
|
|
46
|
-
auth: false,
|
|
47
|
-
json: { deviceCode },
|
|
48
|
-
retryOnUnauthorized: false,
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
export async function waitForDeviceLoginApproval(client, options) {
|
|
24
|
+
async function waitForDeviceLoginCredentials(client, options) {
|
|
52
25
|
let intervalSeconds = Math.max(0, options.intervalSeconds ?? 5);
|
|
53
26
|
let expiresAt = options.expiresAt ?? null;
|
|
27
|
+
let pollingBaseUrl = options.requestBaseUrl ?? null;
|
|
54
28
|
const timeoutAt = Date.now() + Math.max(0, options.timeoutMs ?? 5 * 60 * 1000);
|
|
55
29
|
function remainingTimeoutMs() {
|
|
56
30
|
return Math.max(0, timeoutAt - Date.now());
|
|
57
31
|
}
|
|
58
32
|
while (remainingTimeoutMs() > 0) {
|
|
59
|
-
const expiresAtTime = expiresAt
|
|
33
|
+
const expiresAtTime = expiresAt
|
|
34
|
+
? new Date(expiresAt).getTime()
|
|
35
|
+
: Number.POSITIVE_INFINITY;
|
|
60
36
|
if (Number.isFinite(expiresAtTime) && Date.now() >= expiresAtTime) {
|
|
61
37
|
return {
|
|
62
38
|
status: "expired",
|
|
@@ -71,16 +47,19 @@ export async function waitForDeviceLoginApproval(client, options) {
|
|
|
71
47
|
break;
|
|
72
48
|
}
|
|
73
49
|
try {
|
|
74
|
-
|
|
50
|
+
pollingBaseUrl ??= await client.getBaseUrl();
|
|
51
|
+
const polled = await pollDeviceLogin(client, options.deviceCode, pollingBaseUrl);
|
|
75
52
|
if (polled.status === "pending") {
|
|
76
53
|
intervalSeconds = Math.max(0, polled.intervalSeconds);
|
|
77
54
|
expiresAt = polled.expiresAt;
|
|
78
55
|
continue;
|
|
79
56
|
}
|
|
80
|
-
await saveCredentials(toCredentials(polled));
|
|
81
57
|
return {
|
|
82
58
|
status: "approved",
|
|
83
|
-
|
|
59
|
+
credentials: {
|
|
60
|
+
...toCredentials(polled),
|
|
61
|
+
issuerOrigin: new URL(pollingBaseUrl).origin,
|
|
62
|
+
},
|
|
84
63
|
};
|
|
85
64
|
}
|
|
86
65
|
catch (error) {
|
|
@@ -99,12 +78,129 @@ export async function waitForDeviceLoginApproval(client, options) {
|
|
|
99
78
|
expiresAt,
|
|
100
79
|
};
|
|
101
80
|
}
|
|
81
|
+
export async function getMe(client) {
|
|
82
|
+
try {
|
|
83
|
+
return await client.request("/api/cli/v1/me");
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function startDeviceLogin(client, requestBaseUrl) {
|
|
93
|
+
return client.request("/api/cli/v1/auth/device/start", {
|
|
94
|
+
method: "POST",
|
|
95
|
+
auth: false,
|
|
96
|
+
json: {
|
|
97
|
+
clientName: DEVICE_CLIENT_NAME,
|
|
98
|
+
clientVersion: APEX_CLI_VERSION,
|
|
99
|
+
},
|
|
100
|
+
...(requestBaseUrl ? { requestBaseUrl } : {}),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export async function pollDeviceLogin(client, deviceCode, requestBaseUrl) {
|
|
104
|
+
return client.request("/api/cli/v1/auth/device/poll", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
auth: false,
|
|
107
|
+
json: { deviceCode },
|
|
108
|
+
retryOnUnauthorized: false,
|
|
109
|
+
...(requestBaseUrl ? { requestBaseUrl } : {}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
export async function waitForDeviceLoginApproval(client, options) {
|
|
113
|
+
const currentCredentials = await client.getCredentials();
|
|
114
|
+
if (getCredentialGeneration(currentCredentials) !== options.credentialGeneration) {
|
|
115
|
+
return {
|
|
116
|
+
status: "superseded",
|
|
117
|
+
approvalReceived: false,
|
|
118
|
+
reason: "generation_changed",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const result = await waitForDeviceLoginCredentials(client, options);
|
|
122
|
+
if (result.status === "approved") {
|
|
123
|
+
// A normal login may happen after guest API access expires but before the
|
|
124
|
+
// longer claim window closes. Switch the active auth kind without erasing
|
|
125
|
+
// the locally held claim proof needed by a later `apex claim`.
|
|
126
|
+
const commitResult = await saveDeviceLoginCredentialsIfGenerationMatches(options.credentialGeneration, {
|
|
127
|
+
...result.credentials,
|
|
128
|
+
guest: false,
|
|
129
|
+
});
|
|
130
|
+
if (commitResult !== "saved") {
|
|
131
|
+
return {
|
|
132
|
+
status: "superseded",
|
|
133
|
+
approvalReceived: true,
|
|
134
|
+
reason: commitResult,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
status: "approved",
|
|
139
|
+
me: await client.request("/api/cli/v1/me"),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
export async function readStableDeviceLoginState(client) {
|
|
145
|
+
for (let attempt = 0; attempt < DEVICE_LOGIN_PREFLIGHT_ATTEMPTS; attempt += 1) {
|
|
146
|
+
const before = await client.getCredentials();
|
|
147
|
+
const credentialGeneration = getCredentialGeneration(before);
|
|
148
|
+
const me = await getMe(client);
|
|
149
|
+
const after = await client.getCredentials();
|
|
150
|
+
if (getCredentialGeneration(after) === credentialGeneration) {
|
|
151
|
+
return {
|
|
152
|
+
me,
|
|
153
|
+
credentials: after,
|
|
154
|
+
credentialGeneration,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
throw new Error("The Apex credential state kept changing while device login was starting. The current state was kept; retry `apex login`.");
|
|
159
|
+
}
|
|
160
|
+
export async function loginWithoutPersist(client, options = {}) {
|
|
161
|
+
const requestBaseUrl = options.requestBaseUrl ?? await client.getBaseUrl();
|
|
162
|
+
const started = await startDeviceLogin(client, requestBaseUrl);
|
|
163
|
+
await options.onVerification?.(started);
|
|
164
|
+
log(`Login code: ${started.userCode}`, options.quiet);
|
|
165
|
+
log(`Open: ${started.verificationUrl}`, options.quiet);
|
|
166
|
+
if (!options.noOpen) {
|
|
167
|
+
try {
|
|
168
|
+
await openInBrowser(started.verificationUrl);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
log(`Failed to open browser automatically: ${formatApiError(error)}`, options.quiet);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const result = await waitForDeviceLoginCredentials(client, {
|
|
175
|
+
deviceCode: started.deviceCode,
|
|
176
|
+
requestBaseUrl,
|
|
177
|
+
intervalSeconds: started.intervalSeconds,
|
|
178
|
+
expiresAt: started.expiresAt,
|
|
179
|
+
timeoutMs: Math.max(0, new Date(started.expiresAt).getTime() - Date.now()),
|
|
180
|
+
});
|
|
181
|
+
if (result.status === "approved") {
|
|
182
|
+
return result.credentials;
|
|
183
|
+
}
|
|
184
|
+
throw new Error("CLI login timed out before approval completed");
|
|
185
|
+
}
|
|
102
186
|
export async function login(client, options) {
|
|
103
|
-
const
|
|
187
|
+
const loginState = await readStableDeviceLoginState(client);
|
|
188
|
+
const existing = loginState.me;
|
|
104
189
|
if (existing) {
|
|
105
|
-
|
|
190
|
+
const isGuest = existing.user.role?.trim().toLowerCase() === "guest";
|
|
191
|
+
if (!isGuest) {
|
|
192
|
+
return existing;
|
|
193
|
+
}
|
|
194
|
+
// An active guest normally stays in its recoverable trial flow. Once its
|
|
195
|
+
// locally known claim deadline has passed, explicit `apex login` is the
|
|
196
|
+
// supported escape hatch to an account. Starting device auth is
|
|
197
|
+
// non-destructive and approval preserves the local proof metadata.
|
|
198
|
+
if (getClaimProofState(loginState.credentials) !== "expired") {
|
|
199
|
+
return existing;
|
|
200
|
+
}
|
|
106
201
|
}
|
|
107
|
-
const
|
|
202
|
+
const requestBaseUrl = await client.getBaseUrl();
|
|
203
|
+
const started = await startDeviceLogin(client, requestBaseUrl);
|
|
108
204
|
log(`Login code: ${started.userCode}`, options.quiet);
|
|
109
205
|
log(`Open: ${started.verificationUrl}`, options.quiet);
|
|
110
206
|
if (!options.noOpen) {
|
|
@@ -112,11 +208,13 @@ export async function login(client, options) {
|
|
|
112
208
|
await openInBrowser(started.verificationUrl);
|
|
113
209
|
}
|
|
114
210
|
catch (error) {
|
|
115
|
-
log(`Failed to open browser automatically: ${
|
|
211
|
+
log(`Failed to open browser automatically: ${formatApiError(error)}`, options.quiet);
|
|
116
212
|
}
|
|
117
213
|
}
|
|
118
214
|
const result = await waitForDeviceLoginApproval(client, {
|
|
119
215
|
deviceCode: started.deviceCode,
|
|
216
|
+
credentialGeneration: loginState.credentialGeneration,
|
|
217
|
+
requestBaseUrl,
|
|
120
218
|
intervalSeconds: started.intervalSeconds,
|
|
121
219
|
expiresAt: started.expiresAt,
|
|
122
220
|
timeoutMs: Math.max(0, new Date(started.expiresAt).getTime() - Date.now()),
|
|
@@ -124,20 +222,39 @@ export async function login(client, options) {
|
|
|
124
222
|
if (result.status === "approved") {
|
|
125
223
|
return result.me;
|
|
126
224
|
}
|
|
225
|
+
if (result.status === "superseded") {
|
|
226
|
+
if (result.reason === "credential_operation_busy") {
|
|
227
|
+
throw new Error("Device login was approved, but another Apex credential operation was still in progress. Nothing was overwritten; retry the original command after it finishes.");
|
|
228
|
+
}
|
|
229
|
+
throw new Error(result.approvalReceived
|
|
230
|
+
? "Device login was approved, but the Apex credential state changed before it could be saved. The current state was kept; retry the original command, or run `apex login` again only if authentication is still required."
|
|
231
|
+
: "The Apex credential state changed after device login started. The current state was kept; retry the original command, or run `apex login` again only if authentication is still required.");
|
|
232
|
+
}
|
|
127
233
|
throw new Error("CLI login timed out before approval completed");
|
|
128
234
|
}
|
|
129
|
-
export
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
235
|
+
export function logout(client, expectedCredentials) {
|
|
236
|
+
const expectedGeneration = getCredentialGeneration(expectedCredentials);
|
|
237
|
+
return withCredentialLifecycleLock(async () => {
|
|
238
|
+
const credentials = await client.getCredentials();
|
|
239
|
+
if (getCredentialGeneration(credentials) !== expectedGeneration) {
|
|
240
|
+
throw new Error("Apex credentials changed before logout completed. The current session was kept; retry `apex logout` so any guest trial can be confirmed explicitly.");
|
|
241
|
+
}
|
|
242
|
+
if (!credentials) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!credentials.accessToken.trim() && !credentials.refreshToken.trim()) {
|
|
246
|
+
// A proof-only onboarding stub is already logged out. Keep its claim proof
|
|
247
|
+
// locally and avoid an unauthenticated logout request that can only fail.
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
await client.request("/api/cli/v1/auth/logout", {
|
|
252
|
+
method: "POST",
|
|
253
|
+
auth: true,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
await client.clearCredentials();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
143
260
|
}
|