@paradigma-inc/flywheel 0.2.29 → 0.2.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/cli.mjs +7 -1
  3. package/src/runtime/vendor/flywheel-cli-dist/commands/blobs-get.js +3 -1
  4. package/src/runtime/vendor/flywheel-cli-dist/commands/blobs-get.js.map +1 -1
  5. package/src/runtime/vendor/flywheel-cli-dist/commands/graph-operation-resources.js +33 -1
  6. package/src/runtime/vendor/flywheel-cli-dist/commands/graph-operation-resources.js.map +1 -1
  7. package/src/runtime/vendor/flywheel-cli-dist/commands/hosted-repository-snapshot.js +16 -0
  8. package/src/runtime/vendor/flywheel-cli-dist/commands/hosted-repository-snapshot.js.map +1 -1
  9. package/src/runtime/vendor/flywheel-cli-dist/commands/registry/hosted.js +1 -1
  10. package/src/runtime/vendor/flywheel-cli-dist/commands/registry/hosted.js.map +1 -1
  11. package/src/runtime/vendor/flywheel-cli-dist/commands/registry/repository.js +1 -1
  12. package/src/runtime/vendor/flywheel-cli-dist/commands/registry/repository.js.map +1 -1
  13. package/src/runtime/vendor/flywheel-cli-dist/commands/registry.js +4 -0
  14. package/src/runtime/vendor/flywheel-cli-dist/commands/registry.js.map +1 -1
  15. package/src/runtime/vendor/flywheel-cli-dist/generatedProductTelemetryContract.js +10 -2
  16. package/src/runtime/vendor/flywheel-cli-dist/generatedProductTelemetryContract.js.map +1 -1
  17. package/src/runtime/vendor/flywheel-cli-dist/local/store/apply.js +31 -0
  18. package/src/runtime/vendor/flywheel-cli-dist/local/store/apply.js.map +1 -1
  19. package/src/runtime/vendor/flywheel-cli-dist/local/store/pending.js +13 -0
  20. package/src/runtime/vendor/flywheel-cli-dist/local/store/pending.js.map +1 -1
  21. package/src/runtime/vendor/flywheel-cli-dist/local/store/snapshot/validate.js +20 -0
  22. package/src/runtime/vendor/flywheel-cli-dist/local/store/snapshot/validate.js.map +1 -1
  23. package/src/runtime/vendor/flywheel-cli-dist/local/store/snapshot.js +1 -1
  24. package/src/runtime/vendor/flywheel-cli-dist/local/store/snapshot.js.map +1 -1
  25. package/src/runtime/vendor/flywheel-cli-dist/local/store/sqlite/store.js +36 -3
  26. package/src/runtime/vendor/flywheel-cli-dist/local/store/sqlite/store.js.map +1 -1
  27. package/src/runtime/vendor/flywheel-cli-dist/main.js +28 -19
  28. package/src/runtime/vendor/flywheel-cli-dist/main.js.map +1 -1
  29. package/src/runtime/vendor/flywheel-cli-dist/output/format.js +1 -0
  30. package/src/runtime/vendor/flywheel-cli-dist/output/format.js.map +1 -1
  31. package/src/runtime/vendor/manifest.json +1 -1
  32. package/src/setup/modes/cli.mjs +4 -0
  33. package/src/setup/modes/mcp-command.mjs +88 -71
  34. package/src/setup-auth.mjs +264 -56
@@ -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 };