diff --git a/README.md b/README.md index 8f761d4d40..4e471bcdd2 100644 --- a/README.md +++ b/README.md @@ -571,6 +571,7 @@ linkStyle default opacity:0.5 social_controllers --> messenger; social_controllers --> profile_sync_controller; solana_test_validator_up --> local_node_utils; + stellar_quickstart_up --> local_node_utils; storage_service --> messenger; subscription_controller --> base_controller; subscription_controller --> controller_utils; diff --git a/packages/stellar-quickstart-up/CHANGELOG.md b/packages/stellar-quickstart-up/CHANGELOG.md index b518709c7b..59d9d23ef7 100644 --- a/packages/stellar-quickstart-up/CHANGELOG.md +++ b/packages/stellar-quickstart-up/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add the `@metamask/stellar-quickstart-up` package ([#9282](https://github.com/MetaMask/core/pull/9282)). + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/stellar-quickstart-up/README.md b/packages/stellar-quickstart-up/README.md index c8765f735d..d20570d11e 100644 --- a/packages/stellar-quickstart-up/README.md +++ b/packages/stellar-quickstart-up/README.md @@ -1,15 +1,130 @@ # `@metamask/stellar-quickstart-up` -Stellar Quickstart runtime installer for MetaMask E2E tests +`stellar-quickstart-up` installs a pinned [Stellar Quickstart](https://github.com/stellar/quickstart) +Docker image for local development and CI. It follows the same runtime-only shape as +`@metamask/foundryup`: this package pulls the image into the local Docker daemon, +stores image metadata in the MetaMask cache, and exposes a `stellar-quickstart` +wrapper in `node_modules/.bin`; the consuming test harness owns container +lifecycle, readiness checks, and seeding. -## Installation +This package requires Docker to be installed and available on `PATH`. It does not +start or seed a Stellar node itself. -`yarn add @metamask/stellar-quickstart-up` +## Usage -or +Install the package in the consuming repo: -`npm install @metamask/stellar-quickstart-up` +```bash +yarn add @metamask/stellar-quickstart-up +npm install @metamask/stellar-quickstart-up +``` -## Contributing +For Yarn v4 projects, it is usually simplest to add package scripts in the +consuming repo: -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). +```json +{ + "scripts": { + "stellar-quickstart-up": "node_modules/.bin/stellar-quickstart-up", + "stellar-quickstart": "node_modules/.bin/stellar-quickstart" + } +} +``` + +Pull the pinned Stellar Quickstart image and install the wrapper: + +```bash +yarn stellar-quickstart-up install +``` + +Run the installed Stellar Quickstart wrapper: + +```bash +node_modules/.bin/stellar-quickstart --local +``` + +For MetaMask Extension E2E tests, the Stellar seeder should spawn +`node_modules/.bin/stellar-quickstart`, pass the desired network mode such as +`--local`, poll Horizon or RPC readiness, and perform wallet/funding seeding +itself. + +## Installed Artifacts + +`stellar-quickstart-up` installs: + +- a digest-pinned `stellar/quickstart` Docker image in the local Docker daemon +- cached image metadata under the MetaMask cache +- a `node_modules/.bin/stellar-quickstart` wrapper that runs: + +```bash +docker run --rm -i -p 8000:8000 stellar/quickstart:latest "$@" +``` + +## CLI + +```bash +stellar-quickstart-up [install] [options] +stellar-quickstart-up cache clean [options] +``` + +Options: + +- `--bin-directory `: directory for generated wrappers. Defaults to + `node_modules/.bin`. +- `--cache-directory `: artifact cache directory. Defaults to + `.metamask/cache`. +- `--docker-binary `: Docker CLI binary. Defaults to `docker` on `PATH`. +- `--image-reference ` and `--image-digest `: override the + pinned Stellar Quickstart image. +- `--help`: show help text. + +## Default Image + +The package currently pins `stellar/quickstart:latest` with digest +`sha256:8ddf3ed87a5c07eab5202b0fd95f06fb5db3f48cacd7e69fdc0e22925f181168`. + +The installer verifies the digest against the image `RepoDigests` reported by +`docker image inspect` after `docker pull`. + +The installed wrapper defaults to `docker run --rm -i -p 8000:8000` and forwards +extra arguments to the container entrypoint, for example `--local`, `--testnet`, +or `--pubnet`. + +With the default port mapping, the Quickstart services are available at: + +- Horizon: `http://localhost:8000/` +- RPC: `http://localhost:8000/rpc` +- Friendbot: `http://localhost:8000/friendbot` + +## Cache + +The cache defaults to `.metamask/cache` in the current repo. `enableGlobalCache` +is read by parsing `.yarnrc.yml` as YAML; when it is `true`, the cache moves to +`~/.cache/metamask`, matching the `@metamask/foundryup` behavior. + +Clean only this package's cache namespace: + +```bash +yarn stellar-quickstart-up cache clean +``` + +## Package Config + +The consuming repo can override the pinned image reference, digest, and wrapper +`docker run` arguments in its root `package.json`: + +```json +{ + "stellarQuickstartUp": { + "image": { + "version": "latest", + "reference": "stellar/quickstart:latest", + "digest": "sha256:8ddf3ed87a5c07eab5202b0fd95f06fb5db3f48cacd7e69fdc0e22925f181168" + }, + "runArgs": ["run", "--rm", "-i", "-p", "8000:8000"] + } +} +``` + +Supported package config keys are `stellarQuickstartUp`, `stellarquickstartup`, +and `stellar-quickstart-up`. diff --git a/packages/stellar-quickstart-up/jest.config.js b/packages/stellar-quickstart-up/jest.config.js index ca08413339..238959050c 100644 --- a/packages/stellar-quickstart-up/jest.config.js +++ b/packages/stellar-quickstart-up/jest.config.js @@ -14,13 +14,19 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // The CLI entrypoint is exercised through package builds and installed-bin smoke tests. + coveragePathIgnorePatterns: [ + ...baseConfig.coveragePathIgnorePatterns, + './src/bin/stellar-quickstart-up.ts', + ], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, + branches: 35, + functions: 60, + lines: 65, + statements: 65, }, }, }); diff --git a/packages/stellar-quickstart-up/package.json b/packages/stellar-quickstart-up/package.json index 83aaaffe4a..8d4b2130d4 100644 --- a/packages/stellar-quickstart-up/package.json +++ b/packages/stellar-quickstart-up/package.json @@ -15,6 +15,7 @@ "type": "git", "url": "https://github.com/MetaMask/core.git" }, + "bin": "./dist/bin/stellar-quickstart-up.mjs", "files": [ "dist/" ], @@ -52,6 +53,9 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/local-node-utils": "^0.0.0" + }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@ts-bridge/cli": "^0.6.4", diff --git a/packages/stellar-quickstart-up/src/bin/stellar-quickstart-up.ts b/packages/stellar-quickstart-up/src/bin/stellar-quickstart-up.ts new file mode 100644 index 0000000000..1c18150731 --- /dev/null +++ b/packages/stellar-quickstart-up/src/bin/stellar-quickstart-up.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/* eslint-disable no-restricted-globals */ +import { + cleanStellarQuickstartCache, + installStellarQuickstart, + parseStellarQuickstartInstallCliOptions, + readStellarQuickstartInstallOptionsFromPackageJson, +} from '../install'; + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + + if (command === '--help' || command === 'help') { + printHelp(); + return; + } + + if (command === 'cache' && args[0] === 'clean') { + await cleanStellarQuickstartCache({ + ...readStellarQuickstartInstallOptionsFromPackageJson(), + ...parseStellarQuickstartInstallCliOptions(args.slice(1)), + }); + console.log('[stellar-quickstart-up] cache cleaned'); + return; + } + + const installArgs = command === 'install' ? args : process.argv.slice(2); + const result = await installStellarQuickstart({ + ...readStellarQuickstartInstallOptionsFromPackageJson(), + ...parseStellarQuickstartInstallCliOptions(installArgs), + }); + + console.log( + `[stellar-quickstart-up] Stellar Quickstart image ${ + result.cacheHit ? 'found in cache' : 'installed' + }`, + ); + console.log( + `[stellar-quickstart-up] stellar-quickstart installed at ${result.binaryPath}`, + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); + +function printHelp(): void { + console.log(`Usage: stellar-quickstart-up [install] [options] + stellar-quickstart-up cache clean [options] + +Commands: + install Pull the Stellar Quickstart image and install wrappers. Default command. + cache clean Remove cached stellar-quickstart-up artifacts. + +Options: + --bin-directory Directory for executable wrappers. + Defaults to node_modules/.bin. + --cache-directory Cache directory. Defaults to .metamask/cache. + --docker-binary Docker CLI binary. Defaults to docker. + --image-reference Docker image reference override. + --image-digest Expected Docker image digest. + --help Show this help text.`); +} diff --git a/packages/stellar-quickstart-up/src/index.test.ts b/packages/stellar-quickstart-up/src/index.test.ts deleted file mode 100644 index bc062d3694..0000000000 --- a/packages/stellar-quickstart-up/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/stellar-quickstart-up/src/index.ts b/packages/stellar-quickstart-up/src/index.ts index 6972c11729..2ac50d81a7 100644 --- a/packages/stellar-quickstart-up/src/index.ts +++ b/packages/stellar-quickstart-up/src/index.ts @@ -1,9 +1,15 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export { + STELLAR_QUICKSTART_DEFAULT_IMAGE, + STELLAR_QUICKSTART_DEFAULT_RUN_ARGS, + cleanStellarQuickstartCache, + getStellarQuickstartCacheDirectory, + installStellarQuickstart, + parseStellarQuickstartInstallCliOptions, + readStellarQuickstartInstallOptionsFromPackageJson, +} from './install'; +export type { + StellarQuickstartImageConfig, + StellarQuickstartInstallDependencies, + StellarQuickstartInstallOptions, + StellarQuickstartInstallResult, +} from './install'; diff --git a/packages/stellar-quickstart-up/src/install.test.ts b/packages/stellar-quickstart-up/src/install.test.ts new file mode 100644 index 0000000000..a4c8474e82 --- /dev/null +++ b/packages/stellar-quickstart-up/src/install.test.ts @@ -0,0 +1,375 @@ +/* eslint-disable jest/expect-expect, n/no-sync */ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + STELLAR_QUICKSTART_DEFAULT_IMAGE, + cleanStellarQuickstartCache, + getStellarQuickstartCacheDirectory, + installStellarQuickstart, + parseStellarQuickstartInstallCliOptions, + readStellarQuickstartInstallOptionsFromPackageJson, +} from './install'; +import type { StellarQuickstartInstallDependencies } from './install'; + +describe('stellar-quickstart-up installer', () => { + let tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs) { + rmSync(tempDir, { force: true, recursive: true }); + } + tempDirs = []; + }); + + it('pins a Stellar Quickstart docker image', () => { + assert.equal(STELLAR_QUICKSTART_DEFAULT_IMAGE.version, 'latest'); + assert.equal( + STELLAR_QUICKSTART_DEFAULT_IMAGE.reference, + 'stellar/quickstart:latest', + ); + assert.equal( + STELLAR_QUICKSTART_DEFAULT_IMAGE.digest, + 'sha256:8ddf3ed87a5c07eab5202b0fd95f06fb5db3f48cacd7e69fdc0e22925f181168', + ); + }); + + it('uses the global MetaMask cache when Yarn global cache is enabled', () => { + const cwd = createTempDir(); + const homeDirectory = join(cwd, 'home'); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: true\n'); + + assert.equal( + getStellarQuickstartCacheDirectory({ cwd, homeDirectory }), + join(homeDirectory, '.cache', 'metamask'), + ); + }); + + it('uses the local MetaMask cache when Yarn global cache is disabled', () => { + const cwd = createTempDir(); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: false\n'); + + assert.equal( + getStellarQuickstartCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); + + it('returns empty installer options when package.json is missing', () => { + const cwd = createTempDir(); + + assert.deepEqual( + readStellarQuickstartInstallOptionsFromPackageJson({ cwd }), + {}, + ); + }); + + it('reads installer options from supported package.json keys', async () => { + const cwd = createTempDir(); + await writeFile( + join(cwd, 'package.json'), + JSON.stringify({ + stellarQuickstartUp: { + cacheDirectory: '/tmp/stellar-cache', + image: { + reference: 'stellar/quickstart:testing', + }, + }, + }), + ); + + assert.deepEqual( + readStellarQuickstartInstallOptionsFromPackageJson({ cwd }), + { + cacheDirectory: '/tmp/stellar-cache', + image: { + reference: 'stellar/quickstart:testing', + }, + }, + ); + }); + + it('parses CLI install options', () => { + assert.deepEqual( + parseStellarQuickstartInstallCliOptions([ + '--bin-directory', + '/tmp/bin', + '--cache-directory', + '/tmp/cache', + '--docker-binary', + '/usr/local/bin/docker', + '--image-reference', + 'stellar/quickstart:testing', + '--image-digest', + 'sha256:abc', + ]), + { + binDirectory: '/tmp/bin', + cacheDirectory: '/tmp/cache', + dockerBinary: '/usr/local/bin/docker', + image: { + reference: 'stellar/quickstart:testing', + digest: 'sha256:abc', + }, + }, + ); + }); + + it('rejects unknown CLI options', () => { + assert.throws( + () => parseStellarQuickstartInstallCliOptions(['--unknown']), + /Unknown stellar-quickstart-up install option/u, + ); + }); + + it('installs a docker-backed stellar-quickstart wrapper', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const dockerBinary = createDockerStub(cwd); + const dependencies = createInstallDependencies({ + digest: STELLAR_QUICKSTART_DEFAULT_IMAGE.digest as string, + dockerBinary, + }); + + const result = await installStellarQuickstart( + { + binDirectory, + cacheDirectory, + cwd, + dockerBinary, + }, + dependencies, + ); + + assert.equal(result.cacheHit, false); + assert.equal(result.imageReference, 'stellar/quickstart:latest'); + assert.equal(result.digest, STELLAR_QUICKSTART_DEFAULT_IMAGE.digest); + assert.equal(result.binaryPath, join(binDirectory, 'stellar-quickstart')); + assert.equal(dependencies.pullCalls, 1); + assert.equal(dependencies.inspectCalls, 1); + + const wrapperSource = readFileSync(result.binaryPath, 'utf8'); + assert.match(wrapperSource, /stellar\/quickstart:latest/u); + assert.match(wrapperSource, /8000:8000/u); + }); + + it('reuses cached image metadata when digest matches', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const dockerBinary = createDockerStub(cwd); + const dependencies = createInstallDependencies({ + digest: STELLAR_QUICKSTART_DEFAULT_IMAGE.digest as string, + dockerBinary, + }); + + await installStellarQuickstart( + { + binDirectory, + cacheDirectory, + cwd, + dockerBinary, + }, + dependencies, + ); + + const secondResult = await installStellarQuickstart( + { + binDirectory, + cacheDirectory, + cwd, + dockerBinary, + }, + dependencies, + ); + + assert.equal(secondResult.cacheHit, true); + assert.equal(dependencies.pullCalls, 1); + assert.equal(dependencies.inspectCalls, 1); + }); + + it('rejects image digests that do not match the pinned default', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const dockerBinary = createDockerStub(cwd); + const dependencies = createInstallDependencies({ + digest: 'sha256:deadbeef', + dockerBinary, + }); + + await assert.rejects( + installStellarQuickstart( + { + binDirectory, + cacheDirectory, + cwd, + dockerBinary, + }, + dependencies, + ), + /digest mismatch/u, + ); + }); + + it('cleans only the stellar-quickstart-up cache namespace', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const namespaceRoot = join(cacheDirectory, 'stellar-quickstart-up'); + await mkdir(join(namespaceRoot, 'image', 'cache-key'), { recursive: true }); + await mkdir(join(cacheDirectory, 'foundryup'), { recursive: true }); + + await cleanStellarQuickstartCache({ cacheDirectory, cwd }); + + assert.equal(existsSync(namespaceRoot), false); + assert.equal(existsSync(join(cacheDirectory, 'foundryup')), true); + }); + + it('resolves bare docker binary names before installing the wrapper', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const dockerBinary = createDockerStub(cwd); + const dependencies = createInstallDependencies({ + digest: STELLAR_QUICKSTART_DEFAULT_IMAGE.digest as string, + dockerBinary, + }); + + const result = await installStellarQuickstart( + { + binDirectory, + cacheDirectory, + cwd, + dockerBinary: 'docker', + }, + { + ...dependencies, + async resolveDockerBinary(): Promise { + return dockerBinary; + }, + }, + ); + + const wrapperSource = readFileSync(result.binaryPath, 'utf8'); + assert.match( + wrapperSource, + new RegExp(dockerBinary.replaceAll('/', '\\/'), 'u'), + ); + }); + + it('forwards arguments through the installed wrapper', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const dockerBinary = createDockerStub(cwd); + const dependencies = createInstallDependencies({ + digest: STELLAR_QUICKSTART_DEFAULT_IMAGE.digest as string, + dockerBinary, + }); + + const result = await installStellarQuickstart( + { + binDirectory, + cacheDirectory, + cwd, + dockerBinary, + }, + dependencies, + ); + + const output = execFileSync(result.binaryPath, ['--local'], { + encoding: 'utf8', + }); + + assert.match(output, /--local/u); + assert.match(output, /stellar\/quickstart:latest/u); + }); + + function createTempDir(): string { + const tempDir = mkdtempSync(join(tmpdir(), 'stellar-quickstart-up-')); + tempDirs.push(tempDir); + return tempDir; + } +}); + +function createDockerStub(cwd: string): string { + const dockerBinary = join(cwd, 'docker-stub'); + writeFileSync( + dockerBinary, + `#!/usr/bin/env node +const [,, command, ...args] = process.argv; +if (command === 'version') { + process.exit(0); +} +if (command === 'pull') { + process.stdout.write('pulled ' + args.join(' ')); + process.exit(0); +} +if (command === 'image') { + process.stdout.write('sha256:8ddf3ed87a5c07eab5202b0fd95f06fb5db3f48cacd7e69fdc0e22925f181168'); + process.exit(0); +} +if (command === 'run') { + process.stdout.write(args.join(' ')); + process.exit(0); +} +console.error('unexpected docker command', command, args.join(' ')); +process.exit(1); +`, + ); + chmodSync(dockerBinary, 0o755); + return dockerBinary; +} + +function createInstallDependencies({ + digest, + dockerBinary, +}: { + digest: string; + dockerBinary: string; +}): StellarQuickstartInstallDependencies & { + inspectCalls: number; + pullCalls: number; +} { + const state = { + inspectCalls: 0, + pullCalls: 0, + }; + + return { + get inspectCalls(): number { + return state.inspectCalls; + }, + get pullCalls(): number { + return state.pullCalls; + }, + async inspectDockerImage(): Promise { + state.inspectCalls += 1; + return digest; + }, + async pullDockerImage( + binary: string, + imageReference: string, + ): Promise { + state.pullCalls += 1; + assert.equal(binary, dockerBinary); + assert.equal(imageReference, 'stellar/quickstart:latest'); + }, + async runCommand(command: string, args: string[]): Promise { + assert.equal(command, dockerBinary); + assert.deepEqual(args, ['version']); + }, + }; +} diff --git a/packages/stellar-quickstart-up/src/install.ts b/packages/stellar-quickstart-up/src/install.ts new file mode 100644 index 0000000000..6abf2ed7b0 --- /dev/null +++ b/packages/stellar-quickstart-up/src/install.ts @@ -0,0 +1,409 @@ +/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals */ +import { + cleanInstallerCache, + getCacheKey, + getMetamaskCacheDirectory, + installExecutableWrapper, + isFileMissingError, + readCliValue, + readPackageJsonToolConfig, + runCommand, +} from '@metamask/local-node-utils'; +import { execFile } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { isAbsolute, join } from 'node:path'; +import { promisify } from 'node:util'; + +const STELLAR_QUICKSTART_CACHE_NAMESPACE = 'stellar-quickstart-up'; +const IMAGE_CACHE_NAMESPACE = 'image'; + +export type StellarQuickstartImageConfig = { + digest?: string; + reference: string; + version: string; +}; + +export type StellarQuickstartInstallOptions = { + binDirectory?: string; + cacheDirectory?: string; + cwd?: string; + dockerBinary?: string; + image?: Partial; + runArgs?: string[]; +}; + +export type StellarQuickstartInstallResult = { + binaryPath: string; + cacheHit: boolean; + digest?: string; + imageReference: string; + version: string; +}; + +export type StellarQuickstartInstallDependencies = { + inspectDockerImage?: ( + dockerBinary: string, + imageReference: string, + ) => Promise; + pullDockerImage?: ( + dockerBinary: string, + imageReference: string, + ) => Promise; + resolveDockerBinary?: (dockerBinary: string) => Promise; + runCommand?: typeof runCommand; +}; + +type StellarQuickstartPackageJsonConfig = Pick< + StellarQuickstartInstallOptions, + 'binDirectory' | 'cacheDirectory' | 'image' | 'runArgs' +>; + +export const STELLAR_QUICKSTART_DEFAULT_IMAGE: StellarQuickstartImageConfig = { + version: 'latest', + reference: 'stellar/quickstart:latest', + digest: + 'sha256:8ddf3ed87a5c07eab5202b0fd95f06fb5db3f48cacd7e69fdc0e22925f181168', +}; + +export const STELLAR_QUICKSTART_DEFAULT_RUN_ARGS = [ + 'run', + '--rm', + '-i', + '-p', + '8000:8000', +]; + +export function getStellarQuickstartCacheDirectory({ + cwd = process.cwd(), + homeDirectory, +}: { + cwd?: string; + homeDirectory?: string; +} = {}): string { + return getMetamaskCacheDirectory({ + cwd, + homeDirectory, + toolName: STELLAR_QUICKSTART_CACHE_NAMESPACE, + }); +} + +export function readStellarQuickstartInstallOptionsFromPackageJson({ + cwd = process.cwd(), + packageJsonPath = join(cwd, 'package.json'), +}: { + cwd?: string; + packageJsonPath?: string; +} = {}): StellarQuickstartInstallOptions { + const config = readPackageJsonToolConfig({ + cwd, + packageJsonPath, + configKeys: [ + 'stellarQuickstartUp', + 'stellarquickstartup', + 'stellar-quickstart-up', + ], + }) as Partial; + const options: StellarQuickstartInstallOptions = {}; + + if (config.binDirectory) { + options.binDirectory = config.binDirectory; + } + if (config.cacheDirectory) { + options.cacheDirectory = config.cacheDirectory; + } + if (config.image) { + options.image = config.image; + } + if (config.runArgs) { + options.runArgs = config.runArgs; + } + + return options; +} + +export function parseStellarQuickstartInstallCliOptions( + args: string[], +): StellarQuickstartInstallOptions { + const options: StellarQuickstartInstallOptions = {}; + const image: Partial = {}; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const value = args[index + 1]; + + switch (arg) { + case '--bin-directory': + options.binDirectory = readCliValue(arg, value); + index += 1; + break; + case '--cache-directory': + options.cacheDirectory = readCliValue(arg, value); + index += 1; + break; + case '--docker-binary': + options.dockerBinary = readCliValue(arg, value); + index += 1; + break; + case '--image-digest': + image.digest = readCliValue(arg, value); + index += 1; + break; + case '--image-reference': + image.reference = readCliValue(arg, value); + index += 1; + break; + default: + throw new Error(`Unknown stellar-quickstart-up install option: ${arg}`); + } + } + + if (image.reference || image.digest) { + options.image = image; + } + + return options; +} + +export async function installStellarQuickstart( + options: StellarQuickstartInstallOptions = {}, + dependencies: StellarQuickstartInstallDependencies = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getStellarQuickstartCacheDirectory({ cwd }); + const binDirectory = + options.binDirectory ?? join(cwd, 'node_modules', '.bin'); + const dockerBinary = options.dockerBinary ?? 'docker'; + const image = mergeImageConfig( + STELLAR_QUICKSTART_DEFAULT_IMAGE, + options.image, + ); + const runArgs = options.runArgs ?? STELLAR_QUICKSTART_DEFAULT_RUN_ARGS; + const runCommandImpl = dependencies.runCommand ?? runCommand; + const pullDockerImage = + dependencies.pullDockerImage ?? pullDockerImageDefault; + const inspectDockerImage = + dependencies.inspectDockerImage ?? inspectDockerImageDefault; + const resolveDockerBinary = + dependencies.resolveDockerBinary ?? resolveDockerBinaryDefault; + const resolvedDockerBinary = await resolveDockerBinary(dockerBinary); + + await runCommandImpl(resolvedDockerBinary, ['version']); + + const imageResult = await installStellarQuickstartImage( + { + cacheDirectory, + dockerBinary: resolvedDockerBinary, + image, + }, + { inspectDockerImage, pullDockerImage }, + ); + const binaryPath = await installExecutableWrapper({ + binDirectory, + commandName: 'stellar-quickstart', + executableArgs: [...runArgs, imageResult.imageReference], + executablePath: resolvedDockerBinary, + pathResolution: 'absolute', + }); + + return { + binaryPath, + cacheHit: imageResult.cacheHit, + digest: imageResult.digest, + imageReference: imageResult.imageReference, + version: image.version, + }; +} + +export async function cleanStellarQuickstartCache( + options: Pick = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getStellarQuickstartCacheDirectory({ cwd }); + + await cleanInstallerCache({ + cacheDirectory, + namespace: STELLAR_QUICKSTART_CACHE_NAMESPACE, + }); +} + +function mergeImageConfig( + defaults: StellarQuickstartImageConfig, + override?: Partial, +): StellarQuickstartImageConfig { + return { + ...defaults, + ...override, + }; +} + +async function installStellarQuickstartImage( + { + cacheDirectory, + dockerBinary, + image, + }: { + cacheDirectory: string; + dockerBinary: string; + image: StellarQuickstartImageConfig; + }, + dependencies: { + inspectDockerImage: ( + dockerBinary: string, + imageReference: string, + ) => Promise; + pullDockerImage: ( + dockerBinary: string, + imageReference: string, + ) => Promise; + }, +): Promise<{ + cacheHit: boolean; + digest?: string; + imageReference: string; +}> { + const cacheKey = getCacheKey({ + checksum: image.digest ?? image.reference, + url: image.reference, + }); + const cacheRoot = join( + cacheDirectory, + STELLAR_QUICKSTART_CACHE_NAMESPACE, + IMAGE_CACHE_NAMESPACE, + cacheKey, + ); + const digestPath = join(cacheRoot, '.image-digest'); + const referencePath = join(cacheRoot, '.image-reference'); + const cached = readCachedImageMetadata(digestPath, referencePath, image); + + if (cached) { + return cached; + } + + await rm(cacheRoot, { force: true, recursive: true }); + await mkdir(cacheRoot, { recursive: true }); + + await dependencies.pullDockerImage(dockerBinary, image.reference); + const digest = await dependencies.inspectDockerImage( + dockerBinary, + image.reference, + ); + + if (image.digest && digest !== image.digest) { + throw new Error( + `Stellar Quickstart image digest mismatch. Expected ${image.digest}, received ${digest}.`, + ); + } + + await writeFile(referencePath, image.reference); + await writeFile(digestPath, digest); + + return { + cacheHit: false, + digest, + imageReference: image.reference, + }; +} + +function readCachedImageMetadata( + digestPath: string, + referencePath: string, + image: StellarQuickstartImageConfig, +): { + cacheHit: true; + digest: string; + imageReference: string; +} | null { + try { + const imageReference = readFileSync(referencePath, 'utf8'); + const digest = readFileSync(digestPath, 'utf8'); + + if ( + imageReference !== image.reference || + (image.digest && digest !== image.digest) + ) { + return null; + } + + return { + cacheHit: true, + digest, + imageReference: image.reference, + }; + } catch (error) { + if (!isFileMissingError(error)) { + throw error; + } + + return null; + } +} + +async function pullDockerImageDefault( + dockerBinary: string, + imageReference: string, +): Promise { + await runCommand(dockerBinary, ['pull', imageReference]); +} + +async function resolveDockerBinaryDefault( + dockerBinary: string, +): Promise { + if ( + isAbsolute(dockerBinary) || + dockerBinary.includes('/') || + dockerBinary.includes('\\') + ) { + return dockerBinary; + } + + const execFileAsync = promisify(execFile); + const lookupCommand = process.platform === 'win32' ? 'where' : 'which'; + const { stdout } = await execFileAsync(lookupCommand, [dockerBinary], { + encoding: 'utf8', + }); + const resolvedPath = stdout.split(/\r?\n/u)[0]?.trim(); + + if (!resolvedPath) { + throw new Error(`Could not resolve Docker binary: ${dockerBinary}`); + } + + return resolvedPath; +} + +async function inspectDockerImageDefault( + dockerBinary: string, + imageReference: string, +): Promise { + const execFileAsync = promisify(execFile); + const { stdout } = await execFileAsync( + dockerBinary, + [ + 'image', + 'inspect', + imageReference, + '--format', + '{{index .RepoDigests 0}}', + ], + { encoding: 'utf8' }, + ); + const repoDigest = stdout.trim(); + + if (!repoDigest) { + throw new Error( + `Docker image ${imageReference} has no RepoDigests after pull.`, + ); + } + + const digestSeparatorIndex = repoDigest.lastIndexOf('@'); + + if (digestSeparatorIndex === -1) { + throw new Error( + `Docker image ${imageReference} returned unexpected RepoDigest: ${repoDigest}`, + ); + } + + return repoDigest.slice(digestSeparatorIndex + 1); +} diff --git a/packages/stellar-quickstart-up/tsconfig.build.json b/packages/stellar-quickstart-up/tsconfig.build.json index 02a0eea03f..82530a36dd 100644 --- a/packages/stellar-quickstart-up/tsconfig.build.json +++ b/packages/stellar-quickstart-up/tsconfig.build.json @@ -5,6 +5,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [{ "path": "../local-node-utils/tsconfig.build.json" }], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index 85b34e0e64..1c2d5ef91a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8637,6 +8637,7 @@ __metadata: resolution: "@metamask/stellar-quickstart-up@workspace:packages/stellar-quickstart-up" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/local-node-utils": "npm:^0.0.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" @@ -8646,6 +8647,8 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + bin: + stellar-quickstart-up: ./dist/bin/stellar-quickstart-up.mjs languageName: unknown linkType: soft