@wordpress/env 10.38.0 → 10.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,452 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * External dependencies
5
+ */
6
+ const fs = require( 'fs' ).promises;
7
+ const http = require( 'http' );
8
+ const path = require( 'path' );
9
+ const spawn = require( 'cross-spawn' );
10
+
11
+ /**
12
+ * Promisified dependencies
13
+ */
14
+ const { rimraf } = require( 'rimraf' );
15
+
16
+ /**
17
+ * Internal dependencies
18
+ */
19
+ const { buildBlueprint, getMountArgs } = require( './blueprint-builder' );
20
+ const { UnsupportedCommandError } = require( '../errors' );
21
+ const { downloadSource } = require( '../../download-sources' );
22
+
23
+ /**
24
+ * Playground runtime implementation for wp-env.
25
+ */
26
+ class PlaygroundRuntime {
27
+ constructor() {
28
+ this.serverProcess = null;
29
+ this.serverPort = null;
30
+ }
31
+
32
+ /**
33
+ * Get the name of this runtime.
34
+ *
35
+ * @return {string} Runtime name.
36
+ */
37
+ getName() {
38
+ return 'playground';
39
+ }
40
+
41
+ /**
42
+ * Get supported features for this runtime.
43
+ *
44
+ * @return {Object} Feature flags.
45
+ */
46
+ getFeatures() {
47
+ return {
48
+ testsEnvironment: false, // Single environment only
49
+ xdebug: true, // Supported via --xdebug flag
50
+ spx: false, // Not supported in WebAssembly
51
+ phpMyAdmin: false, // Supported on playground.wordpress.net but not in CLI yet
52
+ multisite: true, // Supported via Blueprint
53
+ customPhpVersion: true, // Supported via --php flag
54
+ persistentDatabase: false, // Could be supported via mounts (not yet implemented)
55
+ wpCli: true, // Limited support (not extensively tested)
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Check if Playground CLI is available.
61
+ *
62
+ * @return {Promise<boolean>} True if Playground CLI is available.
63
+ */
64
+ async isAvailable() {
65
+ // npx will fetch it if not installed locally
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Get the warning message for destroy confirmation.
71
+ *
72
+ * @return {string} Warning message.
73
+ */
74
+ getDestroyWarningMessage() {
75
+ return 'WARNING! This will remove the WordPress Playground environment and all local files.';
76
+ }
77
+
78
+ /**
79
+ * Start the WordPress Playground environment.
80
+ *
81
+ * @param {Object} config The wp-env config object.
82
+ * @param {Object} options Start options.
83
+ * @param {Object} options.spinner A CLI spinner which indicates progress.
84
+ * @param {boolean} options.debug True if debug mode is enabled.
85
+ * @param {string} options.xdebug The Xdebug mode to set.
86
+ */
87
+ async start( config, { spinner, debug, xdebug } ) {
88
+ const envConfig = config.env.development;
89
+
90
+ spinner.text = 'Starting WordPress Playground.';
91
+
92
+ // Download remote sources (git/zip) if needed
93
+ const sources = [];
94
+ const addedSources = {};
95
+ const addSource = ( source ) => {
96
+ if (
97
+ source &&
98
+ ( source.type === 'git' || source.type === 'zip' ) &&
99
+ ! addedSources[ source.url ]
100
+ ) {
101
+ sources.push( source );
102
+ addedSources[ source.url ] = true;
103
+ }
104
+ };
105
+
106
+ // Collect all sources that need downloading
107
+ envConfig.pluginSources.forEach( addSource );
108
+ envConfig.themeSources.forEach( addSource );
109
+ Object.values( envConfig.mappings ).forEach( addSource );
110
+ addSource( envConfig.coreSource );
111
+
112
+ // Download sources if any exist
113
+ if ( sources.length > 0 ) {
114
+ spinner.text = 'Downloading sources.';
115
+
116
+ await Promise.all(
117
+ sources.map( ( source ) =>
118
+ downloadSource( source, {
119
+ onProgress: () => {}, // Progress tracking could be added
120
+ spinner,
121
+ debug,
122
+ } )
123
+ )
124
+ );
125
+ }
126
+
127
+ // Build and save blueprint
128
+ const blueprint = buildBlueprint( config );
129
+ const blueprintPath = path.join(
130
+ config.workDirectoryPath,
131
+ 'playground-blueprint.json'
132
+ );
133
+ await fs.mkdir( config.workDirectoryPath, { recursive: true } );
134
+ await fs.writeFile(
135
+ blueprintPath,
136
+ JSON.stringify( blueprint, null, 2 )
137
+ );
138
+
139
+ // Get mount arguments
140
+ const mountArgs = getMountArgs( config );
141
+
142
+ // Determine port
143
+ const port = envConfig.port || 8888;
144
+ const phpVersion = envConfig.phpVersion || '8.2';
145
+
146
+ // Build command arguments for direct execution
147
+ const cliArgs = [
148
+ 'server',
149
+ '--port',
150
+ String( port ),
151
+ '--php',
152
+ phpVersion,
153
+ '--blueprint',
154
+ blueprintPath,
155
+ '--login',
156
+ '--experimental-multi-worker',
157
+ ...mountArgs,
158
+ ];
159
+
160
+ if ( debug ) {
161
+ cliArgs.push( '--verbosity', 'debug' );
162
+ }
163
+
164
+ if ( xdebug ) {
165
+ cliArgs.push( '--xdebug' );
166
+ }
167
+
168
+ spinner.text = `Starting Playground on port ${ port }...`;
169
+
170
+ const siteUrl = `http://localhost:${ port }`;
171
+ const logFile = path.join( config.workDirectoryPath, 'playground.log' );
172
+ const pidFile = path.join( config.workDirectoryPath, 'playground.pid' );
173
+
174
+ // Use cross-spawn with detached mode for cross-platform support
175
+ // Create write stream for log file
176
+ const logFileStream = await fs.open( logFile, 'w' );
177
+
178
+ return new Promise( ( resolve, reject ) => {
179
+ const child = spawn( 'npx', [ '@wp-playground/cli', ...cliArgs ], {
180
+ detached: true,
181
+ stdio: [ 'ignore', logFileStream.fd, logFileStream.fd ],
182
+ env: { ...process.env, FORCE_COLOR: '0' },
183
+ } );
184
+
185
+ // Store child process reference
186
+ this.serverProcess = child;
187
+ this.serverPort = port;
188
+
189
+ // Save PID to file immediately so stop command can find the
190
+ // process even if startup fails before the server is ready.
191
+ fs.writeFile( pidFile, String( child.pid ) ).catch( () => {} );
192
+
193
+ // Allow parent to exit independently
194
+ child.unref();
195
+
196
+ // Track whether the process has exited so cleanup knows
197
+ // whether it still needs to kill it.
198
+ let processExited = false;
199
+
200
+ // If the process exits before the server is ready (e.g. blueprint
201
+ // validation error), reject immediately instead of waiting for
202
+ // the full 120-second timeout.
203
+ const earlyExitPromise = new Promise( ( _, rejectEarly ) => {
204
+ child.on( 'exit', ( code, signal ) => {
205
+ processExited = true;
206
+ const reason =
207
+ code !== null
208
+ ? `with code ${ code }`
209
+ : `from signal ${ signal }`;
210
+ rejectEarly(
211
+ new Error(
212
+ `Playground process exited unexpectedly ${ reason }.`
213
+ )
214
+ );
215
+ } );
216
+ } );
217
+
218
+ child.on( 'error', ( error ) => {
219
+ logFileStream.close();
220
+ reject(
221
+ new Error(
222
+ `Failed to start Playground: ${ error.message }`
223
+ )
224
+ );
225
+ } );
226
+
227
+ // Race: wait for server to respond vs process early exit.
228
+ Promise.race( [
229
+ this._waitForServer( port, 120000 ),
230
+ earlyExitPromise,
231
+ ] )
232
+ .then( async () => {
233
+ spinner.text = `WordPress Playground started at ${ siteUrl }`;
234
+
235
+ const message =
236
+ 'WordPress development site started at ' + siteUrl;
237
+
238
+ resolve( {
239
+ message,
240
+ siteUrl,
241
+ } );
242
+ } )
243
+ .catch( async ( error ) => {
244
+ // Kill the process if it is still running.
245
+ if ( ! processExited && this.serverProcess ) {
246
+ this.serverProcess.kill( 'SIGKILL' );
247
+ this.serverProcess = null;
248
+ }
249
+
250
+ // Clean up PID file
251
+ try {
252
+ await fs.unlink( pidFile );
253
+ } catch {
254
+ // Ignore if file doesn't exist
255
+ }
256
+
257
+ // Read log file for error details
258
+ let logContent = '';
259
+ try {
260
+ logContent = await fs.readFile( logFile, 'utf8' );
261
+ } catch {
262
+ // Ignore
263
+ }
264
+
265
+ await logFileStream.close();
266
+
267
+ reject(
268
+ new Error(
269
+ `${ error.message }\n\nPlayground log:\n${
270
+ logContent || '(no log output)'
271
+ }`
272
+ )
273
+ );
274
+ } );
275
+ } );
276
+ }
277
+
278
+ /**
279
+ * Stop the WordPress Playground environment.
280
+ *
281
+ * @param {Object} config The wp-env config object.
282
+ * @param {Object} options Stop options.
283
+ * @param {Object} options.spinner A CLI spinner which indicates progress.
284
+ */
285
+ async stop( config, { spinner } ) {
286
+ spinner.text = 'Stopping WordPress Playground.';
287
+
288
+ const pidFile = path.join( config.workDirectoryPath, 'playground.pid' );
289
+
290
+ // Try to read PID from file if process reference not available
291
+ let pid = this.serverProcess?.pid;
292
+ if ( ! pid ) {
293
+ try {
294
+ const pidContent = await fs.readFile( pidFile, 'utf8' );
295
+ pid = parseInt( pidContent.trim(), 10 );
296
+ } catch ( error ) {
297
+ // PID file doesn't exist or can't be read
298
+ spinner.text = 'Stopped WordPress Playground.';
299
+ return;
300
+ }
301
+ }
302
+
303
+ if ( pid ) {
304
+ try {
305
+ // Kill the entire process group (negative PID)
306
+ // This ensures both the npm process and child node process are killed
307
+ process.kill( -pid, 'SIGTERM' );
308
+
309
+ // Give it a moment for graceful shutdown
310
+ await new Promise( ( r ) => setTimeout( r, 1000 ) );
311
+
312
+ // Check if still running and force kill if needed
313
+ try {
314
+ process.kill( -pid, 0 ); // Check if process group exists
315
+ process.kill( -pid, 'SIGKILL' ); // Force kill entire group
316
+ } catch {
317
+ // Process group already terminated
318
+ }
319
+ } catch ( error ) {
320
+ // Process group doesn't exist or already terminated
321
+ }
322
+
323
+ // Clean up PID file
324
+ try {
325
+ await fs.unlink( pidFile );
326
+ } catch {
327
+ // Ignore if file doesn't exist
328
+ }
329
+
330
+ this.serverProcess = null;
331
+ this.serverPort = null;
332
+ }
333
+
334
+ spinner.text = 'Stopped WordPress Playground.';
335
+ }
336
+
337
+ /**
338
+ * Destroy the WordPress Playground environment.
339
+ *
340
+ * @param {Object} config The wp-env config object.
341
+ * @param {Object} options Destroy options.
342
+ * @param {Object} options.spinner A CLI spinner which indicates progress.
343
+ */
344
+ async destroy( config, { spinner } ) {
345
+ await this.stop( config, { spinner } );
346
+
347
+ spinner.text = 'Removing local files.';
348
+ await rimraf( config.workDirectoryPath );
349
+
350
+ spinner.text = 'Removed WordPress Playground environment.';
351
+ }
352
+
353
+ /**
354
+ * Run a command in the Playground environment.
355
+ *
356
+ * @param {Object} config The wp-env config object.
357
+ * @param {Object} options Run options.
358
+ * @param {string} options.container The container to run the command in.
359
+ * @param {string[]} options.command The command to run.
360
+ * @param {string} options.envCwd The working directory.
361
+ * @param {Object} options.spinner A CLI spinner which indicates progress.
362
+ * @param {boolean} options.debug True if debug mode is enabled.
363
+ */
364
+ // eslint-disable-next-line no-unused-vars
365
+ async run( config, { container, command, envCwd, spinner, debug } ) {
366
+ throw new UnsupportedCommandError( 'run' );
367
+ }
368
+
369
+ /**
370
+ * Clean/reset the WordPress database.
371
+ *
372
+ * @param {Object} config The wp-env config object.
373
+ * @param {Object} options Clean options.
374
+ * @param {Object} options.spinner A CLI spinner which indicates progress.
375
+ * @param {boolean} options.debug True if debug mode is enabled.
376
+ */
377
+ async clean( config, { spinner, debug } ) {
378
+ spinner.text = 'Cleaning WordPress Playground environment.';
379
+
380
+ // For Playground, we restart the server to reset the database
381
+ await this.stop( config, { spinner } );
382
+ await this.start( config, { spinner, debug } );
383
+
384
+ spinner.text = 'Cleaned WordPress Playground environment.';
385
+ }
386
+
387
+ /**
388
+ * Show logs from the Playground environment.
389
+ *
390
+ * @param {Object} config The wp-env config object.
391
+ * @param {Object} options Logs options.
392
+ * @param {string} options.environment The environment to show logs for.
393
+ * @param {boolean} options.watch If true, follow along with log output.
394
+ * @param {Object} options.spinner A CLI spinner which indicates progress.
395
+ * @param {boolean} options.debug True if debug mode is enabled.
396
+ */
397
+ // eslint-disable-next-line no-unused-vars
398
+ async logs( config, { environment, watch, spinner, debug } ) {
399
+ throw new UnsupportedCommandError( 'logs' );
400
+ }
401
+
402
+ /**
403
+ * Wait for the server to be ready.
404
+ *
405
+ * @param {number} port Port to check.
406
+ * @param {number} timeout Timeout in milliseconds.
407
+ * @return {Promise<void>}
408
+ */
409
+ async _waitForServer( port, timeout = 30000 ) {
410
+ const start = Date.now();
411
+
412
+ while ( Date.now() - start < timeout ) {
413
+ try {
414
+ await this._checkServer( port );
415
+ return;
416
+ } catch {
417
+ await new Promise( ( r ) => setTimeout( r, 500 ) );
418
+ }
419
+ }
420
+
421
+ throw new Error(
422
+ `Playground server did not start within ${
423
+ timeout / 1000
424
+ } seconds.`
425
+ );
426
+ }
427
+
428
+ /**
429
+ * Check if server is responding.
430
+ *
431
+ * @param {number} port Port to check.
432
+ * @return {Promise<void>}
433
+ */
434
+ _checkServer( port ) {
435
+ return new Promise( ( resolve, reject ) => {
436
+ const req = http.get( `http://localhost:${ port }`, ( res ) => {
437
+ if ( res.statusCode >= 200 && res.statusCode < 400 ) {
438
+ resolve();
439
+ } else {
440
+ reject( new Error( `Status: ${ res.statusCode }` ) );
441
+ }
442
+ } );
443
+ req.on( 'error', reject );
444
+ req.setTimeout( 1000, () => {
445
+ req.destroy();
446
+ reject( new Error( 'Timeout' ) );
447
+ } );
448
+ } );
449
+ }
450
+ }
451
+
452
+ module.exports = PlaygroundRuntime;
@@ -2,8 +2,8 @@
2
2
  /**
3
3
  * Internal dependencies
4
4
  */
5
- const buildDockerComposeConfig = require( '../build-docker-compose-config' );
6
- const getHostUser = require( '../get-host-user' );
5
+ const buildDockerComposeConfig = require( '../runtime/docker/build-docker-compose-config' );
6
+ const getHostUser = require( '../runtime/docker/get-host-user' );
7
7
 
8
8
  // The basic config keys which build docker compose config requires.
9
9
  const CONFIG = {
@@ -14,7 +14,7 @@ const CONFIG = {
14
14
  configDirectoryPath: '/path/to/config',
15
15
  };
16
16
 
17
- jest.mock( '../get-host-user', () => jest.fn() );
17
+ jest.mock( '../runtime/docker/get-host-user', () => jest.fn() );
18
18
  getHostUser.mockImplementation( () => {
19
19
  return {
20
20
  name: 'test',