@wordpress/env
Version:
A zero-config, self contained local WordPress environment for development and testing.
559 lines (485 loc) • 15.2 kB
JavaScript
;
/**
* External dependencies
*/
const fs = require( 'fs' ).promises;
const http = require( 'http' );
const path = require( 'path' );
const spawn = require( 'cross-spawn' );
/**
* Promisified dependencies
*/
const { rimraf } = require( 'rimraf' );
/**
* Internal dependencies
*/
const { buildBlueprint, getMountArgs } = require( './blueprint-builder' );
const { UnsupportedCommandError } = require( '../errors' );
const { downloadSource } = require( '../../download-sources' );
/**
* Playground runtime implementation for wp-env.
*/
class PlaygroundRuntime {
constructor() {
this.serverProcess = null;
this.serverPort = null;
}
/**
* Get the name of this runtime.
*
* @return {string} Runtime name.
*/
getName() {
return 'playground';
}
/**
* Get supported features for this runtime.
*
* @return {Object} Feature flags.
*/
getFeatures() {
return {
testsEnvironment: false, // Single environment only
xdebug: true, // Supported via --xdebug flag
spx: false, // Not supported in WebAssembly
phpMyAdmin: true, // Supported via --phpmyadmin CLI flag
multisite: true, // Supported via Blueprint
customPhpVersion: true, // Supported via --php flag
persistentDatabase: false, // Could be supported via mounts (not yet implemented)
wpCli: true, // Limited support (not extensively tested)
};
}
/**
* Check if Playground CLI is available.
*
* @return {Promise<boolean>} True if Playground CLI is available.
*/
async isAvailable() {
// npx will fetch it if not installed locally
return true;
}
/**
* Get the warning message for destroy confirmation.
*
* @return {string} Warning message.
*/
getDestroyWarningMessage() {
return 'WARNING! This will remove the WordPress Playground environment and all local files.';
}
/**
* Get the warning message for cleanup confirmation.
*
* @return {string} Warning message.
*/
getCleanupWarningMessage() {
return 'WARNING! This will remove the WordPress Playground environment and all local files.';
}
/**
* Start the WordPress Playground environment.
*
* @param {Object} config The wp-env config object.
* @param {Object} options Start options.
* @param {Object} options.spinner A CLI spinner which indicates progress.
*/
async start( config, { spinner } ) {
const envConfig = config.env.development;
spinner.text = 'Starting WordPress Playground.';
// Download remote sources (git/zip) if needed
const sources = [];
const addedSources = {};
const addSource = ( source ) => {
if (
source &&
( source.type === 'git' || source.type === 'zip' ) &&
! addedSources[ source.url ]
) {
sources.push( source );
addedSources[ source.url ] = true;
}
};
// Collect all sources that need downloading
envConfig.pluginSources.forEach( addSource );
envConfig.themeSources.forEach( addSource );
Object.values( envConfig.mappings ).forEach( addSource );
addSource( envConfig.coreSource );
// Download sources if any exist
if ( sources.length > 0 ) {
spinner.text = 'Downloading sources.';
await Promise.all(
sources.map( ( source ) =>
downloadSource( source, {
onProgress: () => {}, // Progress tracking could be added
spinner,
debug: config.debug,
} )
)
);
}
// Build and save blueprint
const blueprint = buildBlueprint( config );
const blueprintPath = path.join(
config.workDirectoryPath,
'playground-blueprint.json'
);
await fs.mkdir( config.workDirectoryPath, { recursive: true } );
await fs.writeFile(
blueprintPath,
JSON.stringify( blueprint, null, 2 )
);
// Get mount arguments
const mountArgs = getMountArgs( config );
const port = envConfig.port || 8888;
const phpVersion = envConfig.phpVersion || '8.2';
// Build command arguments for direct execution
const cliArgs = [
'server',
'--port',
String( port ),
'--php',
phpVersion,
'--blueprint',
blueprintPath,
'--login',
'--experimental-multi-worker',
...mountArgs,
];
if ( config.debug ) {
cliArgs.push( '--verbosity', 'debug' );
}
if ( envConfig.phpmyadmin ) {
cliArgs.push( '--phpmyadmin' );
}
if ( config.xdebug && config.xdebug !== 'off' ) {
cliArgs.push( '--xdebug' );
}
spinner.text = `Starting Playground on port ${ port }...`;
const siteUrl = `http://localhost:${ port }`;
const logFile = path.join( config.workDirectoryPath, 'playground.log' );
const pidFile = path.join( config.workDirectoryPath, 'playground.pid' );
// Use cross-spawn with detached mode for cross-platform support
// Create write stream for log file
const logFileStream = await fs.open( logFile, 'w' );
// Resolve the CLI binary directly so that it is found even when
// the package is nested inside workspace node_modules (where npx
// cannot discover it).
const cliPackageJson = require.resolve(
'@wp-playground/cli/package.json'
);
const cliEntryPoint = path.join(
path.dirname( cliPackageJson ),
'wp-playground.js'
);
return new Promise( ( resolve, reject ) => {
const child = spawn(
process.execPath,
[ cliEntryPoint, ...cliArgs ],
{
detached: true,
stdio: [ 'ignore', logFileStream.fd, logFileStream.fd ],
env: { ...process.env, FORCE_COLOR: '0' },
}
);
// Store child process reference
this.serverProcess = child;
this.serverPort = port;
// Save PID to file immediately so stop command can find the
// process even if startup fails before the server is ready.
fs.writeFile( pidFile, String( child.pid ) ).catch( () => {} );
// Allow parent to exit independently
child.unref();
// Track whether the process has exited so cleanup knows
// whether it still needs to kill it.
let processExited = false;
// If the process exits before the server is ready (e.g. blueprint
// validation error), reject immediately instead of waiting for
// the full 120-second timeout.
const earlyExitPromise = new Promise( ( _, rejectEarly ) => {
child.on( 'exit', ( code, signal ) => {
processExited = true;
const reason =
code !== null
? `with code ${ code }`
: `from signal ${ signal }`;
rejectEarly(
new Error(
`Playground process exited unexpectedly ${ reason }.`
)
);
} );
} );
child.on( 'error', ( error ) => {
logFileStream.close();
reject(
new Error(
`Failed to start Playground: ${ error.message }`
)
);
} );
// Race: wait for server to respond vs process early exit.
Promise.race( [
this._waitForServer( port, 120000 ),
earlyExitPromise,
] )
.then( async () => {
spinner.text = `WordPress Playground started at ${ siteUrl }`;
const phpmyadminUrl = envConfig.phpmyadmin
? `${ siteUrl }/phpmyadmin`
: null;
const message = [
'WordPress development site started at ' + siteUrl,
phpmyadminUrl &&
`phpMyAdmin started at ${ phpmyadminUrl }`,
]
.filter( Boolean )
.join( '\n' );
resolve( {
message,
siteUrl,
} );
} )
.catch( async ( error ) => {
// Kill the process if it is still running.
if ( ! processExited && this.serverProcess ) {
this.serverProcess.kill( 'SIGKILL' );
this.serverProcess = null;
}
// Clean up PID file
try {
await fs.unlink( pidFile );
} catch {
// Ignore if file doesn't exist
}
// Read log file for error details
let logContent = '';
try {
logContent = await fs.readFile( logFile, 'utf8' );
} catch {
// Ignore
}
await logFileStream.close();
reject(
new Error(
`${ error.message }\n\nPlayground log:\n${
logContent || '(no log output)'
}`
)
);
} );
} );
}
/**
* Stop the WordPress Playground environment.
*
* @param {Object} config The wp-env config object.
* @param {Object} options Stop options.
* @param {Object} options.spinner A CLI spinner which indicates progress.
*/
async stop( config, { spinner } ) {
spinner.text = 'Stopping WordPress Playground.';
const pidFile = path.join( config.workDirectoryPath, 'playground.pid' );
// Try to read PID from file if process reference not available
let pid = this.serverProcess?.pid;
if ( ! pid ) {
try {
const pidContent = await fs.readFile( pidFile, 'utf8' );
pid = parseInt( pidContent.trim(), 10 );
} catch {
// PID file doesn't exist or can't be read
spinner.text = 'Stopped WordPress Playground.';
return;
}
}
if ( pid ) {
try {
// Kill the entire process group (negative PID)
// This ensures both the npm process and child node process are killed
process.kill( -pid, 'SIGTERM' );
// Give it a moment for graceful shutdown
await new Promise( ( r ) => setTimeout( r, 1000 ) );
// Check if still running and force kill if needed
try {
process.kill( -pid, 0 ); // Check if process group exists
process.kill( -pid, 'SIGKILL' ); // Force kill entire group
} catch {
// Process group already terminated
}
} catch {
// Process group doesn't exist or already terminated
}
// Clean up PID file
try {
await fs.unlink( pidFile );
} catch {
// Ignore if file doesn't exist
}
this.serverProcess = null;
this.serverPort = null;
}
spinner.text = 'Stopped WordPress Playground.';
}
/**
* Destroy the WordPress Playground environment.
*
* @param {Object} config The wp-env config object.
* @param {Object} options Destroy options.
* @param {Object} options.spinner A CLI spinner which indicates progress.
*/
async destroy( config, { spinner } ) {
await this.stop( config, { spinner } );
spinner.text = 'Removing local files.';
await rimraf( config.workDirectoryPath );
spinner.text = 'Removed WordPress Playground environment.';
}
/**
* Cleanup the WordPress Playground environment.
*
* For Playground, cleanup is the same as destroy since there are no
* shared resources like Docker images to preserve.
*
* @param {Object} config The wp-env config object.
* @param {Object} options Cleanup options.
* @param {Object} options.spinner A CLI spinner which indicates progress.
*/
async cleanup( config, { spinner } ) {
await this.stop( config, { spinner } );
spinner.text = 'Removing local files.';
await rimraf( config.workDirectoryPath );
spinner.text = 'Cleaned up WordPress Playground environment.';
}
/**
* Run a command in the Playground environment.
*
* @param {Object} config The wp-env config object.
* @param {Object} options Run options.
* @param {string} options.container The container to run the command in.
* @param {string[]} options.command The command to run.
* @param {string} options.envCwd The working directory.
* @param {Object} options.spinner A CLI spinner which indicates progress.
* @param {boolean} options.debug True if debug mode is enabled.
*/
// eslint-disable-next-line no-unused-vars
async run( config, { container, command, envCwd, spinner, debug } ) {
throw new UnsupportedCommandError( 'run' );
}
/**
* Reset the WordPress database.
*
* @param {Object} config The wp-env config object.
* @param {Object} options Reset options.
* @param {Object} options.spinner A CLI spinner which indicates progress.
*/
async clean( config, { spinner } ) {
spinner.text = 'Resetting WordPress Playground environment.';
// For Playground, we restart the server to reset the database
await this.stop( config, { spinner } );
await this.start( config, { spinner } );
spinner.text = 'Reset WordPress Playground environment.';
}
/**
* Get the status of the Playground environment.
*
* @param {Object} config The wp-env config object.
* @param {Object} options Status options.
* @param {Object} options.spinner A CLI spinner which indicates progress.
* @return {Promise<Object>} Status object with environment information.
*/
async getStatus( config, { spinner } ) {
spinner.text = 'Getting environment status.';
const envConfig = config.env.development;
const port = envConfig.port || 8888;
const pidFile = path.join( config.workDirectoryPath, 'playground.pid' );
// Check if server is running.
let isRunning = false;
try {
const pidContent = await fs.readFile( pidFile, 'utf8' );
const pid = parseInt( pidContent.trim(), 10 );
// Check if process is still alive.
process.kill( pid, 0 );
// Check if server is responding.
await this._checkServer( port );
isRunning = true;
} catch {
// Process not running or server not responding.
}
return {
status: isRunning ? 'running' : 'stopped',
runtime: 'playground',
urls: {
development: isRunning ? `http://localhost:${ port }` : null,
phpmyadmin:
isRunning && envConfig.phpmyadmin
? `http://localhost:${ port }/phpmyadmin`
: null,
},
ports: {
development: port,
},
config: {
multisite: envConfig.multisite,
xdebug: 'off',
},
configPath: config.configDirectoryPath,
installPath: config.workDirectoryPath,
};
}
/**
* Show logs from the Playground environment.
*
* @param {Object} config The wp-env config object.
* @param {Object} options Logs options.
* @param {string} options.environment The environment to show logs for.
* @param {boolean} options.watch If true, follow along with log output.
* @param {Object} options.spinner A CLI spinner which indicates progress.
* @param {boolean} options.debug True if debug mode is enabled.
*/
// eslint-disable-next-line no-unused-vars
async logs( config, { environment, watch, spinner, debug } ) {
throw new UnsupportedCommandError( 'logs' );
}
/**
* Wait for the server to be ready.
*
* @param {number} port Port to check.
* @param {number} timeout Timeout in milliseconds.
* @return {Promise<void>}
*/
async _waitForServer( port, timeout = 30000 ) {
const start = Date.now();
while ( Date.now() - start < timeout ) {
try {
await this._checkServer( port );
return;
} catch {
await new Promise( ( r ) => setTimeout( r, 500 ) );
}
}
throw new Error(
`Playground server did not start within ${
timeout / 1000
} seconds.`
);
}
/**
* Check if server is responding.
*
* @param {number} port Port to check.
* @return {Promise<void>}
*/
_checkServer( port ) {
return new Promise( ( resolve, reject ) => {
const req = http.get( `http://localhost:${ port }`, ( res ) => {
if ( res.statusCode >= 200 && res.statusCode < 400 ) {
resolve();
} else {
reject( new Error( `Status: ${ res.statusCode }` ) );
}
} );
req.on( 'error', reject );
req.setTimeout( 1000, () => {
req.destroy();
reject( new Error( 'Timeout' ) );
} );
} );
}
}
module.exports = PlaygroundRuntime;