@wordpress/env 11.0.2-next.v.202602271551.0 → 11.1.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.
package/README.md CHANGED
@@ -363,7 +363,10 @@ Options:
363
363
  with a built-in web UI. See
364
364
  https://github.com/NoiseByNorthwest/php-spx for more information.
365
365
  [string]
366
- --scripts Execute any configured lifecycle scripts. [boolean] [default: true]
366
+ --scripts Execute any configured lifecycle scripts.
367
+ [boolean] [default: true]
368
+ --auto-port Automatically find available ports when configured ports are
369
+ busy. [boolean]
367
370
  ```
368
371
 
369
372
  ### `wp-env stop`
@@ -591,8 +594,10 @@ You can customize the WordPress installation, plugins and themes that the develo
591
594
  | `"phpVersion"` | `string\|null` | `null` | The PHP version to use. If `null` is specified, `wp-env` will use the default version used with production release of WordPress. |
592
595
  | `"plugins"` | `string[]` | `[]` | A list of plugins to install and activate in the environment. |
593
596
  | `"themes"` | `string[]` | `[]` | A list of themes to install in the environment. |
594
- | `"port"` | `integer` | `8888` | The primary port number to use for the installation. You'll access the instance through the port: 'http://localhost:8888'. |
597
+ | `"port"` | `integer` | `8888` | The port number to use for the installation. |
595
598
  | `"testsEnvironment"` | `boolean` | `false` | _Deprecated._ Whether to create a separate test environment with its own database and containers. Use `--config` with a separate config file instead. |
599
+ | `"testsPort"` | `integer` | `8889` | The port number for the test site. |
600
+ | `"autoPort"` | `boolean` | `false` | Whether to automatically find available HTTP ports when configured ports are busy. |
596
601
  | `"config"` | `Object` | See below. | Mapping of wp-config.php constants to their desired values. |
597
602
  | `"mappings"` | `Object` | `"{}"` | Mapping of WordPress directories to local directories to be mounted in the WordPress instance. |
598
603
  | `"mysqlPort"` | `integer` | `null` (randomly assigned) | The MySQL port number to expose. |
@@ -603,6 +608,18 @@ You can customize the WordPress installation, plugins and themes that the develo
603
608
 
604
609
  _Note: the port number environment variable (`WP_ENV_PORT`) takes precedence over the .wp-env.json value._
605
610
 
611
+ ### Automatic Port Selection
612
+
613
+ By default, `wp-env` uses fixed ports (`8888` for development, `8889` for tests). If a port is busy, Docker will report an error at start time.
614
+
615
+ To opt in to automatic port selection, pass the `--auto-port` flag:
616
+
617
+ ```sh
618
+ wp-env start --auto-port
619
+ ```
620
+
621
+ When `--auto-port` (or `"autoPort": true`) is enabled and a configured port is busy, `wp-env` scans upward from the configured port to find the next available one (for example: `8888`, `8889`, `8890`, ...). Automatic port selection is disabled when `CI` is set.
622
+
606
623
  Several types of strings can be passed into the `core`, `plugins`, `themes`, and `mappings` fields.
607
624
 
608
625
  | Type | Format | Example(s) |
@@ -785,7 +802,8 @@ You can tell `wp-env` to use a custom port number so that your instance does not
785
802
 
786
803
  These can also be set via environment variables:
787
804
 
788
- - `WP_ENV_PORT` to override the web server's port.
805
+ - `WP_ENV_PORT` to override the development environment's web server's port.
806
+ - `WP_ENV_TESTS_PORT` to override the testing environment's web server's port.
789
807
  - phpMyAdmin is not enabled by default. Enable it with `"phpmyadmin": true` in `.wp-env.json`. The Docker runtime port can also be overridden via `WP_ENV_PHPMYADMIN_PORT`.
790
808
  - By default, MySQL isn't exposed to the host, which means no chance of port conflicts. But this can also be overridden via `WP_ENV_MYSQL_PORT`.
791
809
 
package/lib/cli.js CHANGED
@@ -151,6 +151,11 @@ module.exports = function cli() {
151
151
  choices: getAvailableRuntimes(),
152
152
  default: 'docker',
153
153
  } );
154
+ args.option( 'auto-port', {
155
+ type: 'boolean',
156
+ describe:
157
+ 'Automatically find available ports when configured ports are busy. Overrides the .wp-env.json "autoPort" setting.',
158
+ } );
154
159
  },
155
160
  withSpinner( env.start )
156
161
  );
@@ -22,14 +22,15 @@ const { getRuntime, getSavedRuntime, saveRuntime } = require( '../runtime' );
22
22
  * Starts the development server.
23
23
  *
24
24
  * @param {Object} options
25
- * @param {Object} options.spinner A CLI spinner which indicates progress.
26
- * @param {boolean} options.update If true, update sources.
27
- * @param {string} options.xdebug The Xdebug mode to set.
28
- * @param {string} options.spx The SPX mode to set.
29
- * @param {boolean} options.scripts Indicates whether or not lifecycle scripts should be executed.
30
- * @param {boolean} options.debug True if debug mode is enabled.
31
- * @param {string} options.runtime The runtime to use ('docker' or 'playground').
32
- * @param {string|null} options.config Path to a custom .wp-env.json configuration file.
25
+ * @param {Object} options.spinner A CLI spinner which indicates progress.
26
+ * @param {boolean} options.update If true, update sources.
27
+ * @param {string} options.xdebug The Xdebug mode to set.
28
+ * @param {string} options.spx The SPX mode to set.
29
+ * @param {boolean} options.scripts Indicates whether or not lifecycle scripts should be executed.
30
+ * @param {boolean} options.debug True if debug mode is enabled.
31
+ * @param {string} options.runtime The runtime to use ('docker' or 'playground').
32
+ * @param {boolean} options.autoPort If true, automatically find available ports when configured ports are busy.
33
+ * @param {string|null} options.config Path to a custom .wp-env.json configuration file.
33
34
  */
34
35
  module.exports = async function start( {
35
36
  spinner,
@@ -39,6 +40,7 @@ module.exports = async function start( {
39
40
  scripts,
40
41
  debug,
41
42
  runtime: runtimeName = 'docker',
43
+ autoPort,
42
44
  config: customConfigPath,
43
45
  } ) {
44
46
  spinner.text = 'Reading configuration.';
@@ -50,8 +52,14 @@ module.exports = async function start( {
50
52
  await checkForLegacyInstall( spinner );
51
53
  }
52
54
 
53
- const config = await loadConfig( path.resolve( '.' ), customConfigPath );
55
+ const config = await loadConfig( path.resolve( '.' ), customConfigPath, {
56
+ resolvePorts: true,
57
+ autoPort,
58
+ spinner,
59
+ } );
54
60
  config.debug = debug;
61
+ config.xdebug = xdebug;
62
+ config.spx = spx;
55
63
 
56
64
  // Check if switching runtimes and prompt user to destroy old environment first.
57
65
  const savedRuntime = await getSavedRuntime( config.workDirectoryPath );
@@ -109,9 +117,6 @@ module.exports = async function start( {
109
117
  result = await runtime.start( config, {
110
118
  spinner,
111
119
  update,
112
- xdebug,
113
- spx,
114
- debug,
115
120
  } );
116
121
 
117
122
  // Save the runtime type after successful start.
@@ -13,6 +13,7 @@ const md5 = require( '../md5' );
13
13
  const { parseConfig, getConfigFilePath } = require( './parse-config' );
14
14
  const { ValidationError } = require( './validate-config' );
15
15
  const postProcessConfig = require( './post-process-config' );
16
+ const { createPortResolver } = require( '../resolve-available-ports' );
16
17
 
17
18
  /**
18
19
  * @typedef {import('./parse-config').WPRootConfig} WPRootConfig
@@ -36,14 +37,19 @@ const postProcessConfig = require( './post-process-config' );
36
37
  /**
37
38
  * Loads any configuration from a given directory.
38
39
  *
39
- * @param {string} configDirectoryPath The directory we want to load the config from.
40
- * @param {string|null} customConfigPath Optional custom config file path.
40
+ * @param {string} configDirectoryPath The directory we want to load the config from.
41
+ * @param {string|null} customConfigPath Optional custom config file path.
42
+ * @param {Object} options Options for loading the config.
43
+ * @param {boolean} options.resolvePorts Whether HTTP ports should be resolved for this command.
44
+ * @param {boolean} options.autoPort CLI override for automatic port selection.
45
+ * @param {Object} options.spinner A CLI spinner used by the port resolver.
41
46
  *
42
47
  * @return {Promise<WPConfig>} The config object we've loaded.
43
48
  */
44
49
  module.exports = async function loadConfig(
45
50
  configDirectoryPath,
46
- customConfigPath = null
51
+ customConfigPath = null,
52
+ { resolvePorts = false, autoPort, spinner } = {}
47
53
  ) {
48
54
  const configFilePath = getConfigFilePath(
49
55
  configDirectoryPath,
@@ -75,10 +81,25 @@ module.exports = async function loadConfig(
75
81
  customConfigPath
76
82
  );
77
83
 
84
+ let portResolver;
85
+ if ( resolvePorts ) {
86
+ let shouldAutoPort =
87
+ autoPort !== undefined ? autoPort : config.autoPort;
88
+
89
+ // Automatic port selection is undesirable in CI where determinism matters.
90
+ if ( process.env.CI ) {
91
+ shouldAutoPort = false;
92
+ }
93
+
94
+ if ( shouldAutoPort ) {
95
+ portResolver = createPortResolver( spinner );
96
+ }
97
+ }
98
+
78
99
  // Make sure to perform any additional post-processing that
79
100
  // may be needed before the config object is ready for
80
101
  // consumption elsewhere in the tool.
81
- config = postProcessConfig( config );
102
+ config = await postProcessConfig( config, { portResolver } );
82
103
 
83
104
  return {
84
105
  name: path.basename( configDirectoryPath ),
@@ -35,6 +35,7 @@ const mergeConfigs = require( './merge-configs' );
35
35
  * @typedef WPRootConfigOptions
36
36
  * @property {number} port The port to use in the development environment.
37
37
  * @property {number} testsPort The port to use in the tests environment.
38
+ * @property {boolean} autoPort Whether to automatically select a nearby available HTTP port.
38
39
  * @property {Object.<string, string|null>} lifecycleScripts The scripts to run at certain points in the command lifecycle.
39
40
  * @property {Object.<string, string|null>} lifecycleScripts.afterStart The script to run after the "start" command has completed.
40
41
  * @property {Object.<string, string|null>} lifecycleScripts.afterClean The script to run after the "clean" command has completed.
@@ -89,6 +90,7 @@ const DEFAULT_ENVIRONMENT_CONFIG = {
89
90
  themes: [],
90
91
  port: 8888,
91
92
  testsPort: 8889,
93
+ autoPort: false,
92
94
  mysqlPort: null,
93
95
  phpmyadmin: false,
94
96
  phpmyadminPort: null,
@@ -385,6 +387,14 @@ async function parseRootConfig( configFile, rawConfig, options ) {
385
387
  checkPort( configFile, `testsPort`, rawConfig.testsPort );
386
388
  parsedConfig.testsPort = rawConfig.testsPort;
387
389
  }
390
+ if ( rawConfig.autoPort !== undefined ) {
391
+ if ( typeof rawConfig.autoPort !== 'boolean' ) {
392
+ throw new ValidationError(
393
+ `Invalid ${ configFile }: "autoPort" must be a boolean.`
394
+ );
395
+ }
396
+ parsedConfig.autoPort = rawConfig.autoPort;
397
+ }
388
398
  if ( rawConfig.testsEnvironment !== undefined ) {
389
399
  if ( typeof rawConfig.testsEnvironment !== 'boolean' ) {
390
400
  throw new ValidationError(
@@ -470,6 +480,7 @@ async function parseEnvironmentConfig(
470
480
  // configuration options that we will parse.
471
481
  switch ( key ) {
472
482
  case 'testsPort':
483
+ case 'autoPort':
473
484
  case 'testsEnvironment':
474
485
  case 'lifecycleScripts':
475
486
  case 'env': {
@@ -5,6 +5,7 @@
5
5
  const mergeConfigs = require( './merge-configs' );
6
6
  const addOrReplacePort = require( './add-or-replace-port' );
7
7
  const { ValidationError } = require( './validate-config' );
8
+ const { resolveConfigPorts } = require( '../resolve-available-ports' );
8
9
 
9
10
  /**
10
11
  * @typedef {import('./parse-config').WPRootConfig} WPRootConfig
@@ -14,15 +15,27 @@ const { ValidationError } = require( './validate-config' );
14
15
  /**
15
16
  * Performs any additional post-processing on the config object.
16
17
  *
17
- * @param {WPEnvironmentConfig} config The config to process.
18
+ * @param {WPEnvironmentConfig} config The config to process.
19
+ * @param {Object} options Options for post-processing.
20
+ * @param {Object} options.portResolver An optional port resolver for automatic port selection.
18
21
  *
19
- * @return {WPEnvironmentConfig} The config after post-processing.
22
+ * @return {Promise<WPEnvironmentConfig>} The config after post-processing.
20
23
  */
21
- module.exports = function postProcessConfig( config ) {
24
+ module.exports = async function postProcessConfig(
25
+ config,
26
+ { portResolver } = {}
27
+ ) {
22
28
  // Make sure that we're operating on a config object that has
23
29
  // complete environment configs for convenience.
24
30
  config = mergeRootToEnvironments( config );
25
31
 
32
+ // Resolve ports after merging (so environment ports are finalized)
33
+ // but before appending ports to URLs. This way appendPortToWPConfigs
34
+ // uses the resolved ports and we don't need to fix URLs afterward.
35
+ if ( portResolver ) {
36
+ config = await resolveConfigPorts( config, portResolver );
37
+ }
38
+
26
39
  config = appendPortToWPConfigs( config );
27
40
 
28
41
  validate( config );
@@ -66,6 +79,10 @@ function mergeRootToEnvironments( config ) {
66
79
  delete config.testsPort;
67
80
  }
68
81
  }
82
+ if ( config.autoPort !== undefined ) {
83
+ removedRootOptions.autoPort = config.autoPort;
84
+ delete config.autoPort;
85
+ }
69
86
  if ( config.lifecycleScripts !== undefined ) {
70
87
  removedRootOptions.lifecycleScripts = config.lifecycleScripts;
71
88
  delete config.lifecycleScripts;
@@ -117,7 +134,10 @@ function appendPortToWPConfigs( config ) {
117
134
  continue;
118
135
  }
119
136
 
120
- if ( config.env[ env ].port === undefined ) {
137
+ if (
138
+ config.env[ env ].port === undefined ||
139
+ config.env[ env ].port === null
140
+ ) {
121
141
  continue;
122
142
  }
123
143
 
@@ -157,10 +177,12 @@ function validatePortUniqueness( config ) {
157
177
  if ( env === 'tests' && testsDisabled ) {
158
178
  continue;
159
179
  }
160
- if ( config.env[ env ].port === undefined ) {
161
- throw new ValidationError(
162
- `The "${ env }" environment has an invalid port.`
163
- );
180
+ if (
181
+ config.env[ env ].port === undefined ||
182
+ config.env[ env ].port === null
183
+ ) {
184
+ // Null ports will be resolved later; skip validation.
185
+ continue;
164
186
  }
165
187
 
166
188
  environmentPorts[ env ] = config.env[ env ].port;
@@ -21,6 +21,7 @@ jest.mock( '../../wordpress', () => ( {
21
21
  const DEFAULT_CONFIG = {
22
22
  port: 8888,
23
23
  testsPort: 8889,
24
+ autoPort: false,
24
25
  mysqlPort: null,
25
26
  phpmyadmin: false,
26
27
  phpmyadminPort: null,
@@ -192,6 +193,19 @@ describe( 'parseConfig', () => {
192
193
  expect( parsed ).toEqual( expected );
193
194
  } );
194
195
 
196
+ it( 'should accept autoPort as a boolean', async () => {
197
+ readRawConfigFile.mockImplementation( async ( configFile ) => {
198
+ if ( configFile === '/test/gutenberg/.wp-env.json' ) {
199
+ return {
200
+ autoPort: true,
201
+ };
202
+ }
203
+ } );
204
+
205
+ const parsed = await parseConfig( '/test/gutenberg', '/cache' );
206
+ expect( parsed.autoPort ).toEqual( true );
207
+ } );
208
+
195
209
  it( 'should parse core, plugin, theme, and mapping sources', async () => {
196
210
  readRawConfigFile.mockImplementation( async ( configFile ) => {
197
211
  if ( configFile === '/test/gutenberg/.wp-env.json' ) {
@@ -377,6 +391,24 @@ describe( 'parseConfig', () => {
377
391
  );
378
392
  } );
379
393
 
394
+ it( 'throws for non-boolean autoPort', async () => {
395
+ readRawConfigFile.mockImplementation( async ( configFile ) => {
396
+ if ( configFile === '/test/gutenberg/.wp-env.json' ) {
397
+ return {
398
+ autoPort: 'true',
399
+ };
400
+ }
401
+ } );
402
+
403
+ await expect(
404
+ parseConfig( '/test/gutenberg', '/cache' )
405
+ ).rejects.toEqual(
406
+ new ValidationError(
407
+ `Invalid /test/gutenberg/.wp-env.json: "autoPort" must be a boolean.`
408
+ )
409
+ );
410
+ } );
411
+
380
412
  it( 'throws for root-only config options', async () => {
381
413
  readRawConfigFile.mockImplementation( async ( configFile ) => {
382
414
  if ( configFile === '/test/gutenberg/.wp-env.json' ) {
@@ -406,6 +438,42 @@ describe( 'parseConfig', () => {
406
438
  );
407
439
  } );
408
440
 
441
+ it( 'throws when port is null', async () => {
442
+ readRawConfigFile.mockImplementation( async ( configFile ) => {
443
+ if ( configFile === '/test/gutenberg/.wp-env.json' ) {
444
+ return {
445
+ port: null,
446
+ };
447
+ }
448
+ } );
449
+
450
+ await expect(
451
+ parseConfig( '/test/gutenberg', '/cache' )
452
+ ).rejects.toEqual(
453
+ new ValidationError(
454
+ `Invalid /test/gutenberg/.wp-env.json: "port" must be an integer.`
455
+ )
456
+ );
457
+ } );
458
+
459
+ it( 'throws when testsPort is null', async () => {
460
+ readRawConfigFile.mockImplementation( async ( configFile ) => {
461
+ if ( configFile === '/test/gutenberg/.wp-env.json' ) {
462
+ return {
463
+ testsPort: null,
464
+ };
465
+ }
466
+ } );
467
+
468
+ await expect(
469
+ parseConfig( '/test/gutenberg', '/cache' )
470
+ ).rejects.toEqual(
471
+ new ValidationError(
472
+ `Invalid /test/gutenberg/.wp-env.json: "testsPort" must be an integer.`
473
+ )
474
+ );
475
+ } );
476
+
409
477
  it( 'should parse phpmyadmin configuration for a given environment', async () => {
410
478
  readRawConfigFile.mockImplementation( async ( configFile ) => {
411
479
  if ( configFile === '/test/gutenberg/.wp-env.json' ) {
@@ -10,8 +10,8 @@ describe( 'postProcessConfig', () => {
10
10
  jest.clearAllMocks();
11
11
  } );
12
12
 
13
- it( 'should merge relevant root options into environment options', () => {
14
- const processed = postProcessConfig( {
13
+ it( 'should merge relevant root options into environment options', async () => {
14
+ const processed = await postProcessConfig( {
15
15
  port: 123,
16
16
  testsPort: 456,
17
17
  coreSource: {
@@ -152,8 +152,8 @@ describe( 'postProcessConfig', () => {
152
152
  } );
153
153
  } );
154
154
 
155
- it( 'should not merge some root options into environment options', () => {
156
- const processed = postProcessConfig( {
155
+ it( 'should not merge some root options into environment options', async () => {
156
+ const processed = await postProcessConfig( {
157
157
  port: 8888,
158
158
  testsPort: 8889,
159
159
  lifecycleScripts: {
@@ -183,8 +183,8 @@ describe( 'postProcessConfig', () => {
183
183
  } );
184
184
 
185
185
  describe( 'appendPortToWPConfigs', () => {
186
- it( 'should add port to certain environment config options', () => {
187
- const processed = postProcessConfig( {
186
+ it( 'should add port to certain environment config options', async () => {
187
+ const processed = await postProcessConfig( {
188
188
  port: 123,
189
189
  config: {
190
190
  WP_TESTS_DOMAIN: 'localhost',
@@ -231,8 +231,8 @@ describe( 'postProcessConfig', () => {
231
231
  } );
232
232
  } );
233
233
 
234
- it( 'should not overwrite port in WP_HOME', () => {
235
- const processed = postProcessConfig( {
234
+ it( 'should not overwrite port in WP_HOME', async () => {
235
+ const processed = await postProcessConfig( {
236
236
  env: {
237
237
  development: {
238
238
  port: 123,
@@ -274,11 +274,35 @@ describe( 'postProcessConfig', () => {
274
274
  },
275
275
  } );
276
276
  } );
277
+
278
+ it( 'should not append port to URLs when port is null', async () => {
279
+ const processed = await postProcessConfig( {
280
+ port: null,
281
+ testsPort: null,
282
+ config: {
283
+ WP_TESTS_DOMAIN: 'localhost',
284
+ WP_SITEURL: 'http://localhost',
285
+ WP_HOME: 'http://localhost',
286
+ },
287
+ env: {
288
+ development: {},
289
+ tests: {},
290
+ },
291
+ } );
292
+
293
+ // Null ports should not be appended to URLs.
294
+ expect( processed.env.development.config.WP_SITEURL ).toEqual(
295
+ 'http://localhost'
296
+ );
297
+ expect( processed.env.tests.config.WP_SITEURL ).toEqual(
298
+ 'http://localhost'
299
+ );
300
+ } );
277
301
  } );
278
302
 
279
303
  describe( 'validatePortUniqueness', () => {
280
- it( 'should fail when two environments have the same port', () => {
281
- expect( () => {
304
+ it( 'should fail when two environments have the same port', async () => {
305
+ await expect(
282
306
  postProcessConfig( {
283
307
  env: {
284
308
  development: {
@@ -288,16 +312,16 @@ describe( 'postProcessConfig', () => {
288
312
  port: 123,
289
313
  },
290
314
  },
291
- } );
292
- } ).toThrow(
315
+ } )
316
+ ).rejects.toThrow(
293
317
  new ValidationError(
294
318
  'The "development" and "tests" environments may not have the same port.'
295
319
  )
296
320
  );
297
321
  } );
298
322
 
299
- it( 'should skip port validation for disabled tests environment', () => {
300
- expect( () => {
323
+ it( 'should skip port validation for disabled tests environment', async () => {
324
+ await expect(
301
325
  postProcessConfig( {
302
326
  testsEnvironment: false,
303
327
  port: 123,
@@ -305,14 +329,28 @@ describe( 'postProcessConfig', () => {
305
329
  development: {},
306
330
  tests: {},
307
331
  },
308
- } );
309
- } ).not.toThrow();
332
+ } )
333
+ ).resolves.toBeDefined();
334
+ } );
335
+
336
+ it( 'should not fail when both environments have null ports', async () => {
337
+ const processed = await postProcessConfig( {
338
+ port: null,
339
+ testsPort: null,
340
+ env: {
341
+ development: {},
342
+ tests: {},
343
+ },
344
+ } );
345
+
346
+ expect( processed.env.development.port ).toEqual( null );
347
+ expect( processed.env.tests.port ).toEqual( null );
310
348
  } );
311
349
  } );
312
350
 
313
351
  describe( 'testsEnvironment', () => {
314
- it( 'should ignore env overrides entirely when testsEnvironment is false', () => {
315
- const processed = postProcessConfig( {
352
+ it( 'should ignore env overrides entirely when testsEnvironment is false', async () => {
353
+ const processed = await postProcessConfig( {
316
354
  testsEnvironment: false,
317
355
  port: 123,
318
356
  testsPort: 456,
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+ /**
3
+ * External dependencies
4
+ */
5
+ const net = require( 'net' );
6
+
7
+ const DEFAULT_MIN_PORT = 49152;
8
+ const DEFAULT_MAX_PORT = 65535;
9
+
10
+ /**
11
+ * Checks if a port is available for use.
12
+ *
13
+ * @param {number} port The port to check.
14
+ * @return {Promise<boolean>} True if the port is available, false otherwise.
15
+ */
16
+ async function isPortAvailable( port ) {
17
+ return new Promise( ( resolve ) => {
18
+ const server = net.createServer();
19
+
20
+ server.once( 'error', ( err ) => {
21
+ if ( err.code === 'EADDRINUSE' || err.code === 'EACCES' ) {
22
+ resolve( false );
23
+ } else {
24
+ // For other errors, assume port is not available
25
+ resolve( false );
26
+ }
27
+ } );
28
+
29
+ server.once( 'listening', () => {
30
+ server.close( () => {
31
+ resolve( true );
32
+ } );
33
+ } );
34
+
35
+ server.listen( port, '0.0.0.0' );
36
+ } );
37
+ }
38
+
39
+ /**
40
+ * Finds an available port, starting with the preferred port.
41
+ * Falls back to scanning upward from the preferred port.
42
+ *
43
+ * @param {Object} options Options for finding a port.
44
+ * @param {number} options.preferredPort The preferred port to try first.
45
+ * @param {number} options.minPort Minimum port for fallback scanning.
46
+ * @param {number} options.maxPort Maximum port for fallback scanning.
47
+ * @param {number[]} options.exclude Ports to exclude from selection.
48
+ * @return {Promise<number>} An available port number.
49
+ * @throws {Error} If no available port is found within the range.
50
+ */
51
+ async function findAvailablePort( {
52
+ preferredPort,
53
+ minPort = preferredPort,
54
+ maxPort = DEFAULT_MAX_PORT,
55
+ exclude = [],
56
+ } ) {
57
+ // Try the preferred port first if it's not excluded
58
+ if ( ! exclude.includes( preferredPort ) ) {
59
+ const isAvailable = await isPortAvailable( preferredPort );
60
+ if ( isAvailable ) {
61
+ return preferredPort;
62
+ }
63
+ }
64
+
65
+ // If preferred port is not available, scan upward from the selected minimum.
66
+ const startPort = Math.max( minPort, preferredPort + 1 );
67
+ for ( let port = startPort; port <= maxPort; port++ ) {
68
+ if ( exclude.includes( port ) ) {
69
+ continue;
70
+ }
71
+ const isAvailable = await isPortAvailable( port );
72
+ if ( isAvailable ) {
73
+ return port;
74
+ }
75
+ }
76
+
77
+ throw new Error(
78
+ `No available port found in range ${ startPort }-${ maxPort }.`
79
+ );
80
+ }
81
+
82
+ module.exports = {
83
+ isPortAvailable,
84
+ findAvailablePort,
85
+ DEFAULT_MIN_PORT,
86
+ DEFAULT_MAX_PORT,
87
+ };