Skip to content
Merged
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,7 @@ artifacts
.cursor/
.claude/
CLAUDE.md

# Bundled Node binary (downloaded during build)
bin/node
bin/node.exe
15 changes: 7 additions & 8 deletions bin/studio-cli.bat
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,19 @@ set ORIGINAL_CP=%ORIGINAL_CP: =%
rem Set code page to UTF-8
chcp 65001 >nul

set ELECTRON_EXECUTABLE=%~dp0..\..\Studio.exe
set BUNDLED_NODE_EXECUTABLE=%~dp0node.exe
set CLI_SCRIPT=%~dp0..\cli\main.js

rem Prevent node from printing warnings about NODE_OPTIONS being ignored
set NODE_OPTIONS=

if exist "%ELECTRON_EXECUTABLE%" (
set ELECTRON_RUN_AS_NODE=1
call "%ELECTRON_EXECUTABLE%" "%CLI_SCRIPT%" %*
if exist "%BUNDLED_NODE_EXECUTABLE%" (
call "%BUNDLED_NODE_EXECUTABLE%" "%CLI_SCRIPT%" %*
) else (
if not exist "%CLI_SCRIPT%" (
set CLI_SCRIPT=%~dp0..\dist\cli\main.js
)
call node "%CLI_SCRIPT%" %*
if not exist "%CLI_SCRIPT%" (
set CLI_SCRIPT=%~dp0..\dist\cli\main.js
)
call node "%CLI_SCRIPT%" %*
)

rem Restore original code page
Expand Down
13 changes: 7 additions & 6 deletions bin/studio-cli.sh
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
#!/bin/sh

# The default assumption is that this script lives in `/Applications/Studio.app/Contents/Resources/bin/studio-cli.sh`
CONTENTS_DIR=$(dirname "$(dirname "$(dirname "$(realpath "$0")")")")
ELECTRON_EXECUTABLE="$CONTENTS_DIR/MacOS/Studio"
# The assumption is that this script lives in `/Applications/Studio.app/Contents/Resources/bin/studio-cli.sh`
BIN_DIR=$(dirname "$(realpath "$0")")
BUNDLED_NODE_EXECUTABLE="$BIN_DIR/node"
CONTENTS_DIR=$(dirname "$(dirname "$BIN_DIR")")
CLI_SCRIPT="$CONTENTS_DIR/Resources/cli/main.js"

if [ -x "$ELECTRON_EXECUTABLE" ]; then
if [ -x "$BUNDLED_NODE_EXECUTABLE" ]; then
# Prevent node from printing warnings about NODE_OPTIONS being ignored
unset NODE_OPTIONS
ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON_EXECUTABLE" "$CLI_SCRIPT" "$@"
exec "$BUNDLED_NODE_EXECUTABLE" "$CLI_SCRIPT" "$@"
else
# If the default script path is not found, assume that this script lives in the development directory
# and look for the CLI JS bundle in the `./dist` directory
if ! [ -f "$CLI_SCRIPT" ]; then
SCRIPT_DIR=$(dirname $(dirname "$(realpath "$0")"))
SCRIPT_DIR=$(dirname "$(dirname "$(realpath "$0")")")
CLI_SCRIPT="$SCRIPT_DIR/dist/cli/main.js"
fi

Expand Down
22 changes: 22 additions & 0 deletions cli/patches/ps-man+1.1.8.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
diff --git a/node_modules/ps-man/lib/index.js b/node_modules/ps-man/lib/index.js
index 1234567..abcdefg 100644
--- a/node_modules/ps-man/lib/index.js
+++ b/node_modules/ps-man/lib/index.js
@@ -42,7 +42,7 @@ var listProcesses = function(options, done) {
options = options || {};
}

- var ps = spawn(operations.ps.command, operations.ps.arguments);
+ var ps = spawn(operations.ps.command, operations.ps.arguments, { windowsHide: true });
var processList = '';
var processErr = '';
var options = {
@@ -137,7 +137,7 @@ var killProcesses = function(options, done) {
});
}

- var kill = spawn(operations.kill.command, killArguments);
+ var kill = spawn(operations.kill.command, killArguments, { windowsHide: true });
var processErr = '';

