@paradigma-inc/flywheel 0.2.29 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"generatedProductTelemetryContract.js","sourceRoot":"","sources":["../src/generatedProductTelemetryContract.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"generatedProductTelemetryContract.js","sourceRoot":"","sources":["../src/generatedProductTelemetryContract.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  {
2
- "sourceDigest": "sha256:d9e81d93a26e2afff24ba2a773aaf8913645bf2395ecb0b3320dbaaf94370404",
2
+ "sourceDigest": "sha256:8dc86325003b086c0d36470bfd6ecddf341c4bc7ef61df112d9dfc1ff3560e79",
3
3
  "files": [
4
4
  "auth/active-key-id.js",
5
5
  "auth/active-key-id.js.map",
@@ -199,6 +199,7 @@ export async function runCliSetupMode({
199
199
  cleanupSkillArtifactsForHosts = cleanupInstalledSkillArtifactsForHosts,
200
200
  removeDir = rm,
201
201
  resolveSetupApiKeyFn = resolveSetupApiKey,
202
+ onStartRetry,
202
203
  writeRuntimeConfigFn = writeRuntimeConfig,
203
204
  validateRemoteReadinessFn = validateCandidateRemoteReadiness,
204
205
  resolveFlywheelBinaryPath = resolveFlywheelBinaryOnPath,
@@ -313,6 +314,9 @@ export async function runCliSetupMode({
313
314
  authMode,
314
315
  },
315
316
  baseUrl,
317
+ // Presentation-only progress for scheduled start retries; never part
318
+ // of the setup result.
319
+ onStartRetry,
316
320
  });
317
321
  await validateRemoteReadinessFn({
318
322
  baseUrl,
@@ -20,7 +20,10 @@ import {
20
20
  readYamlConfig,
21
21
  resolveMcpPath,
22
22
  } from "../../mcp-writer.mjs";
23
- import { resolveSetupApiKey } from "../../setup-auth.mjs";
23
+ import {
24
+ formatStartRetryMessage,
25
+ resolveSetupApiKey,
26
+ } from "../../setup-auth.mjs";
24
27
  import {
25
28
  cleanupInstalledSkillArtifactsForHosts,
26
29
  formatSupportedInstallSkillHosts,
@@ -319,20 +322,31 @@ async function resolveHosts(options, scope, serverName, allowPrompt = true) {
319
322
  return selected;
320
323
  }
321
324
 
322
- async function resolveApiKey(options, baseUrl) {
325
+ async function resolveApiKey(
326
+ options,
327
+ baseUrl,
328
+ {
329
+ resolveSetupApiKeyFn = resolveSetupApiKey,
330
+ createAuthSpinner = (text) => ora(text).start(),
331
+ } = {},
332
+ ) {
323
333
  if (Object.hasOwn(options, "apiKey")) {
324
- const resolved = await resolveSetupApiKey({
334
+ const resolved = await resolveSetupApiKeyFn({
325
335
  options,
326
336
  baseUrl,
327
337
  });
328
338
  return resolved.apiKey;
329
339
  }
330
340
 
331
- const spinner = ora("Configuring authentication...").start();
341
+ const spinner = createAuthSpinner("Configuring authentication...");
332
342
  try {
333
- const resolved = await resolveSetupApiKey({
343
+ const resolved = await resolveSetupApiKeyFn({
334
344
  options,
335
345
  baseUrl,
346
+ // Retry progress is presentation-only: update the current spinner text.
347
+ onStartRetry: (progress) => {
348
+ spinner.text = formatStartRetryMessage(progress);
349
+ },
336
350
  });
337
351
  spinner.succeed(`Authenticated (${resolved.authMode})`);
338
352
  return resolved.apiKey;
@@ -586,6 +600,8 @@ function printSetupResultSummary(setupResult) {
586
600
 
587
601
  export async function runMcpModeCommand(options, deps = {}) {
588
602
  const projectRoot = process.cwd();
603
+ const detectPriorModeFn = deps.detectPriorModeForSetupFn ?? detectPriorModeForSetup;
604
+ const installSkillForHostsFn = deps.installSkillForHosts ?? installBundledSkillForHosts;
589
605
  const skillDecisionMode = normalizeSkillDecisionMode(options);
590
606
  const guidedSkillDecisionOverride = getGuidedSkillDecisionOverrideForTests();
591
607
  const nonInteractive = process.stdout.isTTY !== true;
@@ -682,12 +698,12 @@ export async function runMcpModeCommand(options, deps = {}) {
682
698
 
683
699
  // Capture prior-mode state before any install/auth side effects so the
684
700
  // detector does not classify artifacts created by this same command as
685
- // pre-existing CLI-mode installs, and so a refuse or reconcile decision
686
- // happens before we touch disk or start a browser auth flow. Running this
687
- // inside runMcpSetupMode (the prior placement) meant --force would uninstall
688
- // the freshly installed bundled skills, and the non-force path would still
689
- // have run auth before surfacing the conflict.
690
- const priorModeSnapshot = await detectPriorModeForSetup({
701
+ // pre-existing CLI-mode installs, and so a refuse decision happens before we
702
+ // touch disk or start a browser auth flow. Running this inside
703
+ // runMcpSetupMode (the prior placement) meant --force would uninstall the
704
+ // freshly installed bundled skills, and the non-force path would still have
705
+ // run auth before surfacing the conflict.
706
+ const priorModeSnapshot = await detectPriorModeFn({
691
707
  targetMode: "mcp",
692
708
  selectedHosts: hosts,
693
709
  scope,
@@ -695,11 +711,12 @@ export async function runMcpModeCommand(options, deps = {}) {
695
711
  serverName,
696
712
  serverUrl,
697
713
  });
698
- const reconciled = [];
699
- if (
700
- priorModeSnapshot.priorMode &&
701
- priorModeSnapshot.affectedHosts.length > 0
702
- ) {
714
+ // Conflict detection itself performs no write, so it stays ahead of
715
+ // authentication; the refusal and dependency checks below only throw.
716
+ const hasPriorModeConflict =
717
+ Boolean(priorModeSnapshot.priorMode) &&
718
+ priorModeSnapshot.affectedHosts.length > 0;
719
+ if (hasPriorModeConflict) {
703
720
  if (deps.force !== true) {
704
721
  throw new SetupModeUsageError(
705
722
  formatPriorModeConflictError(priorModeSnapshot),
@@ -710,37 +727,6 @@ export async function runMcpModeCommand(options, deps = {}) {
710
727
  "reconcilePriorMode dependency is required when --force is used.",
711
728
  );
712
729
  }
713
- const reconcileEntries = Array.isArray(priorModeSnapshot.entries)
714
- ? priorModeSnapshot.entries
715
- : priorModeSnapshot.affectedHosts.map((hostName) => ({
716
- hostName,
717
- serverNames: [],
718
- }));
719
- for (const entry of reconcileEntries) {
720
- try {
721
- // eslint-disable-next-line no-await-in-loop
722
- await deps.reconcilePriorMode({
723
- priorMode: priorModeSnapshot.priorMode,
724
- targetMode: "mcp",
725
- hostNames: [entry.hostName],
726
- serverNames: entry.serverNames,
727
- scope,
728
- cwd: projectRoot,
729
- serverName,
730
- serverUrl,
731
- });
732
- } catch (error) {
733
- const message = error instanceof Error ? error.message : String(error);
734
- const reconcileError = new Error(
735
- `Failed to reconcile prior --mode ${priorModeSnapshot.priorMode} artifacts for host ${entry.hostName}: ${message}`,
736
- );
737
- reconcileError.exitCode = 4;
738
- throw reconcileError;
739
- }
740
- }
741
- reconciled.push(
742
- `removed prior --mode ${priorModeSnapshot.priorMode} artifacts for host(s): ${priorModeSnapshot.affectedHosts.join(", ")}`,
743
- );
744
730
  }
745
731
 
746
732
  const installDriftHosts = driftHosts;
@@ -757,8 +743,61 @@ export async function runMcpModeCommand(options, deps = {}) {
757
743
  );
758
744
  }
759
745
 
746
+ // FLY-1112: authentication must finish before every configuration,
747
+ // reconciliation, and skill-install mutation, so a failed (or cancelled)
748
+ // authorization sequence never partially reconfigures the machine.
749
+ let apiKey = null;
750
+ const explicitApiKeyProvided = Object.hasOwn(options, "apiKey") || options.apiKeyStdin === true;
751
+ const shouldResolveApiKey =
752
+ !oauthMode && (needsSetupHosts.length > 0 || explicitApiKeyProvided);
753
+ if (shouldResolveApiKey) {
754
+ apiKey = authResult?.apiKey ?? await resolveApiKey(options, baseUrl, {
755
+ resolveSetupApiKeyFn: deps.resolveSetupApiKeyFn,
756
+ createAuthSpinner: deps.createAuthSpinner,
757
+ });
758
+ if (!apiKey) {
759
+ log.warn("Setup cancelled");
760
+ return { exitCode: 0 };
761
+ }
762
+ }
763
+
764
+ const reconciled = [];
760
765
  let cleanupState = null;
761
766
  try {
767
+ if (hasPriorModeConflict) {
768
+ const reconcileEntries = Array.isArray(priorModeSnapshot.entries)
769
+ ? priorModeSnapshot.entries
770
+ : priorModeSnapshot.affectedHosts.map((hostName) => ({
771
+ hostName,
772
+ serverNames: [],
773
+ }));
774
+ for (const entry of reconcileEntries) {
775
+ try {
776
+ // eslint-disable-next-line no-await-in-loop
777
+ await deps.reconcilePriorMode({
778
+ priorMode: priorModeSnapshot.priorMode,
779
+ targetMode: "mcp",
780
+ hostNames: [entry.hostName],
781
+ serverNames: entry.serverNames,
782
+ scope,
783
+ cwd: projectRoot,
784
+ serverName,
785
+ serverUrl,
786
+ });
787
+ } catch (error) {
788
+ const message = error instanceof Error ? error.message : String(error);
789
+ const reconcileError = new Error(
790
+ `Failed to reconcile prior --mode ${priorModeSnapshot.priorMode} artifacts for host ${entry.hostName}: ${message}`,
791
+ );
792
+ reconcileError.exitCode = 4;
793
+ throw reconcileError;
794
+ }
795
+ }
796
+ reconciled.push(
797
+ `removed prior --mode ${priorModeSnapshot.priorMode} artifacts for host(s): ${priorModeSnapshot.affectedHosts.join(", ")}`,
798
+ );
799
+ }
800
+
762
801
  if (skillDecisionMode === "install") {
763
802
  logBundledSkillInstallPlan({
764
803
  bundledSkillNames,
@@ -772,7 +811,7 @@ export async function runMcpModeCommand(options, deps = {}) {
772
811
  scope,
773
812
  skillNames: bundledSkillNames,
774
813
  });
775
- await installBundledSkillForHosts({
814
+ await installSkillForHostsFn({
776
815
  projectRoot,
777
816
  hostNames: supportedHosts,
778
817
  scope,
@@ -787,28 +826,6 @@ export async function runMcpModeCommand(options, deps = {}) {
787
826
  }
788
827
  }
789
828
 
790
- let apiKey = null;
791
- const explicitApiKeyProvided = Object.hasOwn(options, "apiKey") || options.apiKeyStdin === true;
792
- const shouldResolveApiKey =
793
- !oauthMode && (needsSetupHosts.length > 0 || explicitApiKeyProvided);
794
- if (shouldResolveApiKey) {
795
- apiKey = authResult?.apiKey ?? await resolveApiKey(options, baseUrl);
796
- if (!apiKey) {
797
- if (cleanupState) {
798
- await cleanupInstalledSkillArtifactsForHosts({
799
- projectRoot,
800
- hostNames: supportedHosts,
801
- priorState: cleanupState,
802
- scope,
803
- skillNames: bundledSkillNames,
804
- });
805
- cleanupState = null;
806
- }
807
- log.warn("Setup cancelled");
808
- return { exitCode: 0 };
809
- }
810
- }
811
-
812
829
  const setupResult = await runMcpSetupMode({
813
830
  selectedHosts: hosts,
814
831
  scope,
@@ -927,7 +944,7 @@ export async function runMcpModeCommand(options, deps = {}) {
927
944
  scope,
928
945
  skillNames: bundledSkillNames,
929
946
  });
930
- await installBundledSkillForHosts({
947
+ await installSkillForHostsFn({
931
948
  projectRoot,
932
949
  hostNames: supportedHosts,
933
950
  scope,
@@ -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
- remainingMs,
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
- const startTimeoutSignal = AbortSignal.timeout(remainingMs);
222
- let response;
223
- let payload;
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
- response = await fetch(startUrl, {
226
- method: "POST",
227
- headers: {
228
- "content-type": "application/json",
229
- "Idempotency-Key": `setup-device-start:${crypto.randomUUID()}`,
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 (startTimeoutSignal.aborted) {
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 fetch(pollUrl, {
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}:${Date.now()}`,
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 = Date.now();
599
- const remainingMs = timeoutMs - (Date.now() - startedAt);
600
- if (remainingMs <= 0) {
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
- remainingMs,
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 (Date.now() - startedAt < timeoutMs) {
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 };