diff --git a/eslint.config.js b/eslint.config.js index b28a8ae..fbe2b6d 100755 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,10 +41,11 @@ export default tseslint.config({ '@typescript-eslint/require-await': 'warn', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-member-access': 'warn', + '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-redundant-type-constituents': 'warn', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/only-throw-error': 'off', }, }); diff --git a/package-lock.json b/package-lock.json index eb8dda3..38b523f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-prototype", "license": "MIT", "dependencies": { - "@types/tar": "^6.1.13", + "adm-zip": "^0.5.16", "postject": "^1.0.0-alpha.6", "tar": "^7.4.3" }, @@ -18,7 +18,9 @@ }, "devDependencies": { "@eslint/js": "^9.15.0", + "@types/adm-zip": "^0.5.7", "@types/node": "^22.10.1", + "@types/tar": "^6.1.13", "eslint": "^9.15.0", "globals": "^15.12.0", "prettier": "^3.4.1", @@ -309,6 +311,16 @@ "node": ">=14" } }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -327,6 +339,7 @@ "version": "22.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.20.0" @@ -336,6 +349,7 @@ "version": "6.1.13", "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.13.tgz", "integrity": "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -346,6 +360,7 @@ "version": "4.2.8", "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=8" @@ -598,6 +613,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1988,6 +2012,7 @@ "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, "license": "MIT" }, "node_modules/uri-js": { diff --git a/package.json b/package.json index ce5a499..85a5df7 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,14 @@ "format": "prettier --write .", "format:check": "prettier --check .", "lint": "tsc -p tsconfig.json --noEmit && eslint src", - "build": "tsconfig -p tsconfig.json", + "build": "tsc -p tsconfig.json", "prepublishOnly": "npm run build" }, "devDependencies": { "@eslint/js": "^9.15.0", + "@types/adm-zip": "^0.5.7", "@types/node": "^22.10.1", + "@types/tar": "^6.1.13", "eslint": "^9.15.0", "globals": "^15.12.0", "prettier": "^3.4.1", @@ -47,7 +49,7 @@ "typescript-eslint": "^8.16.0" }, "dependencies": { - "@types/tar": "^6.1.13", + "adm-zip": "^0.5.16", "postject": "^1.0.0-alpha.6", "tar": "^7.4.3" } diff --git a/src/cli.ts b/src/cli.ts index ef57a36..a420e20 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,11 @@ +#!/bin/env node import { execSync } from 'node:child_process'; import * as fs from 'node:fs'; import { dirname, join, parse } from 'node:path'; import { parseArgs } from 'node:util'; import { inject } from 'postject'; import { extract } from 'tar'; +import AdmZip from 'adm-zip'; const { values: options, positionals } = parseArgs({ options: { @@ -13,6 +15,7 @@ const { values: options, positionals } = parseArgs({ output: { short: 'o', type: 'string' }, clean: { type: 'boolean', default: false }, node: { short: 'N', type: 'string', default: 'v' + process.versions.node }, + platform: { short: 'P', type: 'string', multiple: true, default: [process.platform + '-' + process.arch] }, }, allowPositionals: true, }); @@ -29,7 +32,9 @@ Options: --quiet,-q Hide non-error output --verbose,-w Show all output --output, -o The output prefix - --tempd Temporary files directory, + --clean Remove temporary files + --node,-N Specify the Node version + --platform, -P Specify which platform(s) to build for `); process.exit(0); } @@ -39,21 +44,31 @@ if (options.verbose && options.quiet) { process.exit(1); } +if (options.clean) { + _log('Removing temporary files...'); + fs.rmSync(tempDir, { recursive: true, force: true }); +} + if (positionals.length != 1) { + if (options.clean) process.exit(0); console.error('Incorrect number of positional arguments, expected 1'); process.exit(1); } -const prefix = options.output ?? parse(positionals[0]).name; +const entryName = parse(positionals[0]).name; -if (options.clean) { - _log('Removing temporary files...'); - fs.rmSync(tempDir, { recursive: true }); +let prefix = options.output ?? entryName; + +if (/\w$/.test(prefix)) { + prefix += '-'; } -fs.mkdirSync(tempDir, { recursive: true }); -const configPath = join(tempDir, 'sea.json'), - blobPath = join(tempDir, 'server.blob'); +_log('Prefix:', prefix); + +fs.mkdirSync(join(tempDir, 'node'), { recursive: true }); + +const configPath = join(tempDir, entryName + '.json'), + blobPath = join(tempDir, entryName + '.blob'); fs.writeFileSync( configPath, @@ -69,45 +84,76 @@ const blob = fs.readFileSync(blobPath); fs.mkdirSync(prefix.endsWith('/') ? prefix : dirname(prefix), { recursive: true }); -/** - * Builds a SEA for a target (e.g. win-x64, linux-arm64) - */ -async function buildSEA(target: string) { - !options.quiet && console.log('Creating SEA for:', target); - const isWindows = target.startsWith('win'); - - const seaPath = join(prefix, isWindows ? target + '.exe' : target); +async function getNode(archiveBase: string) { + const isWindows = archiveBase.startsWith('node-win'); - const archiveFile = `node-${options.node}-${target}.${isWindows ? 'zip' : 'tar.gz'}`; + const archiveFile = archiveBase + '.' + (isWindows ? 'zip' : 'tar.gz'); const archivePath = join(tempDir, archiveFile); - try { - const url = `https://nodejs.org/dist/${options.node}/${archiveFile}`; - _log('Fetching:', url); - const response = await fetch(url); - fs.writeFileSync(archivePath, new Uint8Array(await response.arrayBuffer())); - } catch { - console.error(`Failed to download Node v${options.node} for ${target}`); + const execName = join(tempDir, archiveBase); + + if (fs.existsSync(execName)) { + _log('Found existing:', archiveBase); return; } - const extractedDir = join(tempDir, target); - fs.mkdirSync(extractedDir, { recursive: true }); + if (fs.existsSync(archivePath)) { + _log('Found existing archive:', archiveFile); + } else { + try { + const url = `https://nodejs.org/dist/${options.node}/${archiveFile}`; + _log('Fetching:', url); + const response = await fetch(url); + fs.writeFileSync(archivePath, new Uint8Array(await response.arrayBuffer())); + } catch { + throw ['Failed to download:', archiveBase]; + } + } - _log('Extracting:', archivePath); + _log('Extracting:', archiveFile); if (isWindows) { + const zip = new AdmZip(archivePath); + const data = zip.readFile(isWindows ? 'node.exe' : 'bin/node'); + if (!data) { + throw ['Missing node executable:', archiveBase]; + } + fs.writeFileSync(execName, data); } else { await extract({ file: archivePath, gzip: true, - cwd: extractedDir, + cwd: join(tempDir, 'node'), }); + fs.copyFileSync(join(tempDir, 'node', archiveBase, isWindows ? 'node.exe' : 'bin/node'), execName); + } +} + +/** + * Builds a SEA for a target (e.g. win-x64, linux-arm64) + */ +async function buildSEA(target: string) { + !options.quiet && console.log('Creating SEA for:', target); + const isWindows = target.startsWith('win'); + + const seaPath = prefix + (isWindows ? target + '.exe' : target); + + const archiveBase = `node-${options.node}-${target}`; + + try { + await getNode(archiveBase); + } catch (e: any) { + console.error(...(Array.isArray(e) ? e : [e])); + return; } fs.mkdirSync(dirname(seaPath), { recursive: true }); - fs.copyFileSync(join(extractedDir, isWindows ? 'node.exe' : 'node'), seaPath); + fs.copyFileSync(join(tempDir, archiveBase), seaPath); _log('Injecting:', seaPath); - inject(seaPath, 'NODE_SEA_BLOB', blob, { + await inject(seaPath, 'NODE_SEA_BLOB', blob, { machoSegmentName: 'NODE_SEA', sentinelFuse: 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', }); } + +for (const target of options.platform) { + await buildSEA(target); +} diff --git a/src/postject.d.ts b/src/postject.d.ts index 97fe3a2..31a3c23 100644 --- a/src/postject.d.ts +++ b/src/postject.d.ts @@ -16,5 +16,5 @@ declare module 'postject' { sentinelFuse?: string; } - function inject(filename: string, resourceName: string, resourceData: Buffer, options: InjectOptions): void; + function inject(filename: string, resourceName: string, resourceData: Buffer, options: InjectOptions): Promise; }