Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 }` );
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