diff --git a/docs/COMMANDER-MIGRATION.md b/docs/COMMANDER-MIGRATION.md new file mode 100644 index 000000000..deea035e9 --- /dev/null +++ b/docs/COMMANDER-MIGRATION.md @@ -0,0 +1,55 @@ +# Commander Migration Status + +Goal: remove the abandoned `args` package, keep CLI behavior stable, and support packaging to a single bundled executable (Node SEA or similar). See `AGENTS.md` for broader architecture traps. + +## Migration Outcome + +- `src/lib/cli/command.js` is the active Commander-backed compatibility wrapper for all bins that call `command()`. +- `args` has been removed from `package.json` and `npm-shrinkwrap.json`. +- Root command flow (`src/bin/vip.js`) now dispatches via the shared Commander wrapper again, preserving login gating and subcommand chaining. +- Temporary side-path wrapper work has been removed (`src/lib/cli/command-commander.ts` deleted). + +## Compatibility Behaviors Preserved + +- Legacy command contract stays the same: `command(opts).option(...).argv(process.argv, handler)`. +- Alias behavior remains pre-parse: `@app` and `@app.env` are stripped before `--`; alias + `--app/--env` still errors. +- `_opts` controls are still honored: app/env context fetch, confirmation gating, output formatting, wildcard command handling, required positional args. +- Shared formatting/output and telemetry hooks are still in the wrapper path. +- Local nested subcommand dispatch still works via sibling executable resolution. + +## Post-Migration Hardening + +- Short-flag compatibility was restored for long options without explicit aliases: + - Example: `--elasticsearch` accepts `-e`, `--phpmyadmin` accepts `-p`, etc. + - Auto-short generation skips reserved global flags (`-h`, `-v`, `-d`) and avoids duplicate collisions. +- `vip dev-env exec` now performs bounded readiness checks before deciding an environment is not running. +- `startEnvironment()` now includes bounded post-start readiness checks and one recovery `landoStart` retry if the environment stays `DOWN` after rebuild/start. + +## Remaining Technical Debt + +- `_opts` is still module-level state in `src/lib/cli/command.js` and can leak between command instances in one process. +- Help text parity against historical `args` output is close but still a verification target for high-traffic commands. +- Full dev-env E2E can still show Docker/Lando infrastructure flakiness (network cleanup and startup latency), which is environment-level, not parser-level. + +## Verification Commands + +- `npm run build` +- `npm run check-types` +- `npm run test:e2e:dev-env -- --runInBand __tests__/devenv-e2e/001-create.spec.js __tests__/devenv-e2e/005-update.spec.js` +- `npm run test:e2e:dev-env -- --runInBand __tests__/devenv-e2e/008-exec.spec.js __tests__/devenv-e2e/010-import-sql.spec.js` + +## Single-Bundle Direction + +- Preferred: `esbuild` bundle rooted at `src/bin/vip.js`. +- Keep native deps external (`@postman/node-keytar`) for SEA/packaging workflows. +- Candidate build target: + - `dist/vip.bundle.cjs` for SEA ingestion or launcher wrapping. +- Example command: + - `esbuild src/bin/vip.js --bundle --platform=node --target=node20 --format=cjs --outfile=dist/vip.bundle.cjs --banner:js="#!/usr/bin/env node" --external:@postman/node-keytar` + +## Next Refactor Steps + +1. Remove global `_opts` state from `src/lib/cli/command.js` by moving to per-instance configuration. +2. Add parser contract tests for aliasing, wildcard behavior, and short/long boolean coercion. +3. Add stable startup/readiness integration checks around dev-env commands in CI environments with Docker. +4. Add bundling script(s) and SEA config proof-of-concept for a single-file executable artifact. diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 4373fe89d..6d3f3420a 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -14,11 +14,11 @@ "@automattic/vip-search-replace": "^2.0.0", "@json2csv/plainjs": "^7.0.3", "@wwa/single-line-log": "^1.1.4", - "args": "5.0.3", "chalk": "^5.6.2", "check-disk-space": "3.4.0", "cli-columns": "^4.0.0", "cli-table3": "^0.6.3", + "commander": "^14.0.3", "configstore": "^8.0.0", "debug": "4.4.3", "ejs": "^5.0.1", @@ -263,6 +263,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/cli/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -4895,92 +4904,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/args": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/args/-/args-5.0.3.tgz", - "integrity": "sha512-h6k/zfFgusnv3i5TU08KQkVKuCPBtL/PWQbWkHUxvJrZ2nAyeaUupneemcrgn1xmqxPQsPIzwkUhOpoqPDRZuA==", - "license": "MIT", - "dependencies": { - "camelcase": "5.0.0", - "chalk": "2.4.2", - "leven": "2.1.0", - "mri": "1.1.4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/args/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/args/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/args/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/args/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/args/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/args/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/args/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -5822,15 +5745,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", - "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001774", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", @@ -6131,13 +6045,12 @@ } }, "node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "dev": true, + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=20" } }, "node_modules/comment-parser": { @@ -11324,15 +11237,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/leven": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", - "integrity": "sha512-nvVPLpIHUxCUoRLrFqTgSxXJ614d8AgQoWl7zPe/2VadE8+1dpU3LBhowRuBAcuwruWtOdD8oYC9jDNJjXDPyA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -11640,15 +11544,6 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, - "node_modules/mri": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", - "integrity": "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index d00f3378d..14c3a6689 100644 --- a/package.json +++ b/package.json @@ -142,11 +142,11 @@ "@automattic/vip-search-replace": "^2.0.0", "@json2csv/plainjs": "^7.0.3", "@wwa/single-line-log": "^1.1.4", - "args": "5.0.3", "chalk": "^5.6.2", "check-disk-space": "3.4.0", "cli-columns": "^4.0.0", "cli-table3": "^0.6.3", + "commander": "^14.0.3", "configstore": "^8.0.0", "debug": "4.4.3", "ejs": "^5.0.1", diff --git a/src/bin/vip-dev-env-exec.js b/src/bin/vip-dev-env-exec.js index 237b420f1..3f3e1399d 100755 --- a/src/bin/vip-dev-env-exec.js +++ b/src/bin/vip-dev-env-exec.js @@ -17,6 +17,26 @@ import UserError from '../lib/user-error'; const exampleUsage = 'vip dev-env exec'; const usage = 'vip dev-env exec'; +const ENV_UP_CHECK_ATTEMPTS = 5; +const ENV_UP_CHECK_DELAY_MS = 1500; + +const sleep = ms => new Promise( resolve => setTimeout( resolve, ms ) ); + +async function waitForEnvironmentReadiness( lando, slug ) { + const instancePath = getEnvironmentPath( slug ); + + for ( let attempt = 1; attempt <= ENV_UP_CHECK_ATTEMPTS; attempt++ ) { + if ( await isEnvUp( lando, instancePath ) ) { + return true; + } + + if ( attempt < ENV_UP_CHECK_ATTEMPTS ) { + await sleep( ENV_UP_CHECK_DELAY_MS ); + } + } + + return false; +} const examples = [ { @@ -80,7 +100,7 @@ command( { } if ( ! opt.force ) { - const isUp = await isEnvUp( lando, getEnvironmentPath( slug ) ); + const isUp = await waitForEnvironmentReadiness( lando, slug ); if ( ! isUp ) { throw new UserError( 'A WP-CLI command can only be executed on a running local environment.' diff --git a/src/bin/vip.js b/src/bin/vip.js index a991da695..21388c44f 100755 --- a/src/bin/vip.js +++ b/src/bin/vip.js @@ -49,7 +49,7 @@ const runCmd = async function () { .command( 'whoami', 'Retrieve details about the current authenticated VIP-CLI user.' ) .command( 'wp', 'Execute a WP-CLI command against an environment.' ); - cmd.argv( process.argv ); + await cmd.argv( process.argv ); }; /** diff --git a/src/lib/cli/command.js b/src/lib/cli/command.js index a88013e82..765b1eadd 100644 --- a/src/lib/cli/command.js +++ b/src/lib/cli/command.js @@ -1,10 +1,13 @@ -import args from 'args'; import chalk from 'chalk'; +import { Command } from 'commander'; import debugLib from 'debug'; import { prompt } from 'enquirer'; import gql from 'graphql-tag'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; -import { parseEnvAliasFromArgv } from './envAlias'; +import { isAlias, parseEnvAliasFromArgv } from './envAlias'; import * as exit from './exit'; import { formatData, formatSearchReplaceValues } from './format'; import { confirm } from './prompt'; @@ -35,16 +38,247 @@ process.on( 'unhandledRejection', uncaughtError ); let _opts = {}; let alreadyConfirmedDebugAttachment = false; +const RESERVED_AUTO_SHORT_ALIASES = new Set( [ 'h', 'v', 'd' ] ); + +function normalizeUsage( program, usage ) { + if ( ! usage ) { + return; + } + + const [ rootCommand, ...rest ] = usage.trim().split( /\s+/ ); + if ( rootCommand ) { + program.name( rootCommand ); + } + + if ( rest.length ) { + const usageString = rest.join( ' ' ); + program.usage( + usageString.includes( '[options]' ) ? usageString : `${ usageString } [options]` + ); + } +} + +function createOptionDefinition( name, description, defaultValue, parseFn, usedShortNames ) { + const isArray = Array.isArray( name ); + const shortName = isArray ? name[ 0 ] : null; + const longName = isArray ? name[ 1 ] : name; + const normalizedLongName = String( longName ).trim().replace( /^--?/, '' ); + const explicitShortName = shortName ? String( shortName ).trim().replace( /^-/, '' ) : null; + let normalizedShortName = explicitShortName; + + if ( ! normalizedShortName ) { + const autoShortName = normalizedLongName.charAt( 0 ); + const canUseAutoShortName = + autoShortName && + ! RESERVED_AUTO_SHORT_ALIASES.has( autoShortName ) && + ! usedShortNames.has( autoShortName ); + + if ( canUseAutoShortName ) { + normalizedShortName = autoShortName; + } + } + + if ( normalizedShortName ) { + usedShortNames.add( normalizedShortName ); + } + const isBooleanOption = typeof defaultValue === 'boolean'; + const usesOptionalValue = ! isBooleanOption; + const parseOptionValue = value => { + if ( parseFn ) { + return parseFn( value ); + } + + return value; + }; + + let parser; + if ( usesOptionalValue ) { + parser = ( value, previousValue ) => { + const parsedValue = parseOptionValue( value ); + if ( previousValue === undefined ) { + return parsedValue; + } + + if ( Array.isArray( previousValue ) ) { + return [ ...previousValue, parsedValue ]; + } + + return [ previousValue, parsedValue ]; + }; + } + + let flags = `--${ normalizedLongName }`; + if ( usesOptionalValue ) { + flags += ' [value]'; + } + + if ( normalizedShortName ) { + flags = `-${ normalizedShortName }, ${ flags }`; + } + + return { + flags, + description, + defaultValue, + parser, + }; +} + +class CommanderArgsCompat { + constructor( opts ) { + this.details = { + commands: [], + }; + this.sub = []; + this.examplesList = []; + this.usedShortNames = new Set(); + this._opts = opts; + this.program = new Command(); + this.program.allowUnknownOption( true ); + this.program.allowExcessArguments( true ); + this.program.helpOption( false ); + normalizeUsage( this.program, this._opts.usage ); + } + + option( name, description, defaultValue, parseFn ) { + const definition = createOptionDefinition( + name, + description, + defaultValue, + parseFn, + this.usedShortNames + ); + const { flags, parser } = definition; + + if ( parser && defaultValue !== undefined ) { + this.program.option( flags, description, parser, defaultValue ); + } else if ( parser ) { + this.program.option( flags, description, parser ); + } else if ( defaultValue !== undefined ) { + this.program.option( flags, description, defaultValue ); + } else { + this.program.option( flags, description ); + } + + return this; + } + + command( name, description = '' ) { + this.details.commands.push( { + usage: name, + description, + } ); + + return this; + } + + example( usage, description ) { + this.examplesList.push( { + usage, + description, + } ); + + return this; + } + + examples( examples = [] ) { + for ( const example of examples ) { + this.example( example.usage, example.description ); + } + + return this; + } + + showVersion() { + console.log( pkg.version ); + process.exit( 0 ); + } + + showHelp() { + const lines = [ this.program.helpInformation().trimEnd() ]; + + if ( this.details.commands.length ) { + lines.push( '' ); + lines.push( 'Commands:' ); + for ( const entry of this.details.commands ) { + const commandName = entry.usage.padEnd( 26, ' ' ); + lines.push( ` ${ commandName }${ entry.description }` ); + } + } + + if ( this.examplesList.length ) { + lines.push( '' ); + lines.push( 'Examples:' ); + for ( const example of this.examplesList ) { + lines.push( ` - ${ example.description }` ); + lines.push( ` $ ${ example.usage }` ); + } + } + + console.log( lines.join( '\n' ) ); + process.exit( 0 ); + } + + isDefined( value, key ) { + if ( key !== 'commands' ) { + return false; + } + + return this.details.commands.some( entry => entry.usage === value ); + } + + parse( argv ) { + this.program.parse( argv, { from: 'node' } ); + this.sub = this.program.args.slice(); + return this.program.opts(); + } + + executeSubcommand( argv, parsedAlias, subcommand ) { + const currentScript = argv[ 1 ]; + const extension = path.extname( currentScript ); + const baseScriptPath = extension ? currentScript.slice( 0, -extension.length ) : currentScript; + const childScriptPath = extension + ? `${ baseScriptPath }-${ subcommand }${ extension }` + : `${ baseScriptPath }-${ subcommand }`; + const aliasFromRawArgv = argv.slice( 2 ).find( arg => isAlias( arg ) ); + const subcommandIndex = parsedAlias.argv.findIndex( ( arg, index ) => { + return index > 1 && arg === subcommand; + } ); + + let childArgs = subcommandIndex > -1 ? parsedAlias.argv.slice( subcommandIndex + 1 ) : []; + + if ( aliasFromRawArgv ) { + childArgs = [ aliasFromRawArgv, ...childArgs ]; + } + + let runResult; + if ( fs.existsSync( childScriptPath ) ) { + runResult = spawnSync( process.execPath, [ childScriptPath, ...childArgs ], { + stdio: 'inherit', + env: process.env, + } ); + } else { + const fallbackCommand = `${ path.basename( baseScriptPath ) }-${ subcommand }`; + runResult = spawnSync( fallbackCommand, childArgs, { + stdio: 'inherit', + env: process.env, + shell: process.platform === 'win32', + } ); + } + + if ( runResult.error ) { + throw runResult.error; + } + + process.exit( runResult.status ?? 1 ); + } +} /** * @param {string[]} argv */ // eslint-disable-next-line complexity -args.argv = async function ( argv, cb ) { - if ( process.platform !== 'win32' && argv[ 1 ]?.endsWith( '.js' ) ) { - argv[ 1 ] = argv[ 1 ].slice( 0, -3 ); - } - +CommanderArgsCompat.prototype.argv = async function ( argv, cb ) { if ( process.execArgv.includes( '--inspect' ) && ! alreadyConfirmedDebugAttachment ) { await prompt( { type: 'confirm', @@ -55,25 +289,13 @@ args.argv = async function ( argv, cb ) { } const parsedAlias = parseEnvAliasFromArgv( argv ); - // A usage option allows us to override the default usage text, which isn't - // accurate for subcommands. By default, it will display something like (note - // the hyphen): - // Usage: vip command-subcommand [options] - // - // We can pass "vip command subcommand" to the name param for more accurate - // usage text: - // Usage: vip command subcommand [options] - // - // It also allows us to represent required args in usage text: - // Usage: vip command subcommand [options] - const name = _opts.usage || null; - - const options = this.parse( parsedAlias.argv, { - help: false, - name, - version: false, - debug: false, - } ); + const options = this.parse( parsedAlias.argv ); + + // If there's a sub-command, run that instead + if ( this.isDefined( this.sub[ 0 ], 'commands' ) ) { + this.executeSubcommand( argv, parsedAlias, this.sub[ 0 ] ); + return {}; + } if ( _opts.format && ! options.format ) { options.format = 'table'; @@ -111,11 +333,6 @@ args.argv = async function ( argv, cb ) { exit.withError( error ); } - // If there's a sub-command, run that instead - if ( this.isDefined( this.sub[ 0 ], 'commands' ) ) { - return {}; - } - if ( process.env.NODE_ENV !== 'test' ) { const { default: updateNotifier } = await import( 'update-notifier' ); updateNotifier( { pkg, updateCheckInterval: 1000 * 60 * 60 * 24 } ).notify( { @@ -123,18 +340,7 @@ args.argv = async function ( argv, cb ) { } ); } - // `help` and `version` are always defined as subcommands - const customCommands = this.details.commands.filter( command => { - switch ( command.usage ) { - case 'help': - case 'version': - case 'debug': - return false; - - default: - return true; - } - } ); + const customCommands = this.details.commands; // Show help if no args passed if ( Boolean( customCommands.length ) && ! this.sub.length ) { @@ -159,6 +365,7 @@ args.argv = async function ( argv, cb ) { if ( ! _opts.wildcardCommand && this.sub[ _opts.requiredArgs ] && + subCommands.length && 0 > subCommands.indexOf( this.sub[ _opts.requiredArgs ] ) ) { const subcommand = this.sub.join( ' ' ); @@ -420,7 +627,7 @@ args.argv = async function ( argv, cb ) { info.push( { key: 'Launched?', value: `${ chalk.cyan( launched ) }` } ); } - if ( this.sub ) { + if ( this.sub.length ) { info.push( { key: 'SQL File', value: `${ chalk.blueBright( this.sub ) }` } ); } @@ -483,7 +690,7 @@ args.argv = async function ( argv, cb ) { case 'import-media': { const isUrl = - this.sub && + this.sub.length && ( String( this.sub ).startsWith( 'http://' ) || String( this.sub ).startsWith( 'https://' ) ); const archiveLabel = isUrl ? 'Archive URL' : 'Archive Path'; @@ -603,7 +810,7 @@ function validateOpts( opts ) { } /** - * @returns {args} + * @returns {CommanderArgsCompat} */ export default function ( opts ) { _opts = { @@ -618,6 +825,8 @@ export default function ( opts ) { ...opts, }; + const args = new CommanderArgsCompat( _opts ); + if ( _opts.appContext || _opts.requireConfirm ) { args.option( 'app', @@ -642,15 +851,17 @@ export default function ( opts ) { // Add help and version to all subcommands args.option( - 'help', - 'Retrieve a description, examples, and available options for a (sub)command.' + [ 'h', 'help' ], + 'Retrieve a description, examples, and available options for a (sub)command.', + false ); args.option( - 'version', - 'Retrieve the version number of VIP-CLI currently installed on the local machine.' + [ 'v', 'version' ], + 'Retrieve the version number of VIP-CLI currently installed on the local machine.', + false ); args.option( - 'debug', + [ 'd', 'debug' ], 'Generate verbose output during command execution to help identify or fix errors or bugs.' ); diff --git a/src/lib/dev-environment/dev-environment-core.ts b/src/lib/dev-environment/dev-environment-core.ts index 5a1d4db75..248455612 100644 --- a/src/lib/dev-environment/dev-environment-core.ts +++ b/src/lib/dev-environment/dev-environment-core.ts @@ -28,6 +28,7 @@ import { LandoLogsOptions, LandoExecOptions, getProxyContainer, + isEnvUp, removeProxyCache, } from './dev-environment-lando'; import { AppEnvironment } from '../../graphqlTypes'; @@ -99,6 +100,26 @@ interface WordPressTag { prerelease: boolean; } +const STARTUP_READY_ATTEMPTS = 6; +const STARTUP_READY_DELAY_MS = 2000; + +const sleep = ( ms: number ): Promise< void > => + new Promise( resolve => setTimeout( resolve, ms ) ); + +async function waitForEnvironmentToBeUp( lando: Lando, instancePath: string ): Promise< boolean > { + for ( let attempt = 1; attempt <= STARTUP_READY_ATTEMPTS; attempt++ ) { + if ( await isEnvUp( lando, instancePath ) ) { + return true; + } + + if ( attempt < STARTUP_READY_ATTEMPTS ) { + await sleep( STARTUP_READY_DELAY_MS ); + } + } + + return false; +} + export interface PostStartOptions { openVSCode: boolean; openCursor: boolean; @@ -141,6 +162,21 @@ export async function startEnvironment( await landoRebuild( lando, instancePath ); } + let isEnvironmentUp = await waitForEnvironmentToBeUp( lando, instancePath ); + if ( ! isEnvironmentUp ) { + // A second startup pass helps recover after Docker network auto-cleanup edge cases. + await landoStart( lando, instancePath ); + isEnvironmentUp = await waitForEnvironmentToBeUp( lando, instancePath ); + } + + if ( ! isEnvironmentUp ) { + throw new UserError( + `Environment "${ slug }" did not reach a running state. Please try "${ chalk.bold( + `vip dev-env start --slug ${ slug }` + ) }" again.` + ); + } + await printEnvironmentInfo( lando, slug, { extended: false } ); }