diff --git a/knip.config.ts b/knip.config.ts index 94cae7510e..8d9dff3453 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -83,6 +83,10 @@ const config: KnipConfig = { ignoreBinaries: ['anvil', 'sysctl'], ignoreDependencies: ['yargs-parser'], }, + 'packages/java-tron-up': { + // `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/packages/java-tron-up/CHANGELOG.md b/packages/java-tron-up/CHANGELOG.md index b518709c7b..c8a3e040eb 100644 --- a/packages/java-tron-up/CHANGELOG.md +++ b/packages/java-tron-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/java-tron-up` package ([#9208](https://github.com/MetaMask/core/pull/9208)). + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/java-tron-up/README.md b/packages/java-tron-up/README.md index fefe27d312..9a0ea827b8 100644 --- a/packages/java-tron-up/README.md +++ b/packages/java-tron-up/README.md @@ -1,15 +1,124 @@ # `@metamask/java-tron-up` -java-tron runtime installer for MetaMask E2E tests +`java-tron-up` installs a pinned native java-tron runtime for local development +and CI. It follows the same runtime-only shape as `@metamask/foundryup`: this +package installs external runtime artifacts into the MetaMask cache and exposes +binaries in `node_modules/.bin`; the consuming test harness owns process +startup, private-network config, readiness checks, and seeding. -## Installation +This package does not use Docker and does not start or seed a TRON node. -`yarn add @metamask/java-tron-up` +## Usage -or +Install the package in the consuming repo: -`npm install @metamask/java-tron-up` +```bash +yarn add @metamask/java-tron-up +npm install @metamask/java-tron-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": { + "java-tron-up": "node_modules/.bin/java-tron-up", + "java-tron": "node_modules/.bin/java-tron" + } +} +``` + +Install java-tron and its managed Java runtime: + +```bash +yarn java-tron-up install +``` + +Run the installed node wrapper: + +```bash +node_modules/.bin/java-tron -c /absolute/path/to/fullnode.conf --witness +``` + +For MetaMask Extension E2E tests, the Tron seeder should spawn +`node_modules/.bin/java-tron`, pass its generated private-network config, poll +java-tron's HTTP APIs directly, and perform all account/token/staking seeding +itself. + +## Installed Artifacts + +`java-tron-up` installs: + +- a platform-specific `FullNode.jar` +- a managed Java runtime matching java-tron's architecture requirements +- a `node_modules/.bin/java-tron` wrapper that runs: + +```bash +java -jar FullNode.jar "$@" +``` + +## CLI + +```bash +java-tron-up [install] [options] +java-tron-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`. +- `--full-node-url ` and `--full-node-checksum `: override the + FullNode jar for the current platform. +- `--java-runtime-url ` and `--java-runtime-checksum `: override the + Java runtime archive for the current platform. +- `--platform `: override platform selection, for example + `linux-x64`. + +## Default Release + +The package currently pins java-tron `GreatVoyage-v4.8.1` for `darwin-arm64`, +`darwin-x64`, `linux-arm64`, and `linux-x64`. + +java-tron `4.8.1` requires JDK 8 for x86_64 and JDK 17 for arm64, so this +package installs Azul Zulu Java 8 on x64 platforms and Azul Zulu Java 17 on +arm64 platforms. + +## Cache + +The cache defaults to `.metamask/cache` in the current repo. If `.yarnrc.yml` +contains `enableGlobalCache: true`, the cache moves to `~/.cache/metamask`, +matching the `@metamask/foundryup` behavior. + +Clean only this package's cache namespace: + +```bash +yarn java-tron-up cache clean +``` + +## Package Config + +The consuming repo can override the pinned artifact URLs and checksums in its +root `package.json`: + +```json +{ + "javaTronUp": { + "fullNode": { + "version": "GreatVoyage-v4.8.1", + "platforms": { + "linux-x64": { + "url": "https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.8.1/FullNode.jar", + "checksum": "0e67b2fe75d7077750e73c4fa20725c6e9824657275d96be256ae5da681f9945" + } + } + } + } +} +``` + +Supported package config keys are `javaTronUp`, `javatronup`, and +`java-tron-up`. diff --git a/packages/java-tron-up/jest.config.js b/packages/java-tron-up/jest.config.js index ca08413339..e29b7194b9 100644 --- a/packages/java-tron-up/jest.config.js +++ b/packages/java-tron-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/java-tron-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/java-tron-up/package.json b/packages/java-tron-up/package.json index 93ed2ba935..b8672458c9 100644 --- a/packages/java-tron-up/package.json +++ b/packages/java-tron-up/package.json @@ -15,6 +15,7 @@ "type": "git", "url": "https://github.com/MetaMask/core.git" }, + "bin": "./dist/bin/java-tron-up.mjs", "files": [ "dist/" ], diff --git a/packages/java-tron-up/src/bin/java-tron-up.ts b/packages/java-tron-up/src/bin/java-tron-up.ts new file mode 100644 index 0000000000..a32b97bc7f --- /dev/null +++ b/packages/java-tron-up/src/bin/java-tron-up.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/* eslint-disable no-restricted-globals */ +import { + cleanJavaTronCache, + installJavaTron, + parseJavaTronInstallCliOptions, + readJavaTronInstallOptionsFromPackageJson, +} 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 cleanJavaTronCache({ + ...readJavaTronInstallOptionsFromPackageJson(), + ...parseJavaTronInstallCliOptions(args.slice(1)), + }); + console.log('[java-tron-up] cache cleaned'); + return; + } + + const installArgs = command === 'install' ? args : process.argv.slice(2); + const result = await installJavaTron({ + ...readJavaTronInstallOptionsFromPackageJson(), + ...parseJavaTronInstallCliOptions(installArgs), + }); + + console.log( + `[java-tron-up] java-tron ${ + result.cacheHit ? 'found in cache' : 'installed' + } at ${result.fullNodeJar}`, + ); + console.log(`[java-tron-up] Java runtime installed at ${result.javaBinary}`); + console.log(`[java-tron-up] binary installed at ${result.binaryPath}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); + +function printHelp(): void { + console.log(`Usage: java-tron-up [install] [options] + java-tron-up cache clean [options] + +Commands: + install Install java-tron and the managed Java runtime. Default command. + cache clean Remove cached java-tron-up artifacts. + +Options: + --bin-directory Directory for the java-tron executable. + Defaults to node_modules/.bin. + --cache-directory Cache directory. Defaults to .metamask/cache. + --full-node-url FullNode.jar URL for the current platform. + --full-node-checksum Expected FullNode.jar SHA-256 checksum. + --java-runtime-url Java runtime archive URL for the current platform. + --java-runtime-checksum Expected Java runtime SHA-256 checksum. + --platform Override platform key, e.g. linux-x64. + --help Show this help text.`); +} diff --git a/packages/java-tron-up/src/index.test.ts b/packages/java-tron-up/src/index.test.ts deleted file mode 100644 index bc062d3694..0000000000 --- a/packages/java-tron-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/java-tron-up/src/index.ts b/packages/java-tron-up/src/index.ts index 6972c11729..12992703cd 100644 --- a/packages/java-tron-up/src/index.ts +++ b/packages/java-tron-up/src/index.ts @@ -1,9 +1,18 @@ -/** - * 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 { + JAVA_TRON_DEFAULT_FULL_NODE, + JAVA_TRON_DEFAULT_JAVA_RUNTIME, + cleanJavaTronCache, + getJavaTronCacheDirectory, + installJavaRuntime, + installJavaTron, + parseJavaTronInstallCliOptions, + readJavaTronInstallOptionsFromPackageJson, +} from './install'; +export type { + JavaTronArtifactConfig, + JavaTronArtifactPlatformConfig, + JavaTronInstallDependencies, + JavaTronInstallOptions, + JavaTronInstallResult, + JavaTronJavaRuntimeConfig, +} from './install'; diff --git a/packages/java-tron-up/src/install.test.ts b/packages/java-tron-up/src/install.test.ts new file mode 100644 index 0000000000..ffef0928ab --- /dev/null +++ b/packages/java-tron-up/src/install.test.ts @@ -0,0 +1,357 @@ +/* eslint-disable jest/expect-expect, n/no-sync */ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + JAVA_TRON_DEFAULT_FULL_NODE, + cleanJavaTronCache, + getJavaTronCacheDirectory, + installJavaTron, + parseJavaTronInstallCliOptions, + readJavaTronInstallOptionsFromPackageJson, +} from './install'; +import type { JavaTronInstallDependencies } from './install'; + +describe('java-tron-up installer', () => { + let tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs) { + rmSync(tempDir, { force: true, recursive: true }); + } + tempDirs = []; + }); + + it('pins the current latest java-tron release', () => { + assert.equal(JAVA_TRON_DEFAULT_FULL_NODE.version, 'GreatVoyage-v4.8.1'); + assert.equal( + JAVA_TRON_DEFAULT_FULL_NODE.platforms['darwin-arm64']?.checksum, + '694431860ee76fc986ed495f9ec19f29ed3bd752a394386e7b3b9886b2292f59', + ); + assert.equal( + JAVA_TRON_DEFAULT_FULL_NODE.platforms['linux-x64']?.checksum, + '0e67b2fe75d7077750e73c4fa20725c6e9824657275d96be256ae5da681f9945', + ); + }); + + 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( + getJavaTronCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); + + it('reads pinned installer options from package.json', () => { + const cwd = createTempDir(); + writeFileSync( + join(cwd, 'package.json'), + JSON.stringify({ + javaTronUp: { + fullNode: { + platforms: { + 'linux-x64': { + checksum: sha256('jar-from-package-json'), + url: 'https://example.test/FullNode.jar', + }, + }, + version: 'test-version', + }, + }, + }), + ); + + assert.deepEqual(readJavaTronInstallOptionsFromPackageJson({ cwd }), { + fullNode: { + platforms: { + 'linux-x64': { + checksum: sha256('jar-from-package-json'), + url: 'https://example.test/FullNode.jar', + }, + }, + version: 'test-version', + }, + }); + }); + + it('returns empty options when package.json is absent', () => { + const cwd = createTempDir(); // no package.json written + assert.deepEqual(readJavaTronInstallOptionsFromPackageJson({ cwd }), {}); + }); + + it('parses installer CLI options', () => { + assert.deepEqual( + parseJavaTronInstallCliOptions([ + '--cache-directory', + '/tmp/cache', + '--bin-directory', + '/tmp/bin', + '--full-node-url', + 'https://example.test/FullNode.jar', + '--full-node-checksum', + 'abc123', + ]), + { + binDirectory: '/tmp/bin', + cacheDirectory: '/tmp/cache', + fullNode: { + platforms: { + current: { + checksum: 'abc123', + url: 'https://example.test/FullNode.jar', + }, + }, + }, + }, + ); + }); + + it('downloads, verifies, caches, and installs the java-tron wrapper', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const downloads: { destination: string; url: string }[] = []; + const fullNodeContent = 'fake fullnode jar'; + const javaArchiveContent = 'fake java archive'; + const dependencies = createDependencies({ + downloads, + fullNodeContent, + javaArchiveContent, + }); + + const result = await installJavaTron( + { + binDirectory, + cacheDirectory, + cwd, + fullNode: { + platforms: { + 'darwin-arm64': { + checksum: sha256(fullNodeContent), + url: 'https://example.test/FullNode-aarch64.jar', + }, + }, + version: 'test-java-tron', + }, + javaRuntime: { + platforms: { + 'darwin-arm64': { + checksum: sha256(javaArchiveContent), + url: 'https://example.test/java.tar.gz', + }, + }, + version: 'test-java', + }, + platform: 'darwin-arm64', + }, + dependencies, + ); + + assert.equal(result.cacheHit, false); + assert.equal(result.version, 'test-java-tron'); + assert.equal(result.binaryPath, join(binDirectory, 'java-tron')); + assert.equal(readFileSync(result.fullNodeJar, 'utf8'), fullNodeContent); + assert.ok(result.javaBinary.endsWith('/bin/java')); + assert.ok(existsSync(result.binaryPath)); + assert.deepEqual( + downloads.map(({ url }) => url), + [ + 'https://example.test/java.tar.gz', + 'https://example.test/FullNode-aarch64.jar', + ], + ); + + const wrapperOutput = execFileSync( + process.execPath, + [result.binaryPath, '-v'], + { + encoding: 'utf8', + }, + ); + assert.equal(wrapperOutput.trim(), 'java -jar FullNode.jar -v'); + }); + + it('replaces stale bin symlinks without modifying their targets', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const fullNodeContent = 'fake fullnode jar'; + const javaArchiveContent = 'fake java archive'; + const staleTarget = join(cwd, 'stale-java-tron-target'); + + await mkdir(binDirectory, { recursive: true }); + writeFileSync(staleTarget, 'do not overwrite'); + symlinkSync(staleTarget, join(binDirectory, 'java-tron')); + + const result = await installJavaTron( + { + binDirectory, + cacheDirectory, + cwd, + fullNode: { + platforms: { + 'darwin-arm64': { + checksum: sha256(fullNodeContent), + url: 'https://example.test/FullNode-aarch64.jar', + }, + }, + }, + javaRuntime: { + platforms: { + 'darwin-arm64': { + checksum: sha256(javaArchiveContent), + url: 'https://example.test/java.tar.gz', + }, + }, + }, + platform: 'darwin-arm64', + }, + createDependencies({ fullNodeContent, javaArchiveContent }), + ); + + assert.equal(readFileSync(staleTarget, 'utf8'), 'do not overwrite'); + assert.equal(lstatSync(result.binaryPath).isSymbolicLink(), false); + }); + + it('reuses cached artifacts without downloading again', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const fullNodeContent = 'cached fullnode jar'; + const javaArchiveContent = 'cached java archive'; + + await installJavaTron( + { + binDirectory, + cacheDirectory, + cwd, + fullNode: { + platforms: { + 'linux-x64': { + checksum: sha256(fullNodeContent), + url: 'https://example.test/FullNode.jar', + }, + }, + version: 'cached-version', + }, + javaRuntime: { + platforms: { + 'linux-x64': { + checksum: sha256(javaArchiveContent), + url: 'https://example.test/java.tar.gz', + }, + }, + version: 'cached-java', + }, + platform: 'linux-x64', + }, + createDependencies({ fullNodeContent, javaArchiveContent }), + ); + + const result = await installJavaTron( + { + binDirectory, + cacheDirectory, + cwd, + fullNode: { + platforms: { + 'linux-x64': { + checksum: sha256(fullNodeContent), + url: 'https://example.test/FullNode.jar', + }, + }, + version: 'cached-version', + }, + javaRuntime: { + platforms: { + 'linux-x64': { + checksum: sha256(javaArchiveContent), + url: 'https://example.test/java.tar.gz', + }, + }, + version: 'cached-java', + }, + platform: 'linux-x64', + }, + { + downloadFile: async () => { + throw new Error('cache miss'); + }, + }, + ); + + assert.equal(result.cacheHit, true); + assert.equal(readFileSync(result.fullNodeJar, 'utf8'), fullNodeContent); + }); + + it('cleans only the java-tron-up cache namespace', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + await mkdir(join(cacheDirectory, 'java-tron-up', 'old'), { + recursive: true, + }); + await mkdir(join(cacheDirectory, 'foundryup', 'kept'), { + recursive: true, + }); + + await cleanJavaTronCache({ cacheDirectory, cwd }); + + assert.equal(existsSync(join(cacheDirectory, 'java-tron-up')), false); + assert.equal(existsSync(join(cacheDirectory, 'foundryup', 'kept')), true); + }); + + function createTempDir(): string { + const tempDir = mkdtempSync(join(tmpdir(), 'java-tron-up-test-')); + tempDirs.push(tempDir); + return tempDir; + } +}); + +function createDependencies({ + downloads = [], + fullNodeContent, + javaArchiveContent, +}: { + downloads?: { destination: string; url: string }[]; + fullNodeContent: string; + javaArchiveContent: string; +}): JavaTronInstallDependencies { + return { + downloadFile: async (url, destination): Promise => { + downloads.push({ destination, url }); + await writeFile( + destination, + url.includes('FullNode') ? fullNodeContent : javaArchiveContent, + ); + }, + extractArchive: async (_archivePath, destination): Promise => { + const javaBinary = join(destination, 'jdk', 'bin', 'java'); + await mkdir(join(destination, 'jdk', 'bin'), { recursive: true }); + await writeFile( + javaBinary, + '#!/bin/sh\nflag="$1"\njar="$2"\nshift 2\necho "java $flag $(basename "$jar") $*"\n', + ); + chmodSync(javaBinary, 0o755); + }, + }; +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/packages/java-tron-up/src/install.ts b/packages/java-tron-up/src/install.ts new file mode 100644 index 0000000000..1a0daf7576 --- /dev/null +++ b/packages/java-tron-up/src/install.ts @@ -0,0 +1,715 @@ +/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals */ +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + createReadStream, + createWriteStream, + existsSync, + readdirSync, + readFileSync, + statSync, +} from 'node:fs'; +import { chmod, mkdir, rename, rm, unlink, writeFile } from 'node:fs/promises'; +import { request as requestHttp } from 'node:http'; +import { request as requestHttps } from 'node:https'; +import { arch as osArch, homedir, platform as osPlatform } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +const JAVA_TRON_CACHE_NAMESPACE = 'java-tron-up'; +const FULL_NODE_CACHE_NAMESPACE = 'fullnode'; +const JAVA_CACHE_NAMESPACE = 'java'; + +export type JavaTronArtifactConfig = { + platforms: Record; + version?: string; +}; + +export type JavaTronArtifactPlatformConfig = { + checksum: string; + size?: number; + url: string; +}; + +export type JavaTronJavaRuntimeConfig = JavaTronArtifactConfig; + +export type JavaTronInstallOptions = { + binDirectory?: string; + cacheDirectory?: string; + cwd?: string; + fullNode?: JavaTronArtifactConfig; + javaBinary?: string; + javaRuntime?: JavaTronJavaRuntimeConfig; + platform?: string; +}; + +export type JavaTronInstallResult = { + binaryPath: string; + cacheHit: boolean; + checksum: string; + fullNodeJar: string; + javaBinary: string; + version?: string; +}; + +export type JavaTronInstallDependencies = { + downloadFile?: (url: string, destination: string) => Promise; + extractArchive?: (archivePath: string, destination: string) => Promise; +}; + +type JavaTronPackageJson = { + 'java-tron-up'?: JavaTronPackageJsonConfig; + javaTronUp?: JavaTronPackageJsonConfig; + javatronup?: JavaTronPackageJsonConfig; +}; + +type JavaTronPackageJsonConfig = Pick< + JavaTronInstallOptions, + 'binDirectory' | 'cacheDirectory' | 'fullNode' | 'javaRuntime' +>; + +export const JAVA_TRON_DEFAULT_FULL_NODE: JavaTronArtifactConfig = { + version: 'GreatVoyage-v4.8.1', + platforms: { + 'darwin-arm64': { + checksum: + '694431860ee76fc986ed495f9ec19f29ed3bd752a394386e7b3b9886b2292f59', + size: 202_460_186, + url: 'https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.8.1/FullNode-aarch64.jar', + }, + 'darwin-x64': { + checksum: + '0e67b2fe75d7077750e73c4fa20725c6e9824657275d96be256ae5da681f9945', + size: 145_863_030, + url: 'https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.8.1/FullNode.jar', + }, + 'linux-arm64': { + checksum: + '694431860ee76fc986ed495f9ec19f29ed3bd752a394386e7b3b9886b2292f59', + size: 202_460_186, + url: 'https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.8.1/FullNode-aarch64.jar', + }, + 'linux-x64': { + checksum: + '0e67b2fe75d7077750e73c4fa20725c6e9824657275d96be256ae5da681f9945', + size: 145_863_030, + url: 'https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.8.1/FullNode.jar', + }, + }, +}; + +export const JAVA_TRON_DEFAULT_JAVA_RUNTIME: JavaTronJavaRuntimeConfig = { + version: 'zulu-java8-x64-java17-arm64', + platforms: { + 'darwin-arm64': { + checksum: + 'f2bd5afaaaa4c23eb4bf2c78913c7eb7d3d228e44209ffec652fb72388a2f25c', + size: 192_646_000, + url: 'https://cdn.azul.com/zulu/bin/zulu17.66.19-ca-jdk17.0.19-macosx_aarch64.tar.gz', + }, + 'darwin-x64': { + checksum: + '4ac2efcae5d49afe1f2419ceb09bd3fb4af9df8411ab80184795960fc18fb5f6', + size: 41_346_500, + url: 'https://cdn.azul.com/zulu/bin/zulu8.94.0.17-ca-jre8.0.492-macosx_x64.tar.gz', + }, + 'linux-arm64': { + checksum: + 'c17d5657a673c0cfc099e9d803ed30498495894d7359fd1064d463093ed9850b', + size: 199_156_000, + url: 'https://cdn.azul.com/zulu/bin/zulu17.66.19-ca-jdk17.0.19-linux_aarch64.tar.gz', + }, + 'linux-x64': { + checksum: + '39abf1dc6798b5f6b8e9dca4e78994da316a3f990e444c2c483ea04f7f882cf2', + size: 42_504_400, + url: 'https://cdn.azul.com/zulu/bin/zulu8.94.0.17-ca-jre8.0.492-linux_x64.tar.gz', + }, + }, +}; + +export function getJavaTronCacheDirectory({ + cwd = process.cwd(), + homeDirectory = homedir(), +}: { + cwd?: string; + homeDirectory?: string; +} = {}): string { + const yarnRcPath = join(cwd, '.yarnrc.yml'); + + try { + const yarnRc = readFileSync(yarnRcPath, 'utf8'); + if (/^\s*enableGlobalCache:\s*true\s*$/mu.test(yarnRc)) { + return join(homeDirectory, '.cache', 'metamask'); + } + } catch (error) { + if (!isFileMissingError(error)) { + console.warn( + `Warning: Error reading ${yarnRcPath}, using local java-tron-up cache:`, + error, + ); + } + } + + return join(cwd, '.metamask', 'cache'); +} + +export function readJavaTronInstallOptionsFromPackageJson({ + cwd = process.cwd(), + packageJsonPath = join(cwd, 'package.json'), +}: { + cwd?: string; + packageJsonPath?: string; +} = {}): JavaTronInstallOptions { + let raw: string; + try { + raw = readFileSync(packageJsonPath, 'utf8'); + } catch (error) { + if (isFileMissingError(error)) { + return {}; + } + throw error; + } + const packageJson = JSON.parse(raw) as JavaTronPackageJson; + const config = + packageJson.javaTronUp ?? + packageJson.javatronup ?? + packageJson['java-tron-up']; + const options: JavaTronInstallOptions = {}; + + if (config?.binDirectory) { + options.binDirectory = config.binDirectory; + } + if (config?.cacheDirectory) { + options.cacheDirectory = config.cacheDirectory; + } + if (config?.fullNode) { + options.fullNode = config.fullNode; + } + if (config?.javaRuntime) { + options.javaRuntime = config.javaRuntime; + } + + return options; +} + +export function parseJavaTronInstallCliOptions( + args: string[], +): JavaTronInstallOptions { + const options: JavaTronInstallOptions = {}; + const fullNode: Partial = {}; + const javaRuntime: 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 '--full-node-checksum': + fullNode.checksum = readCliValue(arg, value); + index += 1; + break; + case '--full-node-url': + fullNode.url = readCliValue(arg, value); + index += 1; + break; + case '--java-runtime-checksum': + javaRuntime.checksum = readCliValue(arg, value); + index += 1; + break; + case '--java-runtime-url': + javaRuntime.url = readCliValue(arg, value); + index += 1; + break; + case '--platform': + options.platform = readCliValue(arg, value); + index += 1; + break; + default: + throw new Error(`Unknown java-tron-up install option: ${arg}`); + } + } + + if (fullNode.url || fullNode.checksum) { + options.fullNode = { + platforms: { + current: requireCompletePlatformConfig( + fullNode, + 'FullNode CLI options', + ), + }, + }; + } + + if (javaRuntime.url || javaRuntime.checksum) { + options.javaRuntime = { + platforms: { + current: requireCompletePlatformConfig( + javaRuntime, + 'Java runtime CLI options', + ), + }, + }; + } + + return options; +} + +export async function installJavaTron( + options: JavaTronInstallOptions = {}, + dependencies: JavaTronInstallDependencies = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getJavaTronCacheDirectory({ cwd }); + const binDirectory = + options.binDirectory ?? join(cwd, 'node_modules', '.bin'); + const platformKey = options.platform ?? getPlatformKey(); + const fullNodeConfig = resolvePlatformConfig( + options.fullNode ?? JAVA_TRON_DEFAULT_FULL_NODE, + platformKey, + 'java-tron FullNode', + ); + const javaBinary = + options.javaBinary ?? + (await installJavaRuntime( + { + cacheDirectory, + javaRuntime: options.javaRuntime ?? JAVA_TRON_DEFAULT_JAVA_RUNTIME, + platform: platformKey, + }, + dependencies, + )); + const fullNodeResult = await installFullNodeJar( + { + cacheDirectory, + config: fullNodeConfig, + }, + dependencies, + ); + const binaryPath = await installJavaTronBinary({ + binDirectory, + fullNodeJar: fullNodeResult.fullNodeJar, + javaBinary, + }); + + return { + binaryPath, + cacheHit: fullNodeResult.cacheHit, + checksum: fullNodeConfig.checksum, + fullNodeJar: fullNodeResult.fullNodeJar, + javaBinary, + version: (options.fullNode ?? JAVA_TRON_DEFAULT_FULL_NODE).version, + }; +} + +export async function cleanJavaTronCache( + options: Pick = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getJavaTronCacheDirectory({ cwd }); + + await rm(join(cacheDirectory, JAVA_TRON_CACHE_NAMESPACE), { + force: true, + recursive: true, + }); +} + +export async function installJavaRuntime( + { + cacheDirectory = getJavaTronCacheDirectory(), + javaRuntime = JAVA_TRON_DEFAULT_JAVA_RUNTIME, + platform = getPlatformKey(), + }: { + cacheDirectory?: string; + javaRuntime?: JavaTronJavaRuntimeConfig; + platform?: string; + } = {}, + dependencies: JavaTronInstallDependencies = {}, +): Promise { + const platformConfig = resolvePlatformConfig( + javaRuntime, + platform, + 'java-tron Java runtime', + ); + const cacheKey = getCacheKey(platformConfig); + const cacheRoot = join( + cacheDirectory, + JAVA_TRON_CACHE_NAMESPACE, + JAVA_CACHE_NAMESPACE, + cacheKey, + ); + const existingJavaBinary = findJavaBinary(cacheRoot); + + if (existingJavaBinary) { + return existingJavaBinary; + } + + const tempRoot = `${cacheRoot}.downloading`; + const archivePath = join(tempRoot, 'java-runtime.tar.gz'); + const downloadFile = dependencies.downloadFile ?? downloadFileFromUrl; + const extractArchive = dependencies.extractArchive ?? extractTarGzArchive; + + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + await mkdir(tempRoot, { recursive: true }); + + try { + await downloadFile(platformConfig.url, archivePath); + await verifyFileChecksum( + archivePath, + platformConfig.checksum, + 'Downloaded Java runtime', + ); + await extractArchive(archivePath, tempRoot); + + const javaBinary = findJavaBinary(tempRoot); + if (!javaBinary) { + throw new Error( + `Java runtime archive for ${platform} did not contain bin/java.`, + ); + } + + await mkdir(dirname(cacheRoot), { recursive: true }); + await rename(tempRoot, cacheRoot); + + return javaBinary.replace(tempRoot, cacheRoot); + } catch (error) { + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + throw error; + } +} + +async function installFullNodeJar( + { + cacheDirectory, + config, + }: { + cacheDirectory: string; + config: JavaTronArtifactPlatformConfig; + }, + dependencies: JavaTronInstallDependencies, +): Promise<{ cacheHit: boolean; fullNodeJar: string }> { + const cacheKey = getCacheKey(config); + const cacheRoot = join( + cacheDirectory, + JAVA_TRON_CACHE_NAMESPACE, + FULL_NODE_CACHE_NAMESPACE, + cacheKey, + ); + const fullNodeJar = join(cacheRoot, 'FullNode.jar'); + + if (existsSync(fullNodeJar)) { + await verifyFileChecksum( + fullNodeJar, + config.checksum, + 'Cached java-tron FullNode', + ); + return { cacheHit: true, fullNodeJar }; + } + + const tempRoot = `${cacheRoot}.downloading`; + const tempFullNodeJar = join(tempRoot, 'FullNode.jar'); + const downloadFile = dependencies.downloadFile ?? downloadFileFromUrl; + + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + await mkdir(tempRoot, { recursive: true }); + + try { + await downloadFile(config.url, tempFullNodeJar); + await verifyFileChecksum( + tempFullNodeJar, + config.checksum, + 'Downloaded java-tron FullNode', + ); + await mkdir(dirname(cacheRoot), { recursive: true }); + await rename(tempRoot, cacheRoot); + + return { cacheHit: false, fullNodeJar }; + } catch (error) { + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + throw error; + } +} + +async function installJavaTronBinary({ + binDirectory, + fullNodeJar, + javaBinary, +}: { + binDirectory: string; + fullNodeJar: string; + javaBinary: string; +}): Promise { + const binaryPath = join(binDirectory, 'java-tron'); + const relativeJavaBinary = relative(binDirectory, javaBinary); + const relativeFullNodeJar = relative(binDirectory, fullNodeJar); + + await mkdir(binDirectory, { recursive: true }); + await unlink(binaryPath).catch((error) => { + if (!isFileMissingError(error)) { + throw error; + } + }); + await writeFile( + binaryPath, + `#!/usr/bin/env node +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const javaBinary = path.resolve(__dirname, ${JSON.stringify(relativeJavaBinary)}); +const fullNodeJar = path.resolve(__dirname, ${JSON.stringify(relativeFullNodeJar)}); +const result = spawnSync(javaBinary, ['-jar', fullNodeJar, ...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(result.status ?? 0); +`, + ); + await chmod(binaryPath, 0o755); + + return binaryPath; +} + +function resolvePlatformConfig( + config: JavaTronArtifactConfig, + platform: string, + label: string, +): JavaTronArtifactPlatformConfig { + const platformConfig = config.platforms[platform] ?? config.platforms.current; + + if (!platformConfig) { + throw new Error(`No ${label} is configured for ${platform}.`); + } + + return platformConfig; +} + +function requireCompletePlatformConfig( + config: Partial, + label: string, +): JavaTronArtifactPlatformConfig { + if (!config.url || !config.checksum) { + throw new Error(`${label} require both a URL and a checksum.`); + } + + return { + checksum: config.checksum, + url: config.url, + }; +} + +function getCacheKey(config: JavaTronArtifactPlatformConfig): string { + return createHash('sha256') + .update(`${config.url}:${config.checksum}`) + .digest('hex'); +} + +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}.`, + ); + } +} + +async function downloadFileFromUrl( + url: string, + destination: string, +): Promise { + await mkdir(dirname(destination), { recursive: true }); + await pipeline( + await openDownloadStream(new URL(url)), + createWriteStream(destination), + ); +} + +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(); + }); +} + +async function extractTarGzArchive( + archivePath: string, + destination: string, +): Promise { + await runCommand('tar', ['-xzf', archivePath, '-C', destination]); +} + +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) => { + if (code === 0) { + resolvePromise(); + return; + } + + rejectPromise( + new Error( + `${command} ${args.join(' ')} exited with code ${code}: ${stderr}`, + ), + ); + }); + }); +} + +function findJavaBinary(root: string): string | undefined { + if (!isDirectory(root)) { + return undefined; + } + + const candidate = join(root, 'bin', 'java'); + if (isFile(candidate)) { + return candidate; + } + + for (const entry of readdirSync(root)) { + const child = join(root, entry); + if (!isDirectory(child)) { + continue; + } + + const found = findJavaBinary(child); + if (found) { + return found; + } + } + + return undefined; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function getPlatformKey(): string { + return `${osPlatform()}-${normalizeSystemArchitecture()}`; +} + +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; +} + +function readCliValue(option: string, value: string | undefined): string { + if (!value || value.startsWith('--')) { + throw new Error(`${option} requires a value.`); + } + return value; +} + +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/yarn.lock b/yarn.lock index 680dd9e188..932fcb9634 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7056,6 +7056,8 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + bin: + java-tron-up: ./dist/bin/java-tron-up.mjs languageName: unknown linkType: soft