diff --git a/knip.config.ts b/knip.config.ts index 8d9dff3453..caa51cc823 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -87,6 +87,10 @@ const config: KnipConfig = { // `sysctl` is an external system binary, not an npm package. ignoreBinaries: ['sysctl'], }, + 'packages/local-node-utils': { + // `sysctl` is an external system binary, not an npm package. + ignoreBinaries: ['sysctl'], + }, 'packages/gas-fee-controller': { ignoreDependencies: ['@metamask/ethjs-unit', 'jest-when'], }, diff --git a/package.json b/package.json index 981005845c..24495b3db5 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "lint": "yarn lint:eslint && echo && yarn lint:misc --check && yarn constraints && yarn lint:dependencies && yarn lint:teams && yarn messenger-action-types:check && yarn readme-content:check", "lint:dependencies": "knip --dependencies && yarn dedupe --check", "lint:dependencies:fix": "knip --dependencies && yarn dedupe", - "lint:eslint": "yarn build:only-clean && NODE_OPTIONS='--max-old-space-size=6144' yarn eslint", + "lint:eslint": "yarn build:only-clean && NODE_OPTIONS='--max-old-space-size=7168' yarn eslint", "lint:fix": "yarn lint:eslint --fix --prune-suppressions && echo && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies:fix && yarn messenger-action-types:generate && yarn readme-content:update", "lint:misc": "oxfmt --ignore-path .gitignore", "lint:misc:check": "yarn lint:misc --check", diff --git a/packages/local-node-utils/CHANGELOG.md b/packages/local-node-utils/CHANGELOG.md index f966455684..3ae6f163b5 100644 --- a/packages/local-node-utils/CHANGELOG.md +++ b/packages/local-node-utils/CHANGELOG.md @@ -10,5 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial package scaffold ([#9233](https://github.com/MetaMask/core/pull/9233)) +- Shared installer utilities for local node runtime packages ([#9234](https://github.com/MetaMask/core/pull/9234)) + - Cache directory resolution from Yarn config + - Artifact config helpers, checksum verification, and downloads + - Archive extraction, executable wrappers, and filesystem helpers [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/local-node-utils/README.md b/packages/local-node-utils/README.md index fcb244e9d9..c3f1a6658c 100644 --- a/packages/local-node-utils/README.md +++ b/packages/local-node-utils/README.md @@ -1,6 +1,7 @@ # `@metamask/local-node-utils` -Shared utilities for MetaMask local node runtime installers +Shared utilities for MetaMask local node runtime installers such as +`java-tron-up`, `bitcoin-regtest-up`, and `solana-test-validator-up`. ## Installation @@ -10,6 +11,15 @@ or `npm install @metamask/local-node-utils` +## API + +The package exports shared helpers for: + +- Resolving MetaMask cache directories from Yarn configuration +- Parsing artifact platform configuration and cache keys +- Downloading release archives with checksum verification +- Extracting archives and installing executable wrappers in `node_modules/.bin` + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/local-node-utils/jest.config.js b/packages/local-node-utils/jest.config.js index ca08413339..29b89ed092 100644 --- a/packages/local-node-utils/jest.config.js +++ b/packages/local-node-utils/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, + branches: 75, + functions: 90, + lines: 90, + statements: 90, }, }, }); diff --git a/packages/local-node-utils/package.json b/packages/local-node-utils/package.json index 9b5f61178c..4867fca9a9 100644 --- a/packages/local-node-utils/package.json +++ b/packages/local-node-utils/package.json @@ -52,12 +52,16 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "yaml": "^2.3.4" + }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", + "nock": "^13.3.1", "ts-jest": "^29.2.5", "tsx": "^4.20.5", "typedoc": "^0.25.13", diff --git a/packages/local-node-utils/src/archive.ts b/packages/local-node-utils/src/archive.ts new file mode 100644 index 0000000000..c7310feffd --- /dev/null +++ b/packages/local-node-utils/src/archive.ts @@ -0,0 +1,15 @@ +import { runCommand } from './command'; + +export async function extractTarGzArchive( + archivePath: string, + destination: string, +): Promise { + await runCommand('tar', ['-xzf', archivePath, '-C', destination]); +} + +export async function extractTarBz2Archive( + archivePath: string, + destination: string, +): Promise { + await runCommand('tar', ['-xjf', archivePath, '-C', destination]); +} diff --git a/packages/local-node-utils/src/artifact.ts b/packages/local-node-utils/src/artifact.ts new file mode 100644 index 0000000000..85b2d9a4cb --- /dev/null +++ b/packages/local-node-utils/src/artifact.ts @@ -0,0 +1,52 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { createHash } from 'node:crypto'; + +import type { ArtifactConfig, ArtifactPlatformConfig } from './types'; + +export function mergeArtifactConfig( + defaults: ArtifactConfig, + override: ArtifactConfig | undefined, +): ArtifactConfig { + if (!override) { + return defaults; + } + + return { + version: override.version ?? defaults.version, + platforms: { ...defaults.platforms, ...override.platforms }, + }; +} + +export function resolvePlatformConfig( + config: ArtifactConfig, + platform: string, + label: string, +): ArtifactPlatformConfig { + const platformConfig = config.platforms.current ?? config.platforms[platform]; + + if (!platformConfig) { + throw new Error(`No ${label} is configured for ${platform}.`); + } + + return platformConfig; +} + +export function requireCompletePlatformConfig( + config: Partial, + label: string, +): ArtifactPlatformConfig { + if (!config.url || !config.checksum) { + throw new Error(`${label} require both a URL and a checksum.`); + } + + return { + checksum: config.checksum, + url: config.url, + }; +} + +export function getCacheKey(config: ArtifactPlatformConfig): string { + return createHash('sha256') + .update(`${config.url}:${config.checksum}`) + .digest('hex'); +} diff --git a/packages/local-node-utils/src/cache-directory.ts b/packages/local-node-utils/src/cache-directory.ts new file mode 100644 index 0000000000..0bfd0215bc --- /dev/null +++ b/packages/local-node-utils/src/cache-directory.ts @@ -0,0 +1,37 @@ +/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals */ +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; + +import { isFileMissingError } from './errors'; + +export function getMetamaskCacheDirectory({ + cwd = process.cwd(), + homeDirectory = homedir(), + toolName = 'local-node-utils', +}: { + cwd?: string; + homeDirectory?: string; + toolName?: string; +} = {}): string { + const yarnRcPath = join(cwd, '.yarnrc.yml'); + let enableGlobalCache = false; + + try { + const parsedConfig = parseYaml(readFileSync(yarnRcPath, 'utf8')); + enableGlobalCache = parsedConfig?.enableGlobalCache ?? false; + } catch (error) { + if (isFileMissingError(error)) { + return join(cwd, '.metamask', 'cache'); + } + console.warn( + `Warning: Error reading ${yarnRcPath}, using local ${toolName} cache:`, + error, + ); + } + + return enableGlobalCache + ? join(homeDirectory, '.cache', 'metamask') + : join(cwd, '.metamask', 'cache'); +} diff --git a/packages/local-node-utils/src/cache.ts b/packages/local-node-utils/src/cache.ts new file mode 100644 index 0000000000..e1bd363017 --- /dev/null +++ b/packages/local-node-utils/src/cache.ts @@ -0,0 +1,16 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +export async function cleanInstallerCache({ + cacheDirectory, + namespace, +}: { + cacheDirectory: string; + namespace: string; +}): Promise { + await rm(join(cacheDirectory, namespace), { + force: true, + recursive: true, + }); +} diff --git a/packages/local-node-utils/src/checksum.ts b/packages/local-node-utils/src/checksum.ts new file mode 100644 index 0000000000..82471893ba --- /dev/null +++ b/packages/local-node-utils/src/checksum.ts @@ -0,0 +1,20 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; + +export async function verifyFileChecksum( + filePath: string, + expectedChecksum: string, + label: string, +): Promise { + const hash = createHash('sha256'); + await pipeline(createReadStream(filePath), hash); + const checksum = hash.digest('hex'); + + if (checksum !== expectedChecksum) { + throw new Error( + `${label} checksum mismatch. Expected ${expectedChecksum}, got ${checksum}.`, + ); + } +} diff --git a/packages/local-node-utils/src/cli.ts b/packages/local-node-utils/src/cli.ts new file mode 100644 index 0000000000..79209896b2 --- /dev/null +++ b/packages/local-node-utils/src/cli.ts @@ -0,0 +1,10 @@ +export function readCliValue( + option: string, + value: string | undefined, +): string { + if (!value || value.startsWith('--')) { + throw new Error(`${option} requires a value.`); + } + + return value; +} diff --git a/packages/local-node-utils/src/command.test.ts b/packages/local-node-utils/src/command.test.ts new file mode 100644 index 0000000000..1b4f94c0b6 --- /dev/null +++ b/packages/local-node-utils/src/command.test.ts @@ -0,0 +1,17 @@ +/* eslint-disable jest/expect-expect */ +import assert from 'node:assert/strict'; + +import { runCommand } from './command'; + +describe('runCommand', () => { + it('runs a successful command', async () => { + await runCommand(process.execPath, ['-e', 'process.exit(0)']); + }); + + it('rejects when a command fails', async () => { + await assert.rejects( + runCommand(process.execPath, ['-e', 'process.exit(2)']), + /failed with code 2/u, + ); + }); +}); diff --git a/packages/local-node-utils/src/command.ts b/packages/local-node-utils/src/command.ts new file mode 100644 index 0000000000..251c2dc50f --- /dev/null +++ b/packages/local-node-utils/src/command.ts @@ -0,0 +1,33 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { spawn } from 'node:child_process'; + +export async function runCommand( + command: string, + args: string[], +): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + shell: false, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', rejectPromise); + child.on('close', (code, signal) => { + if (code === 0) { + resolvePromise(); + return; + } + + const exitStatus = signal ? `signal ${signal}` : `code ${code ?? 'null'}`; + rejectPromise( + new Error( + `${command} ${args.join(' ')} failed with ${exitStatus}: ${stderr}`, + ), + ); + }); + }); +} diff --git a/packages/local-node-utils/src/download.ts b/packages/local-node-utils/src/download.ts new file mode 100644 index 0000000000..b86cb427a0 --- /dev/null +++ b/packages/local-node-utils/src/download.ts @@ -0,0 +1,69 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { request as requestHttp } from 'node:http'; +import { request as requestHttps } from 'node:https'; +import { dirname } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +export async function downloadFileFromUrl( + url: string, + destination: string, +): Promise { + await mkdir(dirname(destination), { recursive: true }); + await pipeline( + await openDownloadStream(new URL(url)), + createWriteStream(destination), + ); +} + +export async function openDownloadStream( + url: URL, + redirectsRemaining = 5, +): Promise { + const request = url.protocol === 'http:' ? requestHttp : requestHttps; + + return await new Promise((resolvePromise, rejectPromise) => { + const req = request(url, (response) => { + const { headers, statusCode, statusMessage } = response; + + if ( + statusCode && + statusCode >= 300 && + statusCode < 400 && + headers.location + ) { + response.resume(); + if (redirectsRemaining <= 0) { + rejectPromise(new Error(`Too many redirects downloading ${url}`)); + return; + } + + openDownloadStream( + new URL(headers.location, url), + redirectsRemaining - 1, + ) + .then(resolvePromise) + .catch(rejectPromise); + return; + } + + if (!statusCode || statusCode < 200 || statusCode >= 300) { + response.resume(); + rejectPromise( + new Error( + `Request to ${url} failed with ${statusCode ?? 'unknown'} ${ + statusMessage ?? '' + }`.trim(), + ), + ); + return; + } + + resolvePromise(response); + }); + + req.on('error', rejectPromise); + req.end(); + }); +} diff --git a/packages/local-node-utils/src/errors.ts b/packages/local-node-utils/src/errors.ts new file mode 100644 index 0000000000..20ecf357ef --- /dev/null +++ b/packages/local-node-utils/src/errors.ts @@ -0,0 +1,8 @@ +export function isFileMissingError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + Object.prototype.hasOwnProperty.call(error, 'code') && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/packages/local-node-utils/src/executable-wrapper.ts b/packages/local-node-utils/src/executable-wrapper.ts new file mode 100644 index 0000000000..f01b3dd612 --- /dev/null +++ b/packages/local-node-utils/src/executable-wrapper.ts @@ -0,0 +1,103 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { chmod, mkdir, unlink, writeFile } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; + +import { isFileMissingError } from './errors'; + +export type ExecutableWrapperPathResolution = 'absolute' | 'relative'; + +export async function installExecutableWrapper({ + binDirectory, + commandName, + executableArgs = [], + executablePath, + pathResolution = 'absolute', +}: { + binDirectory: string; + commandName: string; + executableArgs?: string[]; + executablePath: string; + pathResolution?: ExecutableWrapperPathResolution; +}): Promise { + const binaryPath = join(binDirectory, commandName); + const wrapperSource = buildExecutableWrapperSource({ + binDirectory, + executableArgs, + executablePath, + pathResolution, + }); + + await mkdir(binDirectory, { recursive: true }); + await unlink(binaryPath).catch((error) => { + if (!isFileMissingError(error)) { + throw error; + } + }); + await writeFile(binaryPath, wrapperSource); + await chmod(binaryPath, 0o755); + + return binaryPath; +} + +function buildExecutableWrapperSource({ + binDirectory, + executableArgs, + executablePath, + pathResolution, +}: { + binDirectory: string; + executableArgs: string[]; + executablePath: string; + pathResolution: ExecutableWrapperPathResolution; +}): string { + if (pathResolution === 'relative') { + const relativeExecutablePath = relative(binDirectory, executablePath); + + return `#!/usr/bin/env node +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const executablePath = path.resolve(__dirname, ${JSON.stringify(relativeExecutablePath)}); +const executableArgs = ${JSON.stringify(executableArgs)}; +const result = spawnSync(executablePath, executableArgs.concat(process.argv.slice(2)), { + stdio: 'inherit', +}); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +if (result.signal) { + process.kill(process.pid, result.signal); + process.exit(1); +} + +process.exit(result.status ?? 0); +`; + } + + const resolvedExecutablePath = resolve(executablePath); + + return `#!/usr/bin/env node +const { spawnSync } = require('node:child_process'); + +const executablePath = ${JSON.stringify(resolvedExecutablePath)}; +const executableArgs = ${JSON.stringify(executableArgs)}; +const result = spawnSync(executablePath, executableArgs.concat(process.argv.slice(2)), { + stdio: 'inherit', +}); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +if (result.signal) { + process.kill(process.pid, result.signal); + process.exit(1); +} + +process.exit(result.status ?? 0); +`; +} diff --git a/packages/local-node-utils/src/filesystem.ts b/packages/local-node-utils/src/filesystem.ts new file mode 100644 index 0000000000..870f9d1b90 --- /dev/null +++ b/packages/local-node-utils/src/filesystem.ts @@ -0,0 +1,40 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +export function findExecutable(root: string, name: string): string | undefined { + if (!existsSync(root)) { + return undefined; + } + + for (const entry of readdirSync(root)) { + const entryPath = join(root, entry); + const stat = statSync(entryPath); + if (stat.isDirectory()) { + const found = findExecutable(entryPath, name); + if (found) { + return found; + } + } else if (entry === name) { + return entryPath; + } + } + + return undefined; +} + +export function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +export function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} diff --git a/packages/local-node-utils/src/index.test.ts b/packages/local-node-utils/src/index.test.ts index bc062d3694..285994c62b 100644 --- a/packages/local-node-utils/src/index.test.ts +++ b/packages/local-node-utils/src/index.test.ts @@ -1,9 +1,241 @@ -import greeter from '.'; +/* eslint-disable jest/expect-expect, n/no-sync */ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); +import { + getCacheKey, + mergeArtifactConfig, + requireCompletePlatformConfig, + resolvePlatformConfig, +} from './artifact'; +import { getMetamaskCacheDirectory } from './cache-directory'; +import { readCliValue } from './cli'; +import { readPackageJsonToolConfig } from './package-json'; +import type { ArtifactConfig } from './types'; + +describe('artifact helpers', () => { + const defaults: ArtifactConfig = { + version: '1.0.0', + platforms: { + 'linux-x64': { + checksum: 'abc', + url: 'https://example.com/linux', + }, + }, + }; + + it('merges artifact config overrides', () => { + assert.deepEqual( + mergeArtifactConfig(defaults, { + version: '2.0.0', + platforms: { + 'darwin-arm64': { + checksum: 'def', + url: 'https://example.com/darwin', + }, + }, + }), + { + version: '2.0.0', + platforms: { + 'linux-x64': defaults.platforms['linux-x64'], + 'darwin-arm64': { + checksum: 'def', + url: 'https://example.com/darwin', + }, + }, + }, + ); + }); + + it('resolves platform config from current override', () => { + assert.deepEqual( + resolvePlatformConfig( + { + platforms: { + current: { + checksum: 'current', + url: 'https://example.com/current', + }, + }, + }, + 'linux-x64', + 'test artifact', + ), + { + checksum: 'current', + url: 'https://example.com/current', + }, + ); + }); + + it('throws when platform config is missing', () => { + assert.throws( + () => resolvePlatformConfig(defaults, 'darwin-arm64', 'test artifact'), + /No test artifact is configured for darwin-arm64/u, + ); + }); + + it('merges artifact config defaults when override is missing', () => { + assert.deepEqual(mergeArtifactConfig(defaults, undefined), defaults); + }); + + it('requires complete platform config values', () => { + assert.deepEqual( + requireCompletePlatformConfig( + { + checksum: 'abc', + url: 'https://example.com', + }, + 'CLI', + ), + { + checksum: 'abc', + url: 'https://example.com', + }, + ); + assert.throws( + () => + requireCompletePlatformConfig({ url: 'https://example.com' }, 'CLI'), + /CLI require both a URL and a checksum/u, + ); + }); + + it('builds a stable cache key', () => { + const config = { + checksum: 'abc', + url: 'https://example.com/linux', + }; + + assert.equal( + getCacheKey(config), + createHash('sha256') + .update(`${config.url}:${config.checksum}`) + .digest('hex'), + ); + }); +}); + +describe('cache directory', () => { + it('uses the global MetaMask cache when Yarn global cache is enabled', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const homeDirectory = mkdtempSync(join(tmpdir(), 'local-node-utils-home-')); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: true\n'); + + assert.equal( + getMetamaskCacheDirectory({ cwd, homeDirectory }), + join(homeDirectory, '.cache', 'metamask'), + ); + }); + + it('uses the local MetaMask cache when Yarn global cache is disabled', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: false\n'); + + assert.equal( + getMetamaskCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); + + it('uses the local MetaMask cache when .yarnrc.yml is missing', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + + assert.equal( + getMetamaskCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); +}); + +describe('cli helpers', () => { + it('reads the next CLI value', () => { + assert.equal(readCliValue('--platform', 'linux-x64'), 'linux-x64'); + }); + + it('throws when a CLI value is missing', () => { + assert.throws( + () => readCliValue('--platform', undefined), + /--platform requires a value/u, + ); + }); +}); + +describe('package.json helpers', () => { + it('reads the first matching tool config key', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + writeFileSync( + join(cwd, 'package.json'), + JSON.stringify({ + javaTronUp: { + binDirectory: './bin', + }, + 'java-tron-up': { + cacheDirectory: './cache', + }, + }), + ); + + assert.deepEqual( + readPackageJsonToolConfig({ + cwd, + configKeys: ['javaTronUp', 'javatronup', 'java-tron-up'], + }), + { + binDirectory: './bin', + }, + ); + }); + + it('returns an empty object when package.json is missing', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + + assert.deepEqual( + readPackageJsonToolConfig({ + cwd, + configKeys: ['java-tron-up'], + }), + {}, + ); + }); + + it('throws when package.json is invalid JSON', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + writeFileSync(join(cwd, 'package.json'), '{'); + + assert.throws( + () => + readPackageJsonToolConfig({ + cwd, + configKeys: ['java-tron-up'], + }), + /SyntaxError/u, + ); + }); + + it('skips non-object config entries', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + writeFileSync( + join(cwd, 'package.json'), + JSON.stringify({ + javaTronUp: 'not-an-object', + 'java-tron-up': { + cacheDirectory: './cache', + }, + }), + ); + + assert.deepEqual( + readPackageJsonToolConfig({ + cwd, + configKeys: ['javaTronUp', 'java-tron-up'], + }), + { + cacheDirectory: './cache', + }, + ); }); }); diff --git a/packages/local-node-utils/src/index.ts b/packages/local-node-utils/src/index.ts index 6972c11729..5209ae4599 100644 --- a/packages/local-node-utils/src/index.ts +++ b/packages/local-node-utils/src/index.ts @@ -1,9 +1,24 @@ -/** - * 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 type { + ArtifactConfig, + ArtifactPlatformConfig, + InstallDependencies, +} from './types'; +export { + getCacheKey, + mergeArtifactConfig, + requireCompletePlatformConfig, + resolvePlatformConfig, +} from './artifact'; +export { cleanInstallerCache } from './cache'; +export { getMetamaskCacheDirectory } from './cache-directory'; +export { verifyFileChecksum } from './checksum'; +export { readCliValue } from './cli'; +export { runCommand } from './command'; +export { isFileMissingError } from './errors'; +export { extractTarBz2Archive, extractTarGzArchive } from './archive'; +export { downloadFileFromUrl } from './download'; +export { installExecutableWrapper } from './executable-wrapper'; +export type { ExecutableWrapperPathResolution } from './executable-wrapper'; +export { findExecutable, isDirectory, isFile } from './filesystem'; +export { getPlatformKey, normalizeSystemArchitecture } from './platform'; +export { readPackageJsonToolConfig } from './package-json'; diff --git a/packages/local-node-utils/src/integration.test.ts b/packages/local-node-utils/src/integration.test.ts new file mode 100644 index 0000000000..5d94b2d855 --- /dev/null +++ b/packages/local-node-utils/src/integration.test.ts @@ -0,0 +1,242 @@ +import nock, { cleanAll } from 'nock'; +/* eslint-disable jest/expect-expect, n/no-sync */ +import assert from 'node:assert/strict'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { extractTarBz2Archive, extractTarGzArchive } from './archive'; +import { cleanInstallerCache } from './cache'; +import { getMetamaskCacheDirectory } from './cache-directory'; +import { verifyFileChecksum } from './checksum'; +import { runCommand } from './command'; +import { downloadFileFromUrl, openDownloadStream } from './download'; +import { isFileMissingError } from './errors'; +import { installExecutableWrapper } from './executable-wrapper'; +import { findExecutable, isDirectory, isFile } from './filesystem'; +import { getPlatformKey, normalizeSystemArchitecture } from './platform'; + +jest.mock('./command', () => ({ + runCommand: jest.fn(), +})); + +const runCommandMock = jest.mocked(runCommand); + +describe('archive', () => { + beforeEach(() => { + runCommandMock.mockReset(); + runCommandMock.mockResolvedValue(undefined); + }); + + it('extracts tar.gz archives', async () => { + await extractTarGzArchive('/tmp/archive.tar.gz', '/tmp/output'); + + expect(runCommandMock).toHaveBeenCalledWith('tar', [ + '-xzf', + '/tmp/archive.tar.gz', + '-C', + '/tmp/output', + ]); + }); + + it('extracts tar.bz2 archives', async () => { + await extractTarBz2Archive('/tmp/archive.tar.bz2', '/tmp/output'); + + expect(runCommandMock).toHaveBeenCalledWith('tar', [ + '-xjf', + '/tmp/archive.tar.bz2', + '-C', + '/tmp/output', + ]); + }); +}); + +describe('cache', () => { + it('removes a namespaced cache directory', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const namespaceDir = join(tempDir, 'java-tron-up', 'fullnode'); + mkdirSync(namespaceDir, { recursive: true }); + writeFileSync(join(namespaceDir, 'artifact.jar'), 'data'); + + await cleanInstallerCache({ + cacheDirectory: tempDir, + namespace: 'java-tron-up', + }); + + assert.equal(existsSync(namespaceDir), false); + }); +}); + +describe('download', () => { + afterEach(() => { + cleanAll(); + }); + + it('downloads a file from a URL', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const destination = join(tempDir, 'nested', 'artifact.bin'); + + nock('https://example.com').get('/artifact.bin').reply(200, 'artifact'); + + await downloadFileFromUrl('https://example.com/artifact.bin', destination); + + assert.equal(readFileSync(destination, 'utf8'), 'artifact'); + }); + + it('follows redirects', async () => { + nock('https://example.com') + .get('/redirect') + .reply(302, '', { Location: 'https://example.com/final' }); + nock('https://example.com').get('/final').reply(200, 'redirected'); + + const stream = await openDownloadStream( + new URL('https://example.com/redirect'), + ); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + + assert.equal(Buffer.concat(chunks).toString('utf8'), 'redirected'); + }); + + it('rejects failed downloads', async () => { + nock('https://example.com').get('/missing.bin').reply(500, 'nope'); + + await assert.rejects( + downloadFileFromUrl( + 'https://example.com/missing.bin', + join(tmpdir(), 'missing.bin'), + ), + /failed with 500/u, + ); + }); + + it('rejects redirect loops', async () => { + nock('https://example.com') + .persist() + .get('/loop') + .reply(302, '', { Location: 'https://example.com/loop' }); + + await assert.rejects( + openDownloadStream(new URL('https://example.com/loop')), + /Too many redirects/u, + ); + }); +}); + +describe('cache directory warnings', () => { + it('falls back to the local cache when .yarnrc.yml is invalid', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const warnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + writeFileSync(join(cwd, '.yarnrc.yml'), 'not: [valid'); + + assert.equal( + getMetamaskCacheDirectory({ cwd, toolName: 'java-tron-up' }), + join(cwd, '.metamask', 'cache'), + ); + assert.match( + String(warnSpy.mock.calls[0]?.[0]), + /using local java-tron-up cache/u, + ); + + warnSpy.mockRestore(); + }); +}); + +describe('errors', () => { + it('detects missing file errors', () => { + assert.equal(isFileMissingError({ code: 'ENOENT' }), true); + assert.equal(isFileMissingError(new Error('nope')), false); + }); +}); + +describe('platform', () => { + it('returns a platform key', () => { + assert.match(getPlatformKey(), /^(darwin|linux|win32)-/u); + }); + + it('normalizes the current architecture', () => { + assert.equal(typeof normalizeSystemArchitecture(), 'string'); + }); +}); + +describe('checksum', () => { + it('verifies a file checksum', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const filePath = join(tempDir, 'artifact.bin'); + writeFileSync(filePath, 'hello'); + + await verifyFileChecksum( + filePath, + '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + 'test artifact', + ); + }); + + it('throws when checksums do not match', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const filePath = join(tempDir, 'artifact.bin'); + writeFileSync(filePath, 'hello'); + + await assert.rejects( + verifyFileChecksum(filePath, 'deadbeef', 'test artifact'), + /test artifact checksum mismatch/u, + ); + }); +}); + +describe('filesystem', () => { + it('finds nested executables by name', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const nestedDir = join(tempDir, 'release', 'bin'); + mkdirSync(nestedDir, { recursive: true }); + const executablePath = join(nestedDir, 'solana'); + writeFileSync(executablePath, ''); + chmodSync(executablePath, 0o755); + + assert.equal(findExecutable(tempDir, 'solana'), executablePath); + assert.equal(findExecutable(tempDir, 'missing'), undefined); + assert.equal(isDirectory(tempDir), true); + assert.equal(isFile(executablePath), true); + assert.equal(isDirectory(join(tempDir, 'missing')), false); + assert.equal(isFile(join(tempDir, 'missing')), false); + }); +}); + +describe('executable wrapper', () => { + it('installs wrappers with absolute and relative paths', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const binDirectory = join(tempDir, 'bin'); + const executablePath = join(tempDir, 'release', 'bin', 'solana'); + mkdirSync(join(tempDir, 'release', 'bin'), { recursive: true }); + writeFileSync(executablePath, '#!/bin/sh\necho solana\n'); + chmodSync(executablePath, 0o755); + + const relativeWrapper = await installExecutableWrapper({ + binDirectory, + commandName: 'solana', + executablePath, + pathResolution: 'relative', + }); + const absoluteWrapper = await installExecutableWrapper({ + binDirectory, + commandName: 'tool', + executableArgs: ['--flag'], + executablePath, + pathResolution: 'absolute', + }); + + assert.match(readFileSync(relativeWrapper, 'utf8'), /path\.resolve/u); + assert.match(readFileSync(absoluteWrapper, 'utf8'), /--flag/u); + }); +}); diff --git a/packages/local-node-utils/src/package-json.ts b/packages/local-node-utils/src/package-json.ts new file mode 100644 index 0000000000..ab9a55eff2 --- /dev/null +++ b/packages/local-node-utils/src/package-json.ts @@ -0,0 +1,35 @@ +/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { isFileMissingError } from './errors'; + +export function readPackageJsonToolConfig({ + cwd = process.cwd(), + packageJsonPath = join(cwd, 'package.json'), + configKeys, +}: { + cwd?: string; + packageJsonPath?: string; + configKeys: string[]; +}): Record { + let raw: string; + try { + raw = readFileSync(packageJsonPath, 'utf8'); + } catch (error) { + if (isFileMissingError(error)) { + return {}; + } + throw error; + } + + const packageJson = JSON.parse(raw) as Record; + for (const key of configKeys) { + const config = packageJson[key]; + if (config && typeof config === 'object') { + return config as Record; + } + } + + return {}; +} diff --git a/packages/local-node-utils/src/platform.ts b/packages/local-node-utils/src/platform.ts new file mode 100644 index 0000000000..0df844c81d --- /dev/null +++ b/packages/local-node-utils/src/platform.ts @@ -0,0 +1,22 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { spawnSync } from 'node:child_process'; +import { arch as osArch, platform as osPlatform } from 'node:os'; + +export function getPlatformKey(): string { + return `${osPlatform()}-${normalizeSystemArchitecture()}`; +} + +export function normalizeSystemArchitecture(architecture = osArch()): string { + if (architecture === 'x64' && osPlatform() === 'darwin') { + const result = spawnSync('sysctl', ['-n', 'sysctl.proc_translated'], { + encoding: 'utf8', + shell: false, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (result.stdout.trim() === '1') { + return 'arm64'; + } + } + + return architecture; +} diff --git a/packages/local-node-utils/src/types.ts b/packages/local-node-utils/src/types.ts new file mode 100644 index 0000000000..a6efc6cfbc --- /dev/null +++ b/packages/local-node-utils/src/types.ts @@ -0,0 +1,15 @@ +export type ArtifactPlatformConfig = { + checksum: string; + size?: number; + url: string; +}; + +export type ArtifactConfig = { + platforms: Record; + version?: string; +}; + +export type InstallDependencies = { + downloadFile?: (url: string, destination: string) => Promise; + extractArchive?: (archivePath: string, destination: string) => Promise; +}; diff --git a/yarn.lock b/yarn.lock index 9fc2eab3c2..a207342b23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7289,11 +7289,13 @@ __metadata: "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" + nock: "npm:^13.3.1" ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + yaml: "npm:^2.3.4" languageName: unknown linkType: soft