From 0f9ad7e51accaa9eaf8b66a4343e8556a671a383 Mon Sep 17 00:00:00 2001 From: David Murdoch <187813+davidmurdoch@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:42:28 -0400 Subject: [PATCH 1/3] fix: retry Foundry binary downloads --- eslint-suppressions.json | 5 - packages/foundryup/src/download.test.ts | 136 ++++++++++++++++++ packages/foundryup/src/download.ts | 167 ++++++++++++++++++++++- packages/foundryup/src/foundryup.test.ts | 8 +- packages/foundryup/src/index.ts | 12 +- 5 files changed, 314 insertions(+), 14 deletions(-) create mode 100644 packages/foundryup/src/download.test.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index d63a487fe33..1957912a9d2 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1004,11 +1004,6 @@ "count": 1 } }, - "packages/foundryup/src/download.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - } - }, "packages/foundryup/src/extract.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 4 diff --git a/packages/foundryup/src/download.test.ts b/packages/foundryup/src/download.test.ts new file mode 100644 index 00000000000..0eaa434fc76 --- /dev/null +++ b/packages/foundryup/src/download.test.ts @@ -0,0 +1,136 @@ +import { + calculateRetryDelay, + DEFAULT_DOWNLOAD_RETRY_OPTIONS, + DownloadHttpError, + isRetryableDownloadError, + retryDownload, +} from './download.js'; + +describe('calculateRetryDelay', () => { + it('applies equal jitter to exponential delays', () => { + const random = jest.fn().mockReturnValue(0.5); + + expect(calculateRetryDelay(1, { random })).toBe(750); + expect(calculateRetryDelay(2, { random })).toBe(1_500); + expect(calculateRetryDelay(3, { random })).toBe(3_000); + }); + + it('caps the exponential delay', () => { + expect( + calculateRetryDelay(10, { + initialDelayMs: 1_000, + maxDelayMs: 10_000, + random: () => 0.5, + }), + ).toBe(7_500); + }); +}); + +describe('isRetryableDownloadError', () => { + it.each([408, 425, 429, 500, 503])( + 'returns true for HTTP status %s', + (statusCode) => { + expect( + isRetryableDownloadError( + new DownloadHttpError( + new URL('https://example.com/archive.tar.gz'), + statusCode, + 'Request failed', + ), + ), + ).toBe(true); + }, + ); + + it.each([400, 401, 403, 404])( + 'returns false for HTTP status %s', + (statusCode) => { + expect( + isRetryableDownloadError( + new DownloadHttpError( + new URL('https://example.com/archive.tar.gz'), + statusCode, + 'Request failed', + ), + ), + ).toBe(false); + }, + ); + + it('returns true for transient network errors', () => { + const error = new Error('socket hang up') as NodeJS.ErrnoException; + error.code = 'ECONNRESET'; + + expect(isRetryableDownloadError(error)).toBe(true); + }); + + it('returns false for permanent errors', () => { + expect(isRetryableDownloadError(new Error('checksum mismatch'))).toBe( + false, + ); + }); +}); + +describe('retryDownload', () => { + it('retries transient failures with exponential backoff and jitter', async () => { + const transientError = new Error('socket hang up') as NodeJS.ErrnoException; + transientError.code = 'ECONNRESET'; + const operation = jest + .fn, []>() + .mockRejectedValueOnce(transientError) + .mockRejectedValueOnce(transientError) + .mockResolvedValue('downloaded'); + const sleep = jest.fn, [number]>(); + sleep.mockResolvedValue(undefined); + const onRetry = jest.fn(); + + const result = await retryDownload(operation, { + random: () => 0.5, + sleep, + onRetry, + }); + + expect(result).toBe('downloaded'); + expect(operation).toHaveBeenCalledTimes(3); + expect(sleep.mock.calls).toStrictEqual([[750], [1_500]]); + expect(onRetry).toHaveBeenNthCalledWith(1, { + attempt: 2, + maxAttempts: DEFAULT_DOWNLOAD_RETRY_OPTIONS.maxAttempts, + delayMs: 750, + error: transientError, + }); + expect(onRetry).toHaveBeenNthCalledWith(2, { + attempt: 3, + maxAttempts: DEFAULT_DOWNLOAD_RETRY_OPTIONS.maxAttempts, + delayMs: 1_500, + error: transientError, + }); + }); + + it('does not retry permanent failures', async () => { + const operation = jest.fn().mockRejectedValue(new Error('Invalid URL')); + const sleep = jest.fn, [number]>(); + + await expect(retryDownload(operation, { sleep })).rejects.toThrow( + 'Invalid URL', + ); + expect(operation).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('stops after the default maximum number of attempts', async () => { + const transientError = new Error('socket hang up') as NodeJS.ErrnoException; + transientError.code = 'ECONNRESET'; + const operation = jest.fn().mockRejectedValue(transientError); + const sleep = jest.fn, [number]>(); + sleep.mockResolvedValue(undefined); + + await expect( + retryDownload(operation, { random: () => 0.5, sleep }), + ).rejects.toBe(transientError); + expect(operation).toHaveBeenCalledTimes( + DEFAULT_DOWNLOAD_RETRY_OPTIONS.maxAttempts, + ); + expect(sleep.mock.calls).toStrictEqual([[750], [1_500], [3_000], [6_000]]); + }); +}); diff --git a/packages/foundryup/src/download.ts b/packages/foundryup/src/download.ts index d9dac1e9cbc..422362f3d0b 100644 --- a/packages/foundryup/src/download.ts +++ b/packages/foundryup/src/download.ts @@ -5,6 +5,167 @@ import { Stream } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import type { DownloadOptions } from './types.js'; +import { isCodedError } from './utils.js'; + +const RETRYABLE_HTTP_STATUS_CODES = new Set([408, 425, 429]); +const RETRYABLE_ERROR_CODES = new Set([ + 'EAI_AGAIN', + 'ECONNREFUSED', + 'ECONNRESET', + 'EHOSTDOWN', + 'EHOSTUNREACH', + 'ENETDOWN', + 'ENETRESET', + 'ENETUNREACH', + 'EPIPE', + 'ERR_STREAM_PREMATURE_CLOSE', + 'ETIMEDOUT', + 'Z_BUF_ERROR', +]); + +export const DEFAULT_DOWNLOAD_RETRY_OPTIONS = { + maxAttempts: 5, + initialDelayMs: 1_000, + maxDelayMs: 30_000, +} as const; + +export type DownloadRetryEvent = { + attempt: number; + maxAttempts: number; + delayMs: number; + error: unknown; +}; + +export type DownloadRetryOptions = { + maxAttempts?: number; + initialDelayMs?: number; + maxDelayMs?: number; + random?: () => number; + sleep?: (delayMs: number) => Promise; + onRetry?: (event: DownloadRetryEvent) => void; +}; + +/** + * An unsuccessful HTTP response received while downloading an archive. + */ +export class DownloadHttpError extends Error { + readonly statusCode: number | undefined; + + constructor( + url: URL, + statusCode: number | undefined, + statusMessage: string | undefined, + ) { + super( + `Request to ${url} failed. Status Code: ${statusCode} - ${statusMessage}`, + ); + this.name = 'DownloadHttpError'; + this.statusCode = statusCode; + } +} + +/** + * Determines whether a failed download can reasonably succeed when retried. + * + * @param error - The download error. + * @returns Whether the error is transient. + */ +export function isRetryableDownloadError(error: unknown): boolean { + if (error instanceof DownloadHttpError) { + const { statusCode } = error; + return ( + statusCode !== undefined && + (RETRYABLE_HTTP_STATUS_CODES.has(statusCode) || + (statusCode >= 500 && statusCode <= 599)) + ); + } + + return isCodedError(error) && RETRYABLE_ERROR_CODES.has(error.code); +} + +/** + * Calculates an exponential retry delay with equal jitter. + * + * Equal jitter keeps at least half of the exponential delay while spreading + * concurrent retry attempts across the remaining half. + * + * @param failedAttempt - The one-based attempt number that failed. + * @param options - Backoff and randomness options. + * @param options.initialDelayMs - The delay before exponential growth. + * @param options.maxDelayMs - The maximum delay before jitter. + * @param options.random - The source of randomness used for jitter. + * @returns The delay before the next attempt, in milliseconds. + */ +export function calculateRetryDelay( + failedAttempt: number, + { + initialDelayMs = DEFAULT_DOWNLOAD_RETRY_OPTIONS.initialDelayMs, + maxDelayMs = DEFAULT_DOWNLOAD_RETRY_OPTIONS.maxDelayMs, + random = Math.random, + }: Pick< + DownloadRetryOptions, + 'initialDelayMs' | 'maxDelayMs' | 'random' + > = {}, +): number { + const exponentialDelay = Math.min( + maxDelayMs, + initialDelayMs * 2 ** (failedAttempt - 1), + ); + const minimumDelay = exponentialDelay / 2; + return Math.round(minimumDelay + random() * minimumDelay); +} + +/** + * Retries a download operation after transient failures. + * + * @param operation - The complete download operation to retry. + * @param options - Retry, backoff, and observability options. + * @param options.maxAttempts - The total number of attempts, including the first. + * @param options.initialDelayMs - The delay before exponential growth. + * @param options.maxDelayMs - The maximum delay before jitter. + * @param options.random - The source of randomness used for jitter. + * @param options.sleep - The function used to wait between attempts. + * @param options.onRetry - A callback invoked before each retry. + * @returns The result of the successful operation. + * @throws The first permanent error or the final transient error. + */ +export async function retryDownload( + operation: () => Promise, + { + maxAttempts = DEFAULT_DOWNLOAD_RETRY_OPTIONS.maxAttempts, + initialDelayMs = DEFAULT_DOWNLOAD_RETRY_OPTIONS.initialDelayMs, + maxDelayMs = DEFAULT_DOWNLOAD_RETRY_OPTIONS.maxDelayMs, + random = Math.random, + sleep = async (delayMs: number): Promise => + await new Promise((resolve) => setTimeout(resolve, delayMs)), + onRetry, + }: DownloadRetryOptions = {}, +): Promise { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + return await operation(); + } catch (error) { + if (attempt === maxAttempts || !isRetryableDownloadError(error)) { + throw error; + } + + const delayMs = calculateRetryDelay(attempt, { + initialDelayMs, + maxDelayMs, + random, + }); + onRetry?.({ + attempt: attempt + 1, + maxAttempts, + delayMs, + error, + }); + await sleep(delayMs); + } + } + + throw new Error('Download retry loop completed unexpectedly'); +} /** * A PassThrough stream that emits a 'response' event when the HTTP(S) response is available. @@ -35,7 +196,7 @@ export function startDownload( url: URL, options: DownloadOptions = {}, redirects: number = 0, -) { +): DownloadStream { const MAX_REDIRECTS = options.maxRedirects ?? 5; const request = url.protocol === 'http:' ? httpRequest : httpsRequest; const stream = new DownloadStream(); @@ -72,9 +233,7 @@ export function startDownload( else if (!statusCode || statusCode < 200 || statusCode >= 300) { stream.emit( 'error', - new Error( - `Request to ${url} failed. Status Code: ${statusCode} - ${statusMessage}`, - ), + new DownloadHttpError(url, statusCode, statusMessage), ); response.destroy(); } else { diff --git a/packages/foundryup/src/foundryup.test.ts b/packages/foundryup/src/foundryup.test.ts index 113709505cf..829357f0f4b 100644 --- a/packages/foundryup/src/foundryup.test.ts +++ b/packages/foundryup/src/foundryup.test.ts @@ -217,15 +217,15 @@ describe('foundryup', () => { cleanAll(); }); - it('handles download errors gracefully', async () => { + it('propagates permanent download errors', async () => { (fs.opendir as jest.Mock).mockRejectedValue({ code: 'ENOENT' }); cleanAll(); nock('https://example.com') .head('/binaries.zip') - .reply(500, 'Internal Server Error') + .reply(400, 'Bad Request') .get('/binaries.zip') - .reply(500, 'Internal Server Error'); + .reply(400, 'Bad Request'); const result = checkAndDownloadBinaries( mockUrl, @@ -235,7 +235,7 @@ describe('foundryup', () => { Architecture.Amd64, ); await expect(result).rejects.toThrow( - 'Request to https://example.com/binaries.zip failed. Status Code: 500 - null', + 'Request to https://example.com/binaries.zip failed. Status Code: 400 - null', ); }); }); diff --git a/packages/foundryup/src/index.ts b/packages/foundryup/src/index.ts index b483a61987e..3c235d4ee47 100755 --- a/packages/foundryup/src/index.ts +++ b/packages/foundryup/src/index.ts @@ -16,6 +16,7 @@ import { dirname, join, relative } from 'node:path'; import { cwd, exit } from 'node:process'; import { parse as parseYaml } from 'yaml'; +import { retryDownload } from './download.js'; import { extractFrom } from './extract.js'; import { parseArgs, printBanner } from './options.js'; import type { Checksums, Architecture, Binary } from './types.js'; @@ -108,7 +109,16 @@ export async function checkAndDownloadBinaries( say(`installing from ${url.toString()}`); // directory doesn't exist, download and extract const platformChecksums = transformChecksums(checksums, platform, arch); - await extractFrom(url, binaries, cachePath, platformChecksums); + await retryDownload( + () => extractFrom(url, binaries, cachePath, platformChecksums), + { + onRetry: ({ attempt, maxAttempts, delayMs }) => { + say( + `download failed; retrying in ${delayMs}ms (attempt ${attempt}/${maxAttempts})`, + ); + }, + }, + ); downloadedBinaries = await opendir(cachePath); } else { throw e; From 09aafb4c3593306703f505a30a64a387bbbd2aa7 Mon Sep 17 00:00:00 2001 From: David Murdoch <187813+davidmurdoch@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:43:27 -0400 Subject: [PATCH 2/3] docs: update foundryup changelog --- packages/foundryup/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/foundryup/CHANGELOG.md b/packages/foundryup/CHANGELOG.md index f6ce1417dc3..64d3ce4e72a 100644 --- a/packages/foundryup/CHANGELOG.md +++ b/packages/foundryup/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Retry transient Foundry archive download failures with exponential backoff and equal jitter, preventing short-lived network, rate-limit, and server errors from immediately aborting installation ([#9854](https://github.com/MetaMask/core/pull/9854)) + ## [1.0.1] ### Fixed From cb03e77f6961c7f5deb78d8aaec65fa880e8d496 Mon Sep 17 00:00:00 2001 From: David Murdoch <187813+davidmurdoch@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:11:44 -0400 Subject: [PATCH 3/3] feat: configure foundryup download retries --- packages/foundryup/CHANGELOG.md | 1 + packages/foundryup/README.md | 13 ++++ packages/foundryup/src/download.ts | 5 +- packages/foundryup/src/foundryup.test.ts | 84 ++++++++++++++++++++++++ packages/foundryup/src/index.ts | 12 ++++ packages/foundryup/src/options.ts | 45 ++++++++++++- packages/foundryup/src/types.ts | 9 ++- 7 files changed, 166 insertions(+), 3 deletions(-) diff --git a/packages/foundryup/CHANGELOG.md b/packages/foundryup/CHANGELOG.md index 64d3ce4e72a..14306f8c3ad 100644 --- a/packages/foundryup/CHANGELOG.md +++ b/packages/foundryup/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Retry transient Foundry archive download failures with exponential backoff and equal jitter, preventing short-lived network, rate-limit, and server errors from immediately aborting installation ([#9854](https://github.com/MetaMask/core/pull/9854)) + - Configure the retry policy with the `--max-attempts`, `--initial-retry-delay-ms`, and `--max-retry-delay-ms` CLI options or their equivalent `FOUNDRYUP_*` environment variables ## [1.0.1] diff --git a/packages/foundryup/README.md b/packages/foundryup/README.md index 2cb0912a63d..2676c44c1c6 100644 --- a/packages/foundryup/README.md +++ b/packages/foundryup/README.md @@ -18,6 +18,19 @@ This will install the latest version of Foundry things by default. Try `yarn bin mm-foundryup --help` for more options. +### Retry configuration + +Foundry archive downloads retry transient failures with exponential backoff and equal jitter. The retry policy can be +configured with CLI flags or equivalent environment variables: + +| CLI flag | Environment variable | Default | Description | +| -------------------------- | ---------------------------------- | ------: | ---------------------------------------------- | +| `--max-attempts` | `FOUNDRYUP_MAX_ATTEMPTS` | 5 | Total download attempts, including the first | +| `--initial-retry-delay-ms` | `FOUNDRYUP_INITIAL_RETRY_DELAY_MS` | 1,000 | Initial delay before exponential growth, in ms | +| `--max-retry-delay-ms` | `FOUNDRYUP_MAX_RETRY_DELAY_MS` | 30,000 | Maximum delay before applying jitter, in ms | + +Set `--max-attempts 1` to disable retries. + Once you have the binaries installed, you have to figure out how to get to them. Probably best to just add each as a `package.json` script: diff --git a/packages/foundryup/src/download.ts b/packages/foundryup/src/download.ts index 422362f3d0b..5ca32b142ea 100644 --- a/packages/foundryup/src/download.ts +++ b/packages/foundryup/src/download.ts @@ -36,10 +36,13 @@ export type DownloadRetryEvent = { error: unknown; }; -export type DownloadRetryOptions = { +export type DownloadRetryConfiguration = { maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; +}; + +export type DownloadRetryOptions = DownloadRetryConfiguration & { random?: () => number; sleep?: (delayMs: number) => Promise; onRetry?: (event: DownloadRetryEvent) => void; diff --git a/packages/foundryup/src/foundryup.test.ts b/packages/foundryup/src/foundryup.test.ts index 829357f0f4b..2e4c6fc109e 100644 --- a/packages/foundryup/src/foundryup.test.ts +++ b/packages/foundryup/src/foundryup.test.ts @@ -5,6 +5,7 @@ import nock, { cleanAll } from 'nock'; import { join, relative } from 'path'; import { parse as parseYaml } from 'yaml'; +import { DEFAULT_DOWNLOAD_RETRY_OPTIONS } from './download.js'; import { checkAndDownloadBinaries, getBinaryArchiveUrl, @@ -453,6 +454,9 @@ describe('foundryup', () => { version: { version: string; tag: string }; arch: string; platform: string; + maxAttempts: number; + initialRetryDelayMs: number; + maxRetryDelayMs: number; checksums?: Checksums; }; }; @@ -556,5 +560,85 @@ describe('foundryup', () => { }); }); }); + + describe('retry options', () => { + const environmentVariables = [ + 'FOUNDRYUP_MAX_ATTEMPTS', + 'FOUNDRYUP_INITIAL_RETRY_DELAY_MS', + 'FOUNDRYUP_MAX_RETRY_DELAY_MS', + ] as const; + const environment = globalThis.process.env; + const originalEnvironment = Object.fromEntries( + environmentVariables.map((name) => [name, environment[name]]), + ); + + beforeEach(() => { + for (const name of environmentVariables) { + delete environment[name]; + } + }); + + afterEach(() => { + for (const name of environmentVariables) { + const originalValue = originalEnvironment[name]; + if (originalValue === undefined) { + delete environment[name]; + } else { + environment[name] = originalValue; + } + } + }); + + it('uses the default retry configuration', () => { + const result = actualParseArgs([]); + + expect(result.options).toMatchObject({ + maxAttempts: DEFAULT_DOWNLOAD_RETRY_OPTIONS.maxAttempts, + initialRetryDelayMs: DEFAULT_DOWNLOAD_RETRY_OPTIONS.initialDelayMs, + maxRetryDelayMs: DEFAULT_DOWNLOAD_RETRY_OPTIONS.maxDelayMs, + }); + }); + + it('parses retry configuration from CLI flags', () => { + const result = actualParseArgs([ + '--max-attempts', + '3', + '--initial-retry-delay-ms', + '250', + '--max-retry-delay-ms', + '5000', + ]); + + expect(result.options).toMatchObject({ + maxAttempts: 3, + initialRetryDelayMs: 250, + maxRetryDelayMs: 5_000, + }); + }); + + it('parses retry configuration from environment variables', () => { + environment.FOUNDRYUP_MAX_ATTEMPTS = '4'; + environment.FOUNDRYUP_INITIAL_RETRY_DELAY_MS = '500'; + environment.FOUNDRYUP_MAX_RETRY_DELAY_MS = '10000'; + + const result = actualParseArgs([]); + + expect(result.options).toMatchObject({ + maxAttempts: 4, + initialRetryDelayMs: 500, + maxRetryDelayMs: 10_000, + }); + }); + + it.each([ + ['--max-attempts', '0'], + ['--initial-retry-delay-ms', '-1'], + ['--max-retry-delay-ms', '1.5'], + ])('rejects an invalid value for %s', (option, value) => { + expect(() => actualParseArgs([option, value])).toThrow( + `${option} must be a positive integer`, + ); + }); + }); }); }); diff --git a/packages/foundryup/src/index.ts b/packages/foundryup/src/index.ts index 3c235d4ee47..9c03305e0ed 100755 --- a/packages/foundryup/src/index.ts +++ b/packages/foundryup/src/index.ts @@ -17,6 +17,7 @@ import { cwd, exit } from 'node:process'; import { parse as parseYaml } from 'yaml'; import { retryDownload } from './download.js'; +import type { DownloadRetryConfiguration } from './download.js'; import { extractFrom } from './extract.js'; import { parseArgs, printBanner } from './options.js'; import type { Checksums, Architecture, Binary } from './types.js'; @@ -88,6 +89,7 @@ export function getBinaryArchiveUrl( * @param platform - The target platform * @param arch - The target architecture * @param checksums - Optional checksums for verification + * @param retryOptions - Optional download retry configuration * @returns A promise that resolves to the directory containing the downloaded binaries */ export async function checkAndDownloadBinaries( @@ -97,6 +99,7 @@ export async function checkAndDownloadBinaries( platform: Platform, arch: Architecture, checksums?: Checksums, + retryOptions: DownloadRetryConfiguration = {}, ): Promise { let downloadedBinaries: Dir; try { @@ -112,6 +115,7 @@ export async function checkAndDownloadBinaries( await retryDownload( () => extractFrom(url, binaries, cachePath, platformChecksums), { + ...retryOptions, onRetry: ({ attempt, maxAttempts, delayMs }) => { say( `download failed; retrying in ${delayMs}ms (attempt ${attempt}/${maxAttempts})`, @@ -198,6 +202,9 @@ export async function downloadAndInstallFoundryBinaries(): Promise { platform, binaries, checksums, + maxAttempts, + initialRetryDelayMs, + maxRetryDelayMs, } = parsedArgs.options; printBanner(); @@ -226,6 +233,11 @@ export async function downloadAndInstallFoundryBinaries(): Promise { platform, arch, checksums, + { + maxAttempts, + initialDelayMs: initialRetryDelayMs, + maxDelayMs: maxRetryDelayMs, + }, ); await installBinaries(downloadedBinaries, BIN_DIR, cachePath); diff --git a/packages/foundryup/src/options.ts b/packages/foundryup/src/options.ts index 97a95fd3816..37a6fdca069 100644 --- a/packages/foundryup/src/options.ts +++ b/packages/foundryup/src/options.ts @@ -2,6 +2,7 @@ import { platform } from 'node:os'; import { argv, stdout } from 'node:process'; import yargs from 'yargs/yargs'; +import { DEFAULT_DOWNLOAD_RETRY_OPTIONS } from './download.js'; import { Architecture, Binary, Platform } from './types.js'; import type { Checksums, @@ -22,6 +23,21 @@ function isVersionString(value: string): value is `v${string}` { return /^v\d/u.test(value); } +/** + * Validates a CLI option that must be a positive integer. + * + * @param value - The parsed option value. + * @param optionName - The option name used in the error message. + * @returns The validated value. + * @throws If the value is not a positive integer. + */ +function positiveInteger(value: unknown, optionName: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${optionName} must be a positive integer`); + } + return value; +} + /** * Prints the Foundry banner to the console. */ @@ -55,6 +71,9 @@ export function parseArgs(args: string[] = argv.slice(2)) { const { $0, _, ...parsed } = yargs() // Ensure unrecognized commands/options are reported as errors. .strict() + .fail((message, error): never => { + throw error ?? new Error(message); + }) // disable yargs's version, as it doesn't make sense here .version(false) // use the scriptName in `--help` output @@ -69,7 +88,15 @@ export function parseArgs(args: string[] = argv.slice(2)) { // via environment variables prefixed with `FOUNDRYUP_` .env('FOUNDRYUP') .command(['$0', 'install'], 'Install foundry binaries', (builder) => { - builder.options(getOptions()).pkgConf('foundryup'); + builder + .options(getOptions()) + .check(({ maxAttempts, initialRetryDelayMs, maxRetryDelayMs }) => { + positiveInteger(maxAttempts, '--max-attempts'); + positiveInteger(initialRetryDelayMs, '--initial-retry-delay-ms'); + positiveInteger(maxRetryDelayMs, '--max-retry-delay-ms'); + return true; + }) + .pkgConf('foundryup'); }) .command('cache', '', (builder) => { builder.command('clean', 'Remove the shared cache files').demandCommand(); @@ -149,6 +176,22 @@ function getOptions( throw new Error('Invalid version'); }, }, + 'max-attempts': { + description: + 'Specify the total number of download attempts, including the first', + type: 'number' as const, + default: DEFAULT_DOWNLOAD_RETRY_OPTIONS.maxAttempts, + }, + 'initial-retry-delay-ms': { + description: 'Specify the initial retry delay in milliseconds', + type: 'number' as const, + default: DEFAULT_DOWNLOAD_RETRY_OPTIONS.initialDelayMs, + }, + 'max-retry-delay-ms': { + description: 'Specify the maximum retry delay in milliseconds', + type: 'number' as const, + default: DEFAULT_DOWNLOAD_RETRY_OPTIONS.maxDelayMs, + }, arch: { alias: 'a', description: 'Specify the architecture', diff --git a/packages/foundryup/src/types.ts b/packages/foundryup/src/types.ts index 8428b214eaa..a50b52fd6ef 100644 --- a/packages/foundryup/src/types.ts +++ b/packages/foundryup/src/types.ts @@ -15,6 +15,11 @@ type LastInUnion = ? Last : never; +type KebabToCamelCase = + Key extends `${infer Head}-${infer Tail}` + ? `${Head}${Capitalize>}` + : Key; + type UnionToTuple> = [U] extends [ never, ] @@ -90,7 +95,9 @@ export type PlatformArchChecksums = { * Given a map of raw yargs options config, returns a map of inferred types. */ export type ParsedOptions = { - [key in keyof O]: InferredOptionTypes[key]; + [key in keyof O as key extends string + ? KebabToCamelCase + : key]: InferredOptionTypes[key]; }; export type DownloadOptions = {