@paradigma-inc/flywheel 0.2.28 → 0.2.31
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/package.json +1 -1
- package/src/cli.mjs +7 -1
- package/src/runtime/required-runtime-commands.json +1 -0
- package/src/runtime/vendor/flywheel-cli-dist/commands/registry/repository.js +28 -3
- package/src/runtime/vendor/flywheel-cli-dist/commands/registry/repository.js.map +1 -1
- package/src/runtime/vendor/flywheel-cli-dist/commands/repository-operation.js +10 -3
- package/src/runtime/vendor/flywheel-cli-dist/commands/repository-operation.js.map +1 -1
- package/src/runtime/vendor/flywheel-cli-dist/commands/types.js.map +1 -1
- package/src/runtime/vendor/flywheel-cli-dist/generatedProductTelemetryContract.js +10 -2
- package/src/runtime/vendor/flywheel-cli-dist/generatedProductTelemetryContract.js.map +1 -1
- package/src/runtime/vendor/flywheel-cli-dist/local/adapter.js +6 -0
- package/src/runtime/vendor/flywheel-cli-dist/local/adapter.js.map +1 -1
- package/src/runtime/vendor/flywheel-cli-dist/local/runtime/dispatch.js +94 -3
- package/src/runtime/vendor/flywheel-cli-dist/local/runtime/dispatch.js.map +1 -1
- package/src/runtime/vendor/flywheel-cli-dist/local/runtime/remote/artifact-download.js +46 -0
- package/src/runtime/vendor/flywheel-cli-dist/local/runtime/remote/artifact-download.js.map +1 -0
- package/src/runtime/vendor/flywheel-cli-dist/local/store/sqlite/artifact-download.js +46 -0
- package/src/runtime/vendor/flywheel-cli-dist/local/store/sqlite/artifact-download.js.map +1 -0
- package/src/runtime/vendor/flywheel-cli-dist/local/store/sqlite/store.js +7 -0
- package/src/runtime/vendor/flywheel-cli-dist/local/store/sqlite/store.js.map +1 -1
- package/src/runtime/vendor/flywheel-cli-dist/main.js +45 -1
- package/src/runtime/vendor/flywheel-cli-dist/main.js.map +1 -1
- package/src/runtime/vendor/manifest.json +5 -1
- package/src/setup/modes/cli.mjs +4 -0
- package/src/setup/modes/mcp-command.mjs +88 -71
- package/src/setup-auth.mjs +264 -56
package/src/setup-auth.mjs
CHANGED
|
@@ -7,11 +7,25 @@ import { isatty } from "node:tty";
|
|
|
7
7
|
import { setTimeout as delay } from "node:timers/promises";
|
|
8
8
|
|
|
9
9
|
import { SetupInputUsageError } from "./setup/mode.mjs";
|
|
10
|
+
import {
|
|
11
|
+
isRetryableNetworkError,
|
|
12
|
+
isRetryableStatus,
|
|
13
|
+
parseRetryAfterMs,
|
|
14
|
+
withRetry,
|
|
15
|
+
} from "./runtime/vendor/flywheel-cli-dist/http/retry.js";
|
|
10
16
|
|
|
11
17
|
const DEFAULT_AUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
12
18
|
const MIN_POLL_DELAY_MS = 1000;
|
|
13
19
|
const MAX_POLL_DELAY_MS = 10_000;
|
|
14
20
|
const SETUP_DEVICE_API_PREFIX = "/api/v1/auth/mcp-api-keys/setup-device";
|
|
21
|
+
// FLY-1112: the setup-device start request is retried through the bundled
|
|
22
|
+
// helper. One logical request (body, idempotency key, request ID) is reused
|
|
23
|
+
// across at most three attempts; each attempt gets a fresh signal of at most
|
|
24
|
+
// 15s, and the whole recovery sequence is bounded by 49s so a temporary
|
|
25
|
+
// routing failure fails fast instead of pinning the spinner for two minutes.
|
|
26
|
+
const START_ATTEMPT_TIMEOUT_MS = 15_000;
|
|
27
|
+
const START_RECOVERY_BUDGET_MS = 49_000;
|
|
28
|
+
const START_MAX_ATTEMPTS = 3;
|
|
15
29
|
const readDescriptor = promisify(readFd);
|
|
16
30
|
|
|
17
31
|
async function readStdin(fd, buffer, offset, length, position) {
|
|
@@ -211,77 +225,246 @@ export function normalizeSetupAuthMode(value) {
|
|
|
211
225
|
// return { apiKey: key };
|
|
212
226
|
// }
|
|
213
227
|
|
|
228
|
+
export function formatStartRetryMessage({ nextAttempt, maxAttempts }) {
|
|
229
|
+
return `Flywheel is taking longer than expected; retrying (${nextAttempt} of ${maxAttempts})...`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A retryable-class start failure: local attempt timeout, recognized
|
|
233
|
+
// temporary network error, or accepted HTTP status. Carries the structured
|
|
234
|
+
// summary used for progress callbacks and the exhausted-attempts error, plus
|
|
235
|
+
// the original error so non-retried failures retain their existing detail.
|
|
236
|
+
class StartAttemptFailure extends Error {
|
|
237
|
+
constructor(failure, cause, { retryAfter = null, returnAsIs = false } = {}) {
|
|
238
|
+
super(cause.message);
|
|
239
|
+
this.failure = failure;
|
|
240
|
+
this.cause = cause;
|
|
241
|
+
this.retryAfter = retryAfter;
|
|
242
|
+
this.returnAsIs = returnAsIs;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Internal sentinels: the sleep gate raises these when the next attempt can
|
|
247
|
+
// no longer fit inside a deadline; the outer catch maps them to the exact
|
|
248
|
+
// public error messages.
|
|
249
|
+
class StartRecoveryExhausted extends Error {
|
|
250
|
+
constructor(failure) {
|
|
251
|
+
super("start recovery budget exhausted");
|
|
252
|
+
this.failure = failure;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
class StartAuthorizationDeadline extends Error {}
|
|
256
|
+
|
|
257
|
+
function startFailureDescription(failure) {
|
|
258
|
+
return failure.kind === "http" ? `HTTP ${failure.status}` : failure.kind;
|
|
259
|
+
}
|
|
260
|
+
|
|
214
261
|
async function startSetupDeviceSession({
|
|
215
262
|
baseUrl,
|
|
216
263
|
keyName,
|
|
217
264
|
verificationBaseUrl,
|
|
218
|
-
|
|
265
|
+
authorizationDeadlineMs,
|
|
266
|
+
now = Date.now,
|
|
267
|
+
fetchFn = fetch,
|
|
268
|
+
sleep = defaultSleep,
|
|
269
|
+
random = Math.random,
|
|
270
|
+
createTimeoutSignal = AbortSignal.timeout,
|
|
271
|
+
onStartRetry,
|
|
219
272
|
}) {
|
|
220
273
|
const startUrl = new URL(`${SETUP_DEVICE_API_PREFIX}/start`, baseUrl);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
274
|
+
// One logical request: serialized body, idempotency key, and request ID are
|
|
275
|
+
// created once before attempt 1 and reused unchanged on every attempt so the
|
|
276
|
+
// server can replay the first unexpired session instead of duplicating it.
|
|
277
|
+
const body = JSON.stringify({
|
|
278
|
+
name: keyName,
|
|
279
|
+
verification_base_url: verificationBaseUrl,
|
|
280
|
+
});
|
|
281
|
+
const idempotencyKey = `setup-device-start:${crypto.randomUUID()}`;
|
|
282
|
+
const requestId = crypto.randomUUID();
|
|
283
|
+
const recoveryDeadlineMs = Math.min(
|
|
284
|
+
authorizationDeadlineMs,
|
|
285
|
+
now() + START_RECOVERY_BUDGET_MS,
|
|
286
|
+
);
|
|
287
|
+
// Tracks the most recently classified retryable failure so a budget
|
|
288
|
+
// guard reached at the top of a later attempt (see attemptStart below)
|
|
289
|
+
// can still attribute exhaustion to the retry sequence instead of the
|
|
290
|
+
// generic authorization timeout, matching gatedSleep's own attribution.
|
|
291
|
+
let lastFailure = null;
|
|
292
|
+
|
|
293
|
+
const attemptStart = async () => {
|
|
294
|
+
const signalBudgetMs = Math.min(
|
|
295
|
+
START_ATTEMPT_TIMEOUT_MS,
|
|
296
|
+
authorizationDeadlineMs - now(),
|
|
297
|
+
recoveryDeadlineMs - now(),
|
|
298
|
+
);
|
|
299
|
+
if (signalBudgetMs <= 0) {
|
|
300
|
+
// Ordinarily unreachable: the caller guards the authorization deadline
|
|
301
|
+
// before the first attempt, and gatedSleep already re-checks this same
|
|
302
|
+
// budget before scheduling any later attempt's sleep. Kept as a
|
|
303
|
+
// defensive fallback for real-world sleep/timer overrun (the actual
|
|
304
|
+
// wait exceeding the requested delay) between gatedSleep's check and
|
|
305
|
+
// this attempt actually starting.
|
|
306
|
+
if (recoveryDeadlineMs < authorizationDeadlineMs && lastFailure) {
|
|
307
|
+
throw new StartRecoveryExhausted(lastFailure);
|
|
308
|
+
}
|
|
309
|
+
throw new StartAuthorizationDeadline();
|
|
310
|
+
}
|
|
311
|
+
const signal = createTimeoutSignal(signalBudgetMs);
|
|
312
|
+
let response;
|
|
313
|
+
let payload;
|
|
314
|
+
try {
|
|
315
|
+
response = await fetchFn(startUrl, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
headers: {
|
|
318
|
+
"content-type": "application/json",
|
|
319
|
+
"Idempotency-Key": idempotencyKey,
|
|
320
|
+
"X-Flywheel-Surface": "cli",
|
|
321
|
+
"X-Flywheel-Request-Id": requestId,
|
|
322
|
+
"X-Request-ID": requestId,
|
|
323
|
+
},
|
|
324
|
+
body,
|
|
325
|
+
signal,
|
|
326
|
+
});
|
|
327
|
+
payload = await parseJsonResponse(response, { signal });
|
|
328
|
+
} catch (error) {
|
|
329
|
+
// Only an abort of this attempt's own signal is a local timeout.
|
|
330
|
+
if (signal.aborted) {
|
|
331
|
+
throw new StartAttemptFailure({ kind: "timeout" }, error);
|
|
332
|
+
}
|
|
333
|
+
if (isRetryableNetworkError(error)) {
|
|
334
|
+
throw new StartAttemptFailure({ kind: "network" }, error);
|
|
335
|
+
}
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
if (!response.ok) {
|
|
339
|
+
const detail = resolveErrorMessage(payload, "Failed to start setup device flow.");
|
|
340
|
+
throw new StartAttemptFailure(
|
|
341
|
+
{ kind: "http", status: response.status },
|
|
342
|
+
new Error(detail),
|
|
343
|
+
{ retryAfter: response.headers?.get?.("retry-after") ?? null },
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const deviceCode = nonEmptyString(payload.device_code);
|
|
348
|
+
const userCode = nonEmptyString(payload.user_code);
|
|
349
|
+
const verificationUri = nonEmptyString(payload.verification_uri);
|
|
350
|
+
const verificationUriComplete = nonEmptyString(payload.verification_uri_complete);
|
|
351
|
+
const pollIntervalSeconds = Number(payload.poll_interval_seconds);
|
|
352
|
+
const expiresInSeconds = Number(payload.expires_in_seconds);
|
|
353
|
+
|
|
354
|
+
if (!deviceCode || !userCode || !verificationUri || !verificationUriComplete) {
|
|
355
|
+
throw new Error("Setup device session response missing required fields.");
|
|
356
|
+
}
|
|
357
|
+
if (
|
|
358
|
+
!Number.isFinite(pollIntervalSeconds) ||
|
|
359
|
+
pollIntervalSeconds <= 0 ||
|
|
360
|
+
!Number.isFinite(expiresInSeconds) ||
|
|
361
|
+
expiresInSeconds <= 0
|
|
362
|
+
) {
|
|
363
|
+
throw new Error("Setup device session response contained invalid timing values.");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
deviceCode,
|
|
368
|
+
userCode,
|
|
369
|
+
verificationUri,
|
|
370
|
+
verificationUriComplete,
|
|
371
|
+
pollIntervalSeconds: Math.floor(pollIntervalSeconds),
|
|
372
|
+
expiresInSeconds: Math.floor(expiresInSeconds),
|
|
373
|
+
};
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
let pendingRetry = null;
|
|
377
|
+
// A retry is scheduled only when the delay plus a full 15s next attempt still
|
|
378
|
+
// fits inside the recovery budget. The gate runs before the progress
|
|
379
|
+
// callback so "retrying" is announced only when another attempt will happen.
|
|
380
|
+
const gatedSleep = async (delayMs) => {
|
|
381
|
+
const { nextAttempt, failure } = pendingRetry;
|
|
382
|
+
pendingRetry = null;
|
|
383
|
+
if (now() + delayMs + START_ATTEMPT_TIMEOUT_MS > recoveryDeadlineMs) {
|
|
384
|
+
if (recoveryDeadlineMs === authorizationDeadlineMs) {
|
|
385
|
+
throw new StartAuthorizationDeadline();
|
|
386
|
+
}
|
|
387
|
+
throw new StartRecoveryExhausted(failure);
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
onStartRetry?.({
|
|
391
|
+
nextAttempt,
|
|
392
|
+
maxAttempts: START_MAX_ATTEMPTS,
|
|
393
|
+
delayMs,
|
|
394
|
+
requestId,
|
|
395
|
+
failure,
|
|
396
|
+
});
|
|
397
|
+
} catch {
|
|
398
|
+
// Presentation is best-effort and must not change retry behavior.
|
|
399
|
+
}
|
|
400
|
+
await sleep(delayMs);
|
|
401
|
+
};
|
|
402
|
+
|
|
224
403
|
try {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
404
|
+
return await withRetry(attemptStart, {
|
|
405
|
+
maxAttempts: START_MAX_ATTEMPTS,
|
|
406
|
+
sleep: gatedSleep,
|
|
407
|
+
random,
|
|
408
|
+
shouldRetry: (error, attempt) => {
|
|
409
|
+
if (!(error instanceof StartAttemptFailure) || error.returnAsIs) {
|
|
410
|
+
return { retry: false };
|
|
411
|
+
}
|
|
412
|
+
const { failure } = error;
|
|
413
|
+
if (failure.kind === "http") {
|
|
414
|
+
if (!isRetryableStatus(failure.status)) {
|
|
415
|
+
return { retry: false };
|
|
416
|
+
}
|
|
417
|
+
const retryAfterMs = parseRetryAfterMs(error.retryAfter);
|
|
418
|
+
if (retryAfterMs !== null) {
|
|
419
|
+
// A valid Retry-After replaces the helper's delay only when it
|
|
420
|
+
// leaves room for the remaining 15s attempts; otherwise the
|
|
421
|
+
// current HTTP failure is returned unchanged.
|
|
422
|
+
if (now() + retryAfterMs + START_ATTEMPT_TIMEOUT_MS <= recoveryDeadlineMs) {
|
|
423
|
+
pendingRetry = { nextAttempt: attempt + 1, failure };
|
|
424
|
+
lastFailure = failure;
|
|
425
|
+
return { retry: true, delayMs: retryAfterMs };
|
|
426
|
+
}
|
|
427
|
+
error.returnAsIs = true;
|
|
428
|
+
return { retry: false };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
pendingRetry = { nextAttempt: attempt + 1, failure };
|
|
432
|
+
lastFailure = failure;
|
|
433
|
+
return { retry: true };
|
|
230
434
|
},
|
|
231
|
-
body: JSON.stringify({
|
|
232
|
-
name: keyName,
|
|
233
|
-
verification_base_url: verificationBaseUrl,
|
|
234
|
-
}),
|
|
235
|
-
signal: startTimeoutSignal,
|
|
236
435
|
});
|
|
237
|
-
payload = await parseJsonResponse(response, { signal: startTimeoutSignal });
|
|
238
436
|
} catch (error) {
|
|
239
|
-
if (
|
|
437
|
+
if (error instanceof StartAuthorizationDeadline) {
|
|
240
438
|
throw new Error("Timed out waiting for device authorization.");
|
|
241
439
|
}
|
|
440
|
+
if (error instanceof StartRecoveryExhausted) {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`Failed to start device authorization after ${START_MAX_ATTEMPTS} attempts (last failure: ${startFailureDescription(error.failure)}; request ID: ${requestId}). Run the same setup command again.`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
if (error instanceof StartAttemptFailure) {
|
|
446
|
+
const retryableClass =
|
|
447
|
+
error.failure.kind !== "http" || isRetryableStatus(error.failure.status);
|
|
448
|
+
if (retryableClass && !error.returnAsIs) {
|
|
449
|
+
// A retryable failure escaping withRetry means the final attempt
|
|
450
|
+
// failed; report the exhausted sequence with request correlation.
|
|
451
|
+
throw new Error(
|
|
452
|
+
`Failed to start device authorization after ${START_MAX_ATTEMPTS} attempts (last failure: ${startFailureDescription(error.failure)}; request ID: ${requestId}). Run the same setup command again.`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
throw error.cause;
|
|
456
|
+
}
|
|
242
457
|
throw error;
|
|
243
458
|
}
|
|
244
|
-
if (!response.ok) {
|
|
245
|
-
const detail = resolveErrorMessage(payload, "Failed to start setup device flow.");
|
|
246
|
-
throw new Error(detail);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const deviceCode = nonEmptyString(payload.device_code);
|
|
250
|
-
const userCode = nonEmptyString(payload.user_code);
|
|
251
|
-
const verificationUri = nonEmptyString(payload.verification_uri);
|
|
252
|
-
const verificationUriComplete = nonEmptyString(payload.verification_uri_complete);
|
|
253
|
-
const pollIntervalSeconds = Number(payload.poll_interval_seconds);
|
|
254
|
-
const expiresInSeconds = Number(payload.expires_in_seconds);
|
|
255
|
-
|
|
256
|
-
if (!deviceCode || !userCode || !verificationUri || !verificationUriComplete) {
|
|
257
|
-
throw new Error("Setup device session response missing required fields.");
|
|
258
|
-
}
|
|
259
|
-
if (
|
|
260
|
-
!Number.isFinite(pollIntervalSeconds) ||
|
|
261
|
-
pollIntervalSeconds <= 0 ||
|
|
262
|
-
!Number.isFinite(expiresInSeconds) ||
|
|
263
|
-
expiresInSeconds <= 0
|
|
264
|
-
) {
|
|
265
|
-
throw new Error("Setup device session response contained invalid timing values.");
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
return {
|
|
269
|
-
deviceCode,
|
|
270
|
-
userCode,
|
|
271
|
-
verificationUri,
|
|
272
|
-
verificationUriComplete,
|
|
273
|
-
pollIntervalSeconds: Math.floor(pollIntervalSeconds),
|
|
274
|
-
expiresInSeconds: Math.floor(expiresInSeconds),
|
|
275
|
-
};
|
|
276
459
|
}
|
|
277
460
|
|
|
278
|
-
async function pollSetupDeviceSession({ baseUrl, userCode, deviceCode }) {
|
|
461
|
+
async function pollSetupDeviceSession({ baseUrl, userCode, deviceCode, fetchFn = fetch, now = Date.now }) {
|
|
279
462
|
const pollUrl = new URL(`${SETUP_DEVICE_API_PREFIX}/poll`, baseUrl);
|
|
280
|
-
const response = await
|
|
463
|
+
const response = await fetchFn(pollUrl, {
|
|
281
464
|
method: "POST",
|
|
282
465
|
headers: {
|
|
283
466
|
"content-type": "application/json",
|
|
284
|
-
"Idempotency-Key": `setup-device-poll:${deviceCode}:${
|
|
467
|
+
"Idempotency-Key": `setup-device-poll:${deviceCode}:${now()}`,
|
|
285
468
|
},
|
|
286
469
|
body: JSON.stringify({
|
|
287
470
|
user_code: userCode,
|
|
@@ -377,6 +560,12 @@ export async function resolveSetupApiKey({
|
|
|
377
560
|
acquireDeviceFlow = acquireApiKeyViaDeviceFlow,
|
|
378
561
|
acquireBrowserBridge = acquireApiKeyViaBrowserBridge,
|
|
379
562
|
keyNameFactory = () => `flywheel-setup-${crypto.randomBytes(3).toString("hex")}`,
|
|
563
|
+
fetchFn,
|
|
564
|
+
now,
|
|
565
|
+
sleep,
|
|
566
|
+
random,
|
|
567
|
+
createTimeoutSignal,
|
|
568
|
+
onStartRetry,
|
|
380
569
|
} = {}) {
|
|
381
570
|
// Check the value, not just property presence: callers like runCliSetupMode
|
|
382
571
|
// always forward `{apiKey, authMode}` to share one helper shape, so the
|
|
@@ -410,6 +599,12 @@ export async function resolveSetupApiKey({
|
|
|
410
599
|
baseUrl,
|
|
411
600
|
keyName,
|
|
412
601
|
verificationBaseUrl: baseUrl,
|
|
602
|
+
fetchFn,
|
|
603
|
+
now,
|
|
604
|
+
sleep,
|
|
605
|
+
random,
|
|
606
|
+
createTimeoutSignal,
|
|
607
|
+
onStartRetry,
|
|
413
608
|
})
|
|
414
609
|
: await acquireBrowserBridge({
|
|
415
610
|
baseUrl,
|
|
@@ -594,17 +789,28 @@ export async function acquireApiKeyViaDeviceFlow({
|
|
|
594
789
|
timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
|
|
595
790
|
openUrl = openUrlInBrowser,
|
|
596
791
|
sleep = defaultSleep,
|
|
792
|
+
fetchFn = fetch,
|
|
793
|
+
now = Date.now,
|
|
794
|
+
random = Math.random,
|
|
795
|
+
createTimeoutSignal = AbortSignal.timeout,
|
|
796
|
+
onStartRetry,
|
|
597
797
|
}) {
|
|
598
|
-
const startedAt =
|
|
599
|
-
const
|
|
600
|
-
if (
|
|
798
|
+
const startedAt = now();
|
|
799
|
+
const authorizationDeadlineMs = startedAt + timeoutMs;
|
|
800
|
+
if (authorizationDeadlineMs - now() <= 0) {
|
|
601
801
|
throw new Error("Timed out waiting for device authorization.");
|
|
602
802
|
}
|
|
603
803
|
const session = await startSetupDeviceSession({
|
|
604
804
|
baseUrl,
|
|
605
805
|
keyName,
|
|
606
806
|
verificationBaseUrl,
|
|
607
|
-
|
|
807
|
+
authorizationDeadlineMs,
|
|
808
|
+
now,
|
|
809
|
+
fetchFn,
|
|
810
|
+
sleep,
|
|
811
|
+
random,
|
|
812
|
+
createTimeoutSignal,
|
|
813
|
+
onStartRetry,
|
|
608
814
|
});
|
|
609
815
|
|
|
610
816
|
console.log("Opening browser for Flywheel device authorization...");
|
|
@@ -617,12 +823,14 @@ export async function acquireApiKeyViaDeviceFlow({
|
|
|
617
823
|
launchBrowser(openUrl, session.verificationUriComplete);
|
|
618
824
|
|
|
619
825
|
let attempt = 0;
|
|
620
|
-
while (
|
|
826
|
+
while (now() - startedAt < timeoutMs) {
|
|
621
827
|
// eslint-disable-next-line no-await-in-loop
|
|
622
828
|
const pollResult = await pollSetupDeviceSession({
|
|
623
829
|
baseUrl,
|
|
624
830
|
userCode: session.userCode,
|
|
625
831
|
deviceCode: session.deviceCode,
|
|
832
|
+
fetchFn,
|
|
833
|
+
now,
|
|
626
834
|
});
|
|
627
835
|
if (pollResult.status === "approved") {
|
|
628
836
|
return { apiKey: pollResult.apiKey };
|