kill.on('error', done);
9 changes: 6 additions & 3 deletions forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,10 @@ const config: ForgeConfig = {
],
plugins: [ new AutoUnpackNativesPlugin( {} ) ],
hooks: {
prePackage: async () => {
console.log( "Ensuring latest WordPress zip isn't included in production build ..." );
prePackage: async ( _forgeConfig, platform, arch ) => {
const execAsync = promisify( exec );

console.log( "Ensuring latest WordPress zip isn't included in production build ..." );
const zipPath = path.join( __dirname, 'wp-files', 'latest.zip' );
try {
fs.unlinkSync( zipPath );
Expand All @@ -125,8 +126,10 @@ const config: ForgeConfig = {
}

console.log( 'Building CLI ...' );
const execAsync = promisify( exec );
await execAsync( 'npm run cli:build' );

console.log( `Downloading Node.js binary for ${ platform }-${ arch }...` );
await execAsync( `node ./scripts/download-node-binary.mjs ${ platform } ${ arch }` );
},
},
};
Expand Down
166 changes: 166 additions & 0 deletions scripts/download-node-binary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/usr/bin/env node
/**
* Download Node.js binary for bundling with Studio
* Usage: node scripts/download-node-binary.js <platform> <arch>
* Example: node scripts/download-node-binary.js darwin arm64
*/

import fs from 'fs';
import path from 'path';
import os from 'os';
import { extract } from 'tar';
import unzipper from 'unzipper';

const LTS_FALLBACK = 'v22.12.0';

function getNodeVersion() {
const nvmrcPath = path.join( import.meta.dirname, '..', '.nvmrc' );
if ( fs.existsSync( nvmrcPath ) ) {
const version = fs.readFileSync( nvmrcPath, 'utf-8' ).trim();
return version.startsWith( 'v' ) ? version : `v${ version }`;
}
console.log( `.nvmrc not found, using fallback version ${ LTS_FALLBACK }` );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be safer to remove fallback and fail the job/build if there is no version in .nvmrc?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can even consider have a separate version for the bundled Node.
Keeping the Node version in sync with the recommended version from Playground CLI should be the best approach. cc @fredrikekelund

return LTS_FALLBACK;
}

const NODE_VERSION = getNodeVersion();

const platform = process.argv[ 2 ] || process.platform;
const arch = process.argv[ 3 ] || process.arch;

// Map platform names to nodejs.org download naming
const platformMap = {
darwin: 'darwin',
win32: 'win',
};

// Map architecture names to nodejs.org download naming
const archMap = {
arm64: 'arm64',
x64: 'x64',
};

const nodePlatform = platformMap[ platform ];
const nodeArch = archMap[ arch ];

if ( ! nodePlatform ) {
console.error( `Unsupported platform: ${ platform }` );
process.exit( 1 );
}

if ( ! nodeArch ) {
console.error( `Unsupported architecture: ${ arch }` );
process.exit( 1 );
}

const binDir = path.join( import.meta.dirname, '..', 'bin' );
const tmpDir = os.tmpdir();

if ( ! fs.existsSync( binDir ) ) {
fs.mkdirSync( binDir, { recursive: true } );
}

const isWindows = nodePlatform === 'win';
// nodejs.org provides different archive formats depending on the target platform
const ext = isWindows ? 'zip' : 'tar.gz';
const filename = `node-${ NODE_VERSION }-${ nodePlatform }-${ nodeArch }.${ ext }`;
const url = `https://nodejs.org/dist/${ NODE_VERSION }/${ filename }`;
const downloadPath = path.join( tmpDir, filename );

async function download( downloadUrl, dest ) {
console.log( `Downloading Node.js ${ NODE_VERSION } for ${ nodePlatform }-${ nodeArch }...` );

const response = await fetch( downloadUrl );

if ( ! response.ok ) {
throw new Error( `Failed to download: HTTP ${ response.status }` );
}

const file = fs.createWriteStream( dest );
const reader = response.body.getReader();

try {
while ( true ) {
const { done, value } = await reader.read();
if ( done ) {
break;
}
file.write( value );
}
} finally {
reader.releaseLock();
}

await new Promise( ( resolve, reject ) => {
file.on( 'finish', resolve );
file.on( 'error', reject );
file.end();
} );

console.log( 'Download complete.' );
}

async function extractTarGz( archivePath, destDir, binaryName ) {
console.log( 'Extracting node binary...' );

const extractDir = path.join( tmpDir, `node-${ NODE_VERSION }-${ nodePlatform }-${ nodeArch }` );

await extract( {
file: archivePath,
cwd: tmpDir,
} ).then( () => {
// Do nothing. We just need the `.then` chaining to be able to await the promise
} );

const sourcePath = path.join( extractDir, 'bin', 'node' );
const destPath = path.join( destDir, binaryName );

fs.copyFileSync( sourcePath, destPath );
fs.chmodSync( destPath, 0o755 );
fs.rmSync( extractDir, { recursive: true } );
}

async function extractZip( archivePath, destDir, binaryName ) {
console.log( 'Extracting node.exe...' );

const extractDir = path.join( tmpDir, `node-${ NODE_VERSION }-${ nodePlatform }-${ nodeArch }` );

await fs
.createReadStream( archivePath )
.pipe( unzipper.Extract( { path: tmpDir } ) )
.promise();

const sourcePath = path.join( extractDir, 'node.exe' );
const destPath = path.join( destDir, binaryName );

fs.copyFileSync( sourcePath, destPath );
fs.rmSync( extractDir, { recursive: true } );
}

try {
await download( url, downloadPath );

const binaryName = isWindows ? 'node.exe' : 'node';

if ( isWindows ) {
await extractZip( downloadPath, binDir, binaryName );
} else {
await extractTarGz( downloadPath, binDir, binaryName );
}

fs.unlinkSync( downloadPath );

console.log( `\nNode.js binary installed to ${ binDir }` );

const files = fs.readdirSync( binDir );
console.log( '\nBin directory contents:' );
for ( const file of files ) {
const filePath = path.join( binDir, file );
const stats = fs.statSync( filePath );
const size = ( stats.size / 1024 / 1024 ).toFixed( 2 );
console.log( ` ${ file } (${ size } MB)` );
}
} catch ( error ) {
console.error( 'Error:', error.message );
process.exit( 1 );
}
8 changes: 2 additions & 6 deletions src/modules/cli/lib/execute-command.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { fork, ChildProcess, StdioOptions } from 'node:child_process';
import EventEmitter from 'node:events';
import * as Sentry from '@sentry/electron/main';
import { getCliPath } from 'src/storage/paths';
import { getBundledNodeBinaryPath, getCliPath } from 'src/storage/paths';

export interface CliCommandResult {
stdout: string;
Expand Down Expand Up @@ -55,13 +55,9 @@ export function executeCliCommand(
stdio = [ 'ignore', 'ignore', 'ignore', 'ipc' ];
}

// Using Electron's utilityProcess.fork API gave us issues with the child process never exiting
const child = fork( cliPath, [ ...args, '--avoid-telemetry' ], {
stdio,
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
},
execPath: getBundledNodeBinaryPath(),
} );
const eventEmitter = new CliCommandEventEmitter();

Expand Down
18 changes: 18 additions & 0 deletions src/storage/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ export function getCliPath(): string {
: path.join( getResourcesPath(), 'cli', 'main.js' );
}

export function getBundledNodeBinaryPath(): string {
const nodeBinaryName = process.platform === 'win32' ? 'node.exe' : 'node';

if ( process.env.NODE_ENV === 'production' ) {
return path.join( getResourcesPath(), 'bin', nodeBinaryName );
}

// In test environment, use the Electron node binary. The system-level node binary is not reliable
// in this context.
if ( process.env.NODE_ENV === 'test' ) {
return process.execPath;
}

// In development, use the system-level node binary. The bundled node binary most likely isn't
// available, and the Electron binary is noticeably slower.
return nodeBinaryName;
}

function getAppDataPath(): string {
if ( inChildProcess() ) {
if ( ! process.env.STUDIO_APP_DATA_PATH ) {
Expand Down
Loading