diff --git a/packages/stellar-quickstart-up/CHANGELOG.md b/packages/stellar-quickstart-up/CHANGELOG.md index b518709c7b..aebf32c5db 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 initial `stellar-quickstart-up` runtime installer that pulls a pinned `stellar/quickstart` Docker image, caches image metadata, and installs a `stellar-quickstart` wrapper in `node_modules/.bin` ([#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..2658ea97c5 100644 --- a/packages/stellar-quickstart-up/README.md +++ b/packages/stellar-quickstart-up/README.md @@ -1,15 +1,113 @@ # `@metamask/stellar-quickstart-up` -Stellar Quickstart runtime installer for MetaMask E2E tests +`stellar-quickstart-up` installs a pinned `stellar/quickstart` Docker image for local +development and CI. It follows the same runtime-only shape as +`@metamask/foundryup`: this package pulls the external runtime image into the +MetaMask cache and exposes a `stellar-quickstart` binary 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/RPC readiness, and perform wallet/funding seeding itself. + +## Installed Artifacts + +`stellar-quickstart-up` installs: + +- a pinned `stellar/quickstart` Docker image reference in the MetaMask cache +- a `node_modules/.bin/stellar-quickstart` wrapper that forwards to `docker run` + +## 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`. +- `--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 installed wrapper defaults to `docker run --rm -i -p 8000:8000`. + +## 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 and digest 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..959c550bef 100644 --- a/packages/stellar-quickstart-up/package.json +++ b/packages/stellar-quickstart-up/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/stellar-quickstart-up", - "version": "0.0.0", + "version": "0.1.0", "description": "Stellar Quickstart runtime installer for MetaMask E2E tests", "keywords": [ "Ethereum", @@ -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..9ab4de85bc --- /dev/null +++ b/packages/stellar-quickstart-up/src/install.test.ts @@ -0,0 +1,346 @@ +/* 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('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..5074b35363 --- /dev/null +++ b/packages/stellar-quickstart-up/src/install.ts @@ -0,0 +1,336 @@ +/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals */ +import { + cleanInstallerCache, + getCacheKey, + getMetamaskCacheDirectory, + installExecutableWrapper, + readCliValue, + readPackageJsonToolConfig, + runCommand, +} from '@metamask/local-node-utils'; +import { execFile } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { 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; + 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; + + await runCommandImpl(dockerBinary, ['version']); + + const imageResult = await installStellarQuickstartImage( + { + cacheDirectory, + dockerBinary, + image, + }, + { inspectDockerImage, pullDockerImage }, + ); + const binaryPath = await installExecutableWrapper({ + binDirectory, + commandName: 'stellar-quickstart', + executableArgs: [...runArgs, imageResult.imageReference], + executablePath: dockerBinary, + pathResolution: 'absolute', + }); + + return { + binaryPath, + cacheHit: imageResult.cacheHit, + digest: imageResult.digest, + imageReference: imageResult.imageReference, + version: image.version, + }; +} + +export async function cleanStellarQuickstartCache( + options: Pick< + StellarQuickstartInstallOptions, + 'cacheDirectory' | 'cwd' + > = {}, +): 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'); + + if ( + existsSync(digestPath) && + existsSync(referencePath) && + readFileSync(referencePath, 'utf8') === image.reference && + (!image.digest || readFileSync(digestPath, 'utf8') === image.digest) + ) { + return { + cacheHit: true, + digest: readFileSync(digestPath, 'utf8'), + imageReference: image.reference, + }; + } + + 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, + }; +} + +async function pullDockerImageDefault( + dockerBinary: string, + imageReference: string, +): Promise { + await runCommand(dockerBinary, ['pull', imageReference]); +} + +async function inspectDockerImageDefault( + dockerBinary: string, + imageReference: string, +): Promise { + const execFileAsync = promisify(execFile); + const { stdout } = await execFileAsync( + dockerBinary, + ['image', 'inspect', imageReference, '--format', '{{.Id}}'], + { encoding: 'utf8' }, + ); + + return stdout.trim(); +} 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 e4aaefc61f..488e69003f 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