1+ // Build SEA for all platforms (win/linux/macos) from any host.
2+ // - Creates sea-prep.blob once from resources/external/jbrowse.js
3+ // - Downloads official Node runtimes for the same Node version as process.execPath
4+ // - Injects the blob into each runtime with postject
5+ // - Writes outputs to resources/external/jb-cli:
6+ // cli-win.exe, cli-linux, cli-macos
7+ // - Cleans up temp files and resources/external/jbrowse.js at the end
8+
19import { execFileSync , execSync , spawnSync } from 'node:child_process' ;
2- import { copyFileSync , chmodSync , existsSync , mkdirSync , rmSync , renameSync , writeFileSync } from 'node:fs' ;
10+ import {
11+ copyFileSync , chmodSync , existsSync , mkdirSync , rmSync , renameSync , writeFileSync
12+ } from 'node:fs' ;
313import { basename , join , resolve } from 'node:path' ;
4-
5- const input = process . argv [ 2 ] ;
6- if ( ! input ) {
7- console . error ( 'Usage: node build-sea.mjs <input.js> [outputBaseName]' ) ;
8- process . exit ( 1 ) ;
9- }
10- const inputAbs = resolve ( input ) ;
11- const baseName = process . argv [ 3 ] || basename ( input , '.js' ) ;
14+ import https from 'node:https' ;
15+ import { createWriteStream } from 'node:fs' ;
1216
1317const ROOT = resolve ( '.' ) ;
1418const OUTDIR = join ( ROOT , 'resources' , 'external' , 'jb-cli' ) ;
15- mkdirSync ( OUTDIR , { recursive : true } ) ;
16-
17- const platform = process . platform ; // 'win32' | 'darwin' | 'linux'
18- const outName =
19- platform === 'win32' ? 'cli-win.exe' :
20- platform === 'darwin' ? 'cli-macos' : 'cli-linux' ;
21-
22- const tmpCfg = 'sea-config.json' ;
23- const tmpBlob = 'sea-prep.blob' ;
24- const tmpOut = `${ baseName } ${ platform === 'win32' ? '.exe' : '' } ` ;
19+ const TMPDIR = join ( ROOT , '.sea-tmp' ) ;
20+ const INPUT_JS = join ( ROOT , 'resources' , 'external' , 'jbrowse.js' ) ;
21+
22+ const NODE_VERSION = process . versions . node ;
23+ const DIST_BASE = process . env . NODE_DIST_URL || 'https://nodejs.org/dist' ;
24+ const TARGETS = [
25+ [ 'win-x64' , 'cli-win.exe' , 'zip' ] ,
26+ [ 'linux-x64' , 'cli-linux' , 'tar.xz' ] ,
27+ [ 'darwin-x64' , 'cli-macos' , 'tar.xz' ] ,
28+ // ['darwin-arm64','cli-macos-arm64','tar.xz'],
29+ // ['linux-arm64','cli-linux-arm64','tar.xz'],
30+ ] ;
2531
26- console . log ( `SEA build: ${ inputAbs } -> ${ join ( OUTDIR , outName ) } [${ platform } ]` ) ;
32+ const SENTINEL = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2' ;
33+ const POSTJECT_PKG = 'postject@1.0.0-alpha.6' ;
2734
28- // 1) Create SEA config
29- const cfg = {
30- main : inputAbs ,
31- output : tmpBlob ,
32- disableExperimentalSEAWarning : true ,
33- } ;
34- writeFileSync ( tmpCfg , JSON . stringify ( cfg , null , 2 ) ) ;
35+ function log ( s ) { console . log ( s ) ; }
36+ function warn ( s ) { console . warn ( s ) ; }
37+ function fail ( e ) { console . error ( e ) ; process . exit ( 1 ) ; }
3538
36- // 2) Produce blob
37- execFileSync ( process . execPath , [ '--experimental-sea-config' , tmpCfg ] , { stdio : 'inherit' } ) ;
39+ function ensureDirs ( ) {
40+ mkdirSync ( OUTDIR , { recursive : true } ) ;
41+ mkdirSync ( TMPDIR , { recursive : true } ) ;
42+ }
3843
39- // 3) Copy current Node runtime as the base executable
40- copyFileSync ( process . execPath , tmpOut ) ;
44+ function httpDownload ( url , dest ) {
45+ return new Promise ( ( resolveP , rejectP ) => {
46+ const file = createWriteStream ( dest ) ;
47+ https . get ( url , res => {
48+ if ( res . statusCode && res . statusCode >= 300 && res . statusCode < 400 && res . headers . location ) {
49+ httpDownload ( res . headers . location , dest ) . then ( resolveP , rejectP ) ;
50+ return ;
51+ }
52+ if ( res . statusCode !== 200 ) {
53+ rejectP ( new Error ( `Download failed ${ res . statusCode } : ${ url } ` ) ) ;
54+ return ;
55+ }
56+ res . pipe ( file ) ;
57+ file . on ( 'finish' , ( ) => file . close ( ( ) => resolveP ( void 0 ) ) ) ;
58+ } ) . on ( 'error' , err => { rejectP ( err ) ; } ) ;
59+ } ) ;
60+ }
4161
42- // 4) Remove signature (it'll be invalid once we postject the Jbrowse CLI into the node runtime executable)
43- if ( platform === 'darwin' ) {
44- try { spawnSync ( 'codesign' , [ '--remove-signature' , tmpOut ] , { stdio : 'inherit' } ) ; } catch { }
62+ function runOrThrow ( cmd , args , opts = { } ) {
63+ execFileSync ( cmd , args , { stdio : 'inherit' , ...opts } ) ;
4564}
46- if ( platform === 'win32' ) {
47- try { spawnSync ( 'signtool' , [ 'remove' , '/s' , tmpOut ] , { stdio : 'inherit' } ) ; } catch { }
65+
66+ function shellOrThrow ( command ) {
67+ execSync ( command , { stdio : 'inherit' , shell : true } ) ;
4868}
4969
50- // Helper: run postject with multiple fallbacks
51- function runPostject ( argsList ) {
52- const isWin = platform === 'win32' ;
70+ // postject runner with fallbacks
71+ function runPostject ( args ) {
72+ const isWin = process . platform === 'win32' ;
5373 const npxCmd = isWin ? 'npx.cmd' : 'npx' ;
5474 const npmCmd = isWin ? 'npm.cmd' : 'npm' ;
55- const postjectPkg = 'postject@1.0.0-alpha.6' ;
56-
57- // Attempt 1: npx (execFileSync)
58- try {
59- execFileSync ( npxCmd , [ '--yes' , postjectPkg , ...argsList ] , { stdio : 'inherit' } ) ;
60- return ;
61- } catch ( e1 ) {
62- console . warn ( '[postject] npx (execFileSync) failed, trying npm exec…' ) ;
63- // Attempt 2: npm exec (execFileSync)
64- try {
65- execFileSync ( npmCmd , [ 'exec' , '-y' , postjectPkg , '--' , ...argsList ] , { stdio : 'inherit' } ) ;
66- return ;
67- } catch ( e2 ) {
68- console . warn ( '[postject] npm exec (execFileSync) failed, trying shell npx…' ) ;
69- // Attempt 3: npx via shell (execSync)
70- try {
71- const cmd = `npx --yes ${ postjectPkg } ${ argsList . map ( a => `"${ a } "` ) . join ( ' ' ) } ` ;
72- execSync ( cmd , { stdio : 'inherit' , shell : true } ) ;
73- return ;
74- } catch ( e3 ) {
75- console . warn ( '[postject] shell npx failed, trying shell npm exec…' ) ;
76- // Attempt 4: npm exec via shell (execSync)
77- const cmd2 = `npm exec -y ${ postjectPkg } -- ${ argsList . map ( a => `"${ a } "` ) . join ( ' ' ) } ` ;
78- execSync ( cmd2 , { stdio : 'inherit' , shell : true } ) ;
79- }
80- }
81- }
75+ try { runOrThrow ( npxCmd , [ '--yes' , POSTJECT_PKG , ...args ] ) ; return ; }
76+ catch { warn ( '[postject] npx failed, trying npm exec…' ) ; }
77+ try { runOrThrow ( npmCmd , [ 'exec' , '-y' , POSTJECT_PKG , '--' , ...args ] ) ; return ; }
78+ catch { warn ( '[postject] npm exec failed, trying shell npx…' ) ; }
79+ try { shellOrThrow ( `npx --yes ${ POSTJECT_PKG } ${ args . map ( a => `"${ a } "` ) . join ( ' ' ) } ` ) ; return ; }
80+ catch { warn ( '[postject] shell npx failed, trying shell npm exec…' ) ; }
81+ shellOrThrow ( `npm exec -y ${ POSTJECT_PKG } -- ${ args . map ( a => `"${ a } "` ) . join ( ' ' ) } ` ) ;
8282}
8383
84- // Postject magic from the docs to do the jbrowse->node appending
85- const SENTINEL = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2' ;
86- const postjectArgs = platform === 'darwin'
87- ? [ tmpOut , 'NODE_SEA_BLOB' , tmpBlob , '--sentinel-fuse' , SENTINEL , '--macho-segment-name' , 'NODE_SEA' ]
88- : [ tmpOut , 'NODE_SEA_BLOB' , tmpBlob , '--sentinel-fuse' , SENTINEL ] ;
84+ function extractZip ( zipPath , outDir ) {
85+ if ( process . platform === 'win32' ) {
86+ shellOrThrow ( `powershell -NoProfile -Command "Expand-Archive -Force '${ zipPath . replace ( / ' / g, "''" ) } ' '${ outDir . replace ( / ' / g, "''" ) } '"` ) ;
87+ } else {
88+ shellOrThrow ( `unzip -o "${ zipPath } " -d "${ outDir } "` ) ;
89+ }
90+ }
8991
90- // 5) Inject the SEA blob
91- runPostject ( postjectArgs ) ;
92+ // extract only bin/node from tar.xz to avoid symlink issues on Windows
93+ function extractNodeFromTarXz ( tarxzPath , outDir , target ) {
94+ const innerPath = `node-v${ NODE_VERSION } -${ target } /bin/node` ;
95+ try { shellOrThrow ( `tar -xJf "${ tarxzPath } " -C "${ outDir } " "${ innerPath } "` ) ; }
96+ catch { shellOrThrow ( `bsdtar -xf "${ tarxzPath } " -C "${ outDir } " "${ innerPath } "` ) ; }
97+ return join ( outDir , innerPath ) ;
98+ }
9299
93- // 6) Re-sign for the new combined executable
94- if ( platform === 'darwin' ) {
95- try { spawnSync ( 'codesign' , [ '--sign' , '-' , tmpOut ] , { stdio : 'inherit' } ) ; } catch { }
100+ function nodeBinaryPathFromExtract ( dir , target ) {
101+ const base = `node-v${ NODE_VERSION } -${ target } ` ;
102+ if ( target . startsWith ( 'win' ) ) return join ( dir , base , 'node.exe' ) ;
103+ return join ( dir , base , 'bin' , 'node' ) ;
96104}
97- if ( platform === 'win32' ) {
98- try { spawnSync ( 'signtool' , [ 'sign' , '/fd' , 'SHA256' , tmpOut ] , { stdio : 'inherit' } ) ; } catch { }
105+
106+ function injectForTarget ( target , outName , archiveExt , blobPath ) {
107+ const distUrl = `${ DIST_BASE } /v${ NODE_VERSION } /node-v${ NODE_VERSION } -${ target } .${ archiveExt } ` ;
108+ const dlPath = join ( TMPDIR , `node-${ target } .${ archiveExt } ` ) ;
109+
110+ log ( `\n=== Target ${ target } ===` ) ;
111+ log ( `Downloading: ${ distUrl } ` ) ;
112+ httpDownload ( distUrl , dlPath ) . then ( ( ) => {
113+ log ( 'Extracting…' ) ;
114+ let nodePath ;
115+ if ( archiveExt === 'zip' ) {
116+ extractZip ( dlPath , TMPDIR ) ;
117+ nodePath = nodeBinaryPathFromExtract ( TMPDIR , target ) ;
118+ } else {
119+ nodePath = extractNodeFromTarXz ( dlPath , TMPDIR , target ) ;
120+ }
121+ if ( ! existsSync ( nodePath ) ) fail ( `node binary not found in ${ nodePath } ` ) ;
122+
123+ const workExe = join ( TMPDIR , `work-${ outName } ` ) ;
124+ copyFileSync ( nodePath , workExe ) ;
125+
126+ const postjectArgs = target . startsWith ( 'darwin' )
127+ ? [ workExe , 'NODE_SEA_BLOB' , blobPath , '--sentinel-fuse' , SENTINEL , '--macho-segment-name' , 'NODE_SEA' ]
128+ : [ workExe , 'NODE_SEA_BLOB' , blobPath , '--sentinel-fuse' , SENTINEL ] ;
129+
130+ log ( 'Injecting SEA blob…' ) ;
131+ runPostject ( postjectArgs ) ;
132+
133+ if ( ! target . startsWith ( 'win' ) ) chmodSync ( workExe , 0o755 ) ;
134+
135+ const finalPath = join ( OUTDIR , outName ) ;
136+ if ( existsSync ( finalPath ) ) rmSync ( finalPath , { force : true } ) ;
137+ renameSync ( workExe , finalPath ) ;
138+
139+ rmSync ( dlPath , { force : true } ) ;
140+ try { rmSync ( join ( TMPDIR , `node-v${ NODE_VERSION } -${ target } ` ) , { recursive : true , force : true } ) ; } catch { }
141+ log ( `Wrote ${ finalPath } ` ) ;
142+ } ) . catch ( fail ) ;
99143}
100144
101- // 7) POSIX chmod
102- if ( platform !== 'win32' ) {
103- chmodSync ( tmpOut , 0o755 ) ;
145+ function buildBlobOnce ( ) {
146+ const cfgPath = join ( TMPDIR , 'sea-config.json' ) ;
147+ const blobPath = join ( TMPDIR , 'sea-prep.blob' ) ;
148+ log ( 'Creating SEA blob…' ) ;
149+ const cfg = { main : INPUT_JS , output : blobPath , disableExperimentalSEAWarning : true } ;
150+ writeFileSync ( cfgPath , JSON . stringify ( cfg , null , 2 ) ) ;
151+ runOrThrow ( process . execPath , [ '--experimental-sea-config' , cfgPath ] ) ;
152+ return blobPath ;
104153}
105154
106- // 8) Move to final destination
107- const finalPath = join ( OUTDIR , outName ) ;
108- if ( existsSync ( finalPath ) ) rmSync ( finalPath , { force : true } ) ;
109- renameSync ( tmpOut , finalPath ) ;
155+ async function main ( ) {
156+ ensureDirs ( ) ;
157+ if ( ! existsSync ( INPUT_JS ) ) fail ( `Missing ${ INPUT_JS } . Run your fetch step first.` ) ;
158+ rmSync ( TMPDIR , { recursive : true , force : true } ) ;
159+ mkdirSync ( TMPDIR , { recursive : true } ) ;
160+
161+ const blob = buildBlobOnce ( ) ;
162+
163+ for ( const [ target , outName , ext ] of TARGETS ) {
164+ await new Promise ( ( resolveP , rejectP ) => {
165+ try {
166+ injectForTarget ( target , outName , ext , blob ) ;
167+ const interval = setInterval ( ( ) => {
168+ if ( existsSync ( join ( OUTDIR , outName ) ) ) {
169+ clearInterval ( interval ) ;
170+ resolveP ( ) ;
171+ }
172+ } , 500 ) ;
173+ } catch ( e ) {
174+ rejectP ( e ) ;
175+ }
176+ } ) ;
177+ }
110178
111- // 9) Cleanup
112- rmSync ( tmpCfg , { force : true } ) ;
113- rmSync ( tmpBlob , { force : true } ) ;
179+ try { rmSync ( TMPDIR , { recursive : true , force : true } ) ; } catch { }
180+ try { if ( existsSync ( INPUT_JS ) ) rmSync ( INPUT_JS , { force : true } ) ; } catch { }
114181
115- const jbrowseJsPath = join ( ROOT , 'resources' , 'external' , 'jbrowse.js' ) ;
116- if ( existsSync ( jbrowseJsPath ) ) {
117- rmSync ( jbrowseJsPath , { force : true } ) ;
182+ log ( '\nAll targets built.' ) ;
118183}
119184
120- console . log ( `Done: ${ finalPath } ` ) ;
185+ main ( ) . catch ( fail ) ;
0 commit comments