From b7fcd04d8758f7cf6f636aae1348712bf07f2d98 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 12 Feb 2026 13:01:00 -0600 Subject: [PATCH 1/8] Agents and Claude --- AGENTS.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 3 +++ 2 files changed, 83 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..a0f68f399 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# AGENTS: vip-cli + +Guide for future agents working on this codebase. Focus on traps, cross-cutting constraints, and how to avoid breaking prod while refactoring or migrating the CLI parser. + +## Repo Orientation +- Entrypoints live in `src/bin` (one file per CLI command) and are compiled to `dist/**`. Do not edit `dist`; rebuild via `npm run build` before publishing. +- Shared logic sits under `src/lib`; GraphQL command wrappers in `src/commands`; fixtures/tests in `__fixtures__` and `__tests__` (E2E lives in `__tests__/devenv-e2e`). +- Config required at runtime: `config/config.publish.json` (or `config.local.json` in dev). Missing files cause a hard exit. +- Babel (not tsc) performs builds; target is Node 18 in `babel.config.js` even though `package.json#engines.node` is 20+. Be cautious using very new Node APIs unless polyfilled. + +## Command Anatomy (current `args` DSL) +- Every bin uses `command()` from `src/lib/cli/command.js`. It mutates the `args` package: `_opts` is module-scoped, and `args.argv` is replaced to add async parsing, validation, telemetry, and app/env lookup before your handler runs. +- Call shape: `command(opts).option(...).command(...sub...).example(...).argv(process.argv, handler)`. `handler` receives `(subArgsArray, optionsObject)` where `subArgsArray` are positional args (alias removed) and `optionsObject` holds flags plus resolved `app`/`env` when requested. +- `_opts` knobs: `appContext`/`envContext`/`childEnvContext` run GraphQL lookups (using `appQuery` + optional fragments) and interactive prompts when `--app/--env` are missing; `childEnvContext` forbids production. `requiredArgs` enforces positional count; `wildcardCommand` disables subcommand validation. `format` adds `--format` defaulting to table and postprocesses handler results; `requireConfirm` + `--force` gates destructive paths with `enquirer` prompts; `skipConfirmPrompt` bypasses the prompt (used in tests). +- Alias handling happens before parsing: `@app` or `@app.env` is stripped from `argv` in `envAlias.ts` (only before `--`) and populates `options.app/options.env`. Using both alias and `--app/--env` exits with an error. +- Global flags injected everywhere: `--help/--version/--debug`. `--debug` enables `debug` namespaces (`*` when boolean). `update-notifier` runs after validation unless `NODE_ENV=test`. +- Output contract: if handler returns `{header,data}` it prints header as key/value then formats `data`; if it returns an array it strips `__typename` and formats; returning `undefined` skips printing. Formatting uses `formatData` with `table|csv|json`. +- Caveat: `_opts` is shared. Instantiating multiple command runners in one process (tests, composite commands) can leak settings—avoid or refactor. + +## Build, Test, Tooling +- `npm test` runs lint + type-check + jest; slow. Use `npm run jest` to skip lint/tsc when iterating. `NODE_ENV=test` also suppresses the update-notifier network call in `src/lib/cli/command.js`. +- E2E dev-env tests (`npm run test:e2e:dev-env`) require Docker + Lando and will mutate the host; they are excluded from the default test script. +- GraphQL types are generated from a private `schema.gql` (`codegen.ts`). To regenerate you need that schema plus `npm run typescript:codegen:*`; do not hand-edit `src/graphqlTypes.d.ts` or `*.generated.d.ts` files. +- go-search-replace binaries are needed for some runtime paths and tests; fixtures live in `__fixtures__/search-replace-binaries`. Without them certain commands/tests will fail silently or skip. +- Postinstall runs `helpers/check-version.js` and will exit if Node is outside the engine range; keep the local version aligned. + +## CLI Architecture +- Root executable is `src/bin/vip.js`. It triggers login unless one of: `--help/-h`, `--version/-v`, `logout`, `dev-env` without env args, or `WPVIP_DEPLOY_TOKEN` is set for deploy. Automation that lacks a token should pass `--help` or set `WPVIP_DEPLOY_TOKEN` to avoid interactive prompts that call `open`. +- Command wiring happens through `src/lib/cli/command.js`, a thin wrapper around `args` with custom validation and telemetry. Options in `_opts` control behavior: + - `appContext`/`envContext`/`childEnvContext` prompt or validate app/env via GraphQL (uses `appQuery` + optional fragments). Child env forbids production. + - `requiredArgs` forces positional arg count; `wildcardCommand` relaxes subcommand validation; `format` auto-adds `--format` and postformats output. + - `requireConfirm` + `--force` gate destructive actions with `enquirer` confirmations; tests should set `skipConfirmPrompt` or pass `--force`. +- Environment aliases like `@app.env` are parsed in `src/lib/cli/envAlias.ts`; aliases are stripped from argv and populate `--app/--env`. Using both alias and explicit flags is rejected. +- `args.argv` is monkey-patched to add the above behavior; avoid invoking multiple command instances in the same process unless you understand the shared `_opts` state. + +## Auth & Session Flow +- Auth is centralized in `src/bin/vip.js`. It loads a JWT from keychain (`Token.get()`), checks `id/iat/exp`, and considers the CLI “logged in” when valid. A missing/invalid token triggers an interactive login unless the argv contains help/version/logout, a `dev-env` call without env, or a deploy using `WPVIP_DEPLOY_TOKEN`. +- Login flow: prints banner, opens `https://dashboard.wpvip.com/me/cli/token` via `open`, prompts for token with `enquirer`, decodes and validates JWT, stores it (`Token.set()`), de-anonymizes analytics via `aliasUser`, then re-enters command dispatch. Errors (expired/malformed) exit with guidance and telemetry events. +- Token storage is per-`API_HOST`: service name changes with host, so switching to staging/local uses a different stored token. +- Downstream commands assume valid auth. The Apollo client exits on 401 unless constructed with `silenceAuthErrors`/`exitOnError=false`. + +## API/GraphQL Layer +- `src/lib/api.ts` builds an Apollo client with `ErrorLink` that prints GraphQL errors and calls `process.exit(1)` by default. Use `disableGlobalGraphQLErrorHandling()` in tests to keep errors throwable. +- Retry logic only retries queries (not mutations) and stops after 5 attempts; ECONNREFUSED triggers retry, 4xx (except 429) does not. Be careful when wrapping mutations—errors will not retry. +- On 401, the client prints a custom message and exits; ensure authenticated tests stub the network or set `silenceAuthErrors`/`exitOnError=false` when constructing the client. + +## Dev-Env Subsystem (High Blast Radius) +- Implemented under `src/lib/dev-environment/**`; shells out to Lando and Docker, renders templates from `assets/dev-env.*.ejs`, and writes to per-environment folders inside `xdgData()/vip-cli` (overridden by `XDG_DATA_HOME`). Running these commands mutates local docker networks and may fetch WP/PHP version metadata from GitHub constants. +- Proxy helpers live in `src/lib/http/proxy-*`; dev-env code constructs agents automatically using `VIP_PROXY`/`SOCKS_PROXY`/`HTTP_PROXY`/`VIP_USE_SYSTEM_PROXY`. Unexpected proxies can break downloads—clear those env vars when debugging. +- Avoid invoking dev-env logic in unit tests unless you mock `lando`, filesystem, and network; the E2E suite covers the real paths. + +## Import/Export/Sync Commands (high validation) +- Heavy validators live in `src/lib/site-import/**` and `src/lib/validations/**`. `vip import sql` enforces file name rules, extension checks, size caps (`SQL_IMPORT_FILE_SIZE_LIMIT*`), and detects multisite; it may upload to S3 via `src/lib/client-file-uploader.ts` (expects readable file or URL and optional MD5). These paths also emit analytics; use `NODE_ENV=test` and stubs to avoid network. +- Sync/backup/snapshot commands rely on GraphQL fields like `syncPreview` and environment metadata; `command.js` will prompt for app/env selection via GraphQL if not provided. +- These commands stack multiple prompts (confirm, reload manifest, error-log download); in headless runs pass `--force` and other flags to skip interaction. + +## Data Paths & Temp Files +- Temporary working dirs are created with `makeTempDir()` and cleaned up on normal exit only. Crashes may leave artifacts under the system tmp folder. +- Persistent data (tokens, analytics UUID, dev-env state, caches) lands in Configstore or under `xdgData()`; clean those if you hit unexplained state issues. + +## Release & Packaging +- `prepare` runs `npm run clean && npm run build`; npm package bins point to `dist/**`. Always rebuild before publishing so dist matches src. +- `helpers/prepublishOnly.js` enforces branch `trunk` for `npm publish --tag latest` and optionally reruns `npm test`. Release flows expect a clean node version that satisfies `engines.node`. + +## Common Pitfalls Checklist +- Running CLI without a token opens a browser (`open`) and waits for interactive input—pass `--help` or set `WPVIP_DEPLOY_TOKEN` in automation. +- Forgetting `--app/--env` or alias when a command expects them triggers extra GraphQL lookups and prompts; in headless contexts set `_opts.appContext=false` or supply explicit flags. +- Analytics + update-notifier will reach the public internet unless `DO_NOT_TRACK=1` and `NODE_ENV=test` are set. +- Babel pathing relies on relative `__dirname` from transpiled files; when moving files adjust import paths with the compiled layout in mind. +- TypeScript is type-checked separately from the build; Babel will happily emit code that fails `tsc`, so keep `npm run check-types` in the loop during refactors. + +## Migration off `args` (e.g., to Commander) +- Preserve pre-parse alias stripping and the “only consider args before `--`” rule in `parseEnvAliasFromArgv`. Reject mixed alias + `--app/--env`. +- Reimplement `_opts` behaviors: GraphQL app/env lookup plus prompts, production ban for child envs, positional validation, wildcard subcommand allowance, `--format` processing, `requireConfirm` + `--force`, and module-specific confirmation payloads (import-sql, sync, import-media). +- Maintain global flags and side effects: help/version/debug on every subcommand; update-notifier (or intentionally suppress); `debug` namespace toggling. +- Keep telemetry hooks (`trackEvent`, `trackEventWithEnv`, `makeCommandTracker`) and the login-time `aliasUser` de-anonymization. +- Match handler contracts: handler args `(subArgs, options)`; output formatting expectations (array or `{header,data}`); `__typename` stripping. +- Respect exit semantics: uncaught exceptions routed to `exit.withError`; GraphQL errors call `process.exit(1)` unless explicitly disabled; 401 auth errors exit with guidance. +- Watch shared state: current implementation mutates a global; ensure new parser avoids cross-command bleeding when multiple command instances run in-process (tests/CLI wrappers). +- Login guardrails in `vip.js` let certain commands bypass auth; preserve equivalent shortcuts or add explicit non-interactive flags for CI. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1e7b1d4e8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE + +For guidance on working in this repo, traps, and migration notes, see `AGENTS.md`. From 61f1ee9746151bbde2443587311a0a551c207e3b Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 12 Feb 2026 13:43:43 -0600 Subject: [PATCH 2/8] First pass --- npm-shrinkwrap.json | 19 ++- package.json | 1 + src/bin/vip-logout.ts | 2 +- src/bin/vip-whoami.ts | 2 +- src/bin/vip.js | 41 +++++- src/lib/cli/command-commander.ts | 246 +++++++++++++++++++++++++++++++ 6 files changed, 301 insertions(+), 10 deletions(-) create mode 100644 src/lib/cli/command-commander.ts diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index a9a438a61..2d71064ba 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -19,6 +19,7 @@ "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": "^4.0.1", @@ -263,6 +264,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", @@ -6341,13 +6351,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": { diff --git a/package.json b/package.json index 1c8d2b8ce..c4a7daa16 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "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": "^4.0.1", diff --git a/src/bin/vip-logout.ts b/src/bin/vip-logout.ts index beaf575e1..97e97e971 100755 --- a/src/bin/vip-logout.ts +++ b/src/bin/vip-logout.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import command from '../lib/cli/command'; +import { command } from '../lib/cli/command-commander'; import logout from '../lib/logout'; void command( { usage: 'vip logout' } ) diff --git a/src/bin/vip-whoami.ts b/src/bin/vip-whoami.ts index aafb8ee95..0b80adffe 100755 --- a/src/bin/vip-whoami.ts +++ b/src/bin/vip-whoami.ts @@ -2,7 +2,7 @@ import { Me } from '../graphqlTypes'; import { getCurrentUserInfo } from '../lib/api/user'; -import command from '../lib/cli/command'; +import { command } from '../lib/cli/command-commander'; import * as exit from '../lib/cli/exit'; import { trackEvent } from '../lib/tracker'; diff --git a/src/bin/vip.js b/src/bin/vip.js index a991da695..536e43746 100755 --- a/src/bin/vip.js +++ b/src/bin/vip.js @@ -3,11 +3,13 @@ import '../lib/node-version-check'; import chalk from 'chalk'; +import { Command } from 'commander'; import debugLib from 'debug'; import { prompt } from 'enquirer'; -import command, { containsAppEnvArgument } from '../lib/cli/command'; +import pkg from '../../package.json'; import config from '../lib/cli/config'; +import { parseEnvAliasFromArgv } from '../lib/cli/envAlias'; import Token from '../lib/token'; import { aliasUser, trackEvent } from '../lib/tracker'; @@ -25,8 +27,29 @@ const tokenURL = 'https://dashboard.wpvip.com/me/cli/token'; const customDeployToken = process.env.WPVIP_DEPLOY_TOKEN; const runCmd = async function () { - const cmd = command(); + const cmd = new Command(); cmd + .name( 'vip' ) + .version( + pkg.version, + '-v, --version', + 'Retrieve the version number of VIP-CLI currently installed on the local machine.' + ) + .helpOption( + '-h, --help', + 'Retrieve a description, examples, and available options for a (sub)command.' + ) + .option( + '-d, --debug [namespaces]', + 'Generate verbose output during command execution to help identify or fix errors or bugs.', + value => { + const namespaces = value || '*'; + debugLib.enable( namespaces ); + process.env.DEBUG = namespaces; + + return value || true; + } + ) .command( 'logout', 'Log out the current authenticated VIP-CLI user.' ) .command( 'app', @@ -49,7 +72,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.parseAsync( process.argv ); }; /** @@ -61,6 +84,18 @@ function doesArgvHaveAtLeastOneParam( argv, params ) { return argv.some( arg => params.includes( arg ) ); } +/** + * @param {string[]} argv + * @returns {boolean} + */ +function containsAppEnvArgument( argv ) { + const parsedAlias = parseEnvAliasFromArgv( argv ); + + return Boolean( + parsedAlias.app || parsedAlias.env || argv.includes( '--app' ) || argv.includes( '--env' ) + ); +} + const rootCmd = async function () { let token = await Token.get(); diff --git a/src/lib/cli/command-commander.ts b/src/lib/cli/command-commander.ts new file mode 100644 index 000000000..7386dc1e8 --- /dev/null +++ b/src/lib/cli/command-commander.ts @@ -0,0 +1,246 @@ +import { Command } from 'commander'; +import debugLib from 'debug'; + +import { parseEnvAliasFromArgv } from './envAlias'; +import * as exit from './exit'; +import { formatData } from './format'; +import pkg from '../../../package.json'; + +import type { OutputFormat, Tuple } from './format'; + +interface Example { + usage: string; + description: string; +} + +interface CommandOptions { + usage?: string; + requiredArgs?: number; + wildcardCommand?: boolean; + format?: boolean; +} + +type CommandResult = + | { + header?: Tuple[]; + data?: Record< string, unknown >[]; + } + | Record< string, unknown >[] + | undefined; + +type CommandCallback = ( subArgs: string[], options: Record< string, unknown > ) => unknown; + +function normalizeUsage( program: Command, usage?: string ) { + if ( ! usage ) { + return; + } + + const [ rootCommand, ...rest ] = usage.trim().split( /\s+/ ); + if ( rootCommand ) { + program.name( rootCommand ); + } + + if ( rest.length ) { + const usageValue = rest.join( ' ' ); + program.usage( usageValue.includes( '[options]' ) ? usageValue : `${ usageValue } [options]` ); + } +} + +function getOptionSpec( name: string, defaultValue?: unknown ): string { + const normalizedName = name.trim().replace( /^--?/, '' ); + if ( typeof defaultValue === 'boolean' ) { + return `--${ normalizedName }`; + } + + return `--${ normalizedName } `; +} + +function formatExamples( examples: Example[] ): string { + if ( ! examples.length ) { + return ''; + } + + const lines = examples.flatMap( example => [ + ` - ${ example.description }`, + ` $ ${ example.usage }`, + '', + ] ); + + return [ '', 'Examples:', ...lines ].join( '\n' ).trimEnd(); +} + +export default class CommanderCompatCommand { + private readonly options: Required< CommandOptions >; + private readonly program: Command; + private readonly examplesList: Example[] = []; + private readonly subcommandNames = new Set< string >(); + + constructor( opts: CommandOptions = {} ) { + this.options = { + usage: opts.usage ?? '', + requiredArgs: opts.requiredArgs ?? 0, + wildcardCommand: opts.wildcardCommand ?? false, + format: opts.format ?? false, + }; + + this.program = new Command(); + normalizeUsage( this.program, this.options.usage ); + + this.program + .version( + pkg.version, + '-v, --version', + 'Retrieve the version number of VIP-CLI currently installed on the local machine.' + ) + .helpOption( + '-h, --help', + 'Retrieve a description, examples, and available options for a (sub)command.' + ) + .option( + '-d, --debug [namespaces]', + 'Generate verbose output during command execution to help identify or fix errors or bugs.', + value => { + const namespaces = value || '*'; + debugLib.enable( namespaces ); + process.env.DEBUG = namespaces; + + return value || true; + } + ); + + if ( this.options.format ) { + this.program.option( + '--format ', + 'Render output in a particular format. Accepts “table“ (default), “csv“, and “json“.', + 'table' + ); + } + } + + public option( name: string, description: string, defaultValue?: unknown ): this { + const spec = getOptionSpec( name, defaultValue ); + + if ( undefined === defaultValue ) { + this.program.option( spec, description ); + } else { + this.program.option( spec, description, defaultValue as never ); + } + + return this; + } + + public command( name: string, description?: string ): this { + const subcommandName = name.trim().split( /\s+/ )[ 0 ]; + this.subcommandNames.add( subcommandName ); + this.program.command( name ).description( description ?? '' ); + + return this; + } + + public example( usage: string, description: string ): this { + this.examplesList.push( { + usage, + description, + } ); + + return this; + } + + public examples( examples: Example[] ): this { + for ( const example of examples ) { + this.examplesList.push( example ); + } + + return this; + } + + // eslint-disable-next-line complexity + public async argv( argv: string[], cb?: CommandCallback ): Promise< Record< string, unknown > > { + const examplesText = formatExamples( this.examplesList ); + if ( examplesText ) { + this.program.addHelpText( 'after', examplesText ); + } + + const parsedAlias = parseEnvAliasFromArgv( argv ); + await this.program.parseAsync( parsedAlias.argv ); + + const options = this.program.opts< Record< string, unknown > >(); + + if ( parsedAlias.app && ( options.app || options.env ) ) { + exit.withError( + 'Please only use an environment alias, or the --app and --env parameters, but not both' + ); + } + + if ( parsedAlias.app ) { + options.app = parsedAlias.app; + options.env = parsedAlias.env; + } + + if ( + ! this.options.wildcardCommand && + this.subcommandNames.size && + ! this.program.args.length + ) { + this.program.help(); + } + + if ( this.options.requiredArgs > this.program.args.length ) { + this.program.help( { error: true } ); + } + + if ( process.env.NODE_ENV !== 'test' ) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const updateNotifier = require( 'update-notifier' ) as ( options: { + pkg: typeof pkg; + updateCheckInterval: number; + } ) => { notify: ( options: { isGlobal: boolean } ) => void }; + + updateNotifier( { pkg, updateCheckInterval: 1000 * 60 * 60 * 24 } ).notify( { + isGlobal: true, + } ); + } + + if ( ! cb ) { + return options; + } + + let result = ( await cb( this.program.args, options ) ) as CommandResult; + if ( this.options.format && result ) { + if ( ! Array.isArray( result ) && result.header && result.data ) { + if ( options.format !== 'json' ) { + console.log( formatData( result.header, 'keyValue' ) ); + } + + result = result.data; + } + + if ( Array.isArray( result ) ) { + const sanitized = result.map( row => { + const output = { ...row }; + if ( Object.hasOwn( output, '__typename' ) ) { + delete output.__typename; + } + + return output; + } ); + + const outputFormat: OutputFormat = + options.format === 'csv' || options.format === 'json' ? options.format : 'table'; + + console.log( + formatData( + sanitized as Record< string, string | { toString: () => string } >[], + outputFormat + ) + ); + } + } + + return options; + } +} + +export function command( opts: CommandOptions = {} ): CommanderCompatCommand { + return new CommanderCompatCommand( opts ); +} From ce6a987847b39b42669c862f349d57bce14019f6 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 12 Feb 2026 14:11:25 -0600 Subject: [PATCH 3/8] Migrate all commands --- AGENTS.md | 22 +-- npm-shrinkwrap.json | 114 ------------ package.json | 1 - src/bin/vip-logout.ts | 2 +- src/bin/vip-whoami.ts | 2 +- src/bin/vip.js | 41 +---- src/lib/cli/command-commander.ts | 246 -------------------------- src/lib/cli/command.js | 288 +++++++++++++++++++++++++------ 8 files changed, 251 insertions(+), 465 deletions(-) delete mode 100644 src/lib/cli/command-commander.ts diff --git a/AGENTS.md b/AGENTS.md index a0f68f399..6c87f5de8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,12 +8,13 @@ Guide for future agents working on this codebase. Focus on traps, cross-cutting - Config required at runtime: `config/config.publish.json` (or `config.local.json` in dev). Missing files cause a hard exit. - Babel (not tsc) performs builds; target is Node 18 in `babel.config.js` even though `package.json#engines.node` is 20+. Be cautious using very new Node APIs unless polyfilled. -## Command Anatomy (current `args` DSL) -- Every bin uses `command()` from `src/lib/cli/command.js`. It mutates the `args` package: `_opts` is module-scoped, and `args.argv` is replaced to add async parsing, validation, telemetry, and app/env lookup before your handler runs. +## Command Anatomy (current Commander compatibility layer) +- Every bin uses `command()` from `src/lib/cli/command.js`. It wraps `commander` and keeps the legacy handler contract while preserving app/env context resolution, confirmations, telemetry, and output formatting. - Call shape: `command(opts).option(...).command(...sub...).example(...).argv(process.argv, handler)`. `handler` receives `(subArgsArray, optionsObject)` where `subArgsArray` are positional args (alias removed) and `optionsObject` holds flags plus resolved `app`/`env` when requested. - `_opts` knobs: `appContext`/`envContext`/`childEnvContext` run GraphQL lookups (using `appQuery` + optional fragments) and interactive prompts when `--app/--env` are missing; `childEnvContext` forbids production. `requiredArgs` enforces positional count; `wildcardCommand` disables subcommand validation. `format` adds `--format` defaulting to table and postprocesses handler results; `requireConfirm` + `--force` gates destructive paths with `enquirer` prompts; `skipConfirmPrompt` bypasses the prompt (used in tests). - Alias handling happens before parsing: `@app` or `@app.env` is stripped from `argv` in `envAlias.ts` (only before `--`) and populates `options.app/options.env`. Using both alias and `--app/--env` exits with an error. - Global flags injected everywhere: `--help/--version/--debug`. `--debug` enables `debug` namespaces (`*` when boolean). `update-notifier` runs after validation unless `NODE_ENV=test`. +- Nested subcommands are dispatched by the wrapper to sibling bin scripts (`vip-config` -> `vip-config-envvar` -> `vip-config-envvar-set`) using local `dist/bin` paths when available. - Output contract: if handler returns `{header,data}` it prints header as key/value then formats `data`; if it returns an array it strips `__typename` and formats; returning `undefined` skips printing. Formatting uses `formatData` with `table|csv|json`. - Caveat: `_opts` is shared. Instantiating multiple command runners in one process (tests, composite commands) can leak settings—avoid or refactor. @@ -26,12 +27,12 @@ Guide for future agents working on this codebase. Focus on traps, cross-cutting ## CLI Architecture - Root executable is `src/bin/vip.js`. It triggers login unless one of: `--help/-h`, `--version/-v`, `logout`, `dev-env` without env args, or `WPVIP_DEPLOY_TOKEN` is set for deploy. Automation that lacks a token should pass `--help` or set `WPVIP_DEPLOY_TOKEN` to avoid interactive prompts that call `open`. -- Command wiring happens through `src/lib/cli/command.js`, a thin wrapper around `args` with custom validation and telemetry. Options in `_opts` control behavior: +- Command wiring happens through `src/lib/cli/command.js`, a `commander`-backed compatibility wrapper with custom validation and telemetry. Options in `_opts` control behavior: - `appContext`/`envContext`/`childEnvContext` prompt or validate app/env via GraphQL (uses `appQuery` + optional fragments). Child env forbids production. - `requiredArgs` forces positional arg count; `wildcardCommand` relaxes subcommand validation; `format` auto-adds `--format` and postformats output. - `requireConfirm` + `--force` gate destructive actions with `enquirer` confirmations; tests should set `skipConfirmPrompt` or pass `--force`. - Environment aliases like `@app.env` are parsed in `src/lib/cli/envAlias.ts`; aliases are stripped from argv and populate `--app/--env`. Using both alias and explicit flags is rejected. -- `args.argv` is monkey-patched to add the above behavior; avoid invoking multiple command instances in the same process unless you understand the shared `_opts` state. +- Subcommand chaining now happens in the wrapper itself (instead of `args`), so behavior changes here impact the entire CLI tree. ## Auth & Session Flow - Auth is centralized in `src/bin/vip.js`. It loads a JWT from keychain (`Token.get()`), checks `id/iat/exp`, and considers the CLI “logged in” when valid. A missing/invalid token triggers an interactive login unless the argv contains help/version/logout, a `dev-env` call without env, or a deploy using `WPVIP_DEPLOY_TOKEN`. @@ -69,12 +70,7 @@ Guide for future agents working on this codebase. Focus on traps, cross-cutting - Babel pathing relies on relative `__dirname` from transpiled files; when moving files adjust import paths with the compiled layout in mind. - TypeScript is type-checked separately from the build; Babel will happily emit code that fails `tsc`, so keep `npm run check-types` in the loop during refactors. -## Migration off `args` (e.g., to Commander) -- Preserve pre-parse alias stripping and the “only consider args before `--`” rule in `parseEnvAliasFromArgv`. Reject mixed alias + `--app/--env`. -- Reimplement `_opts` behaviors: GraphQL app/env lookup plus prompts, production ban for child envs, positional validation, wildcard subcommand allowance, `--format` processing, `requireConfirm` + `--force`, and module-specific confirmation payloads (import-sql, sync, import-media). -- Maintain global flags and side effects: help/version/debug on every subcommand; update-notifier (or intentionally suppress); `debug` namespace toggling. -- Keep telemetry hooks (`trackEvent`, `trackEventWithEnv`, `makeCommandTracker`) and the login-time `aliasUser` de-anonymization. -- Match handler contracts: handler args `(subArgs, options)`; output formatting expectations (array or `{header,data}`); `__typename` stripping. -- Respect exit semantics: uncaught exceptions routed to `exit.withError`; GraphQL errors call `process.exit(1)` unless explicitly disabled; 401 auth errors exit with guidance. -- Watch shared state: current implementation mutates a global; ensure new parser avoids cross-command bleeding when multiple command instances run in-process (tests/CLI wrappers). -- Login guardrails in `vip.js` let certain commands bypass auth; preserve equivalent shortcuts or add explicit non-interactive flags for CI. +## Commander Migration Status +- Core parser migration is active in `src/lib/cli/command.js`; command bins using this wrapper run on Commander semantics. +- High-risk parity points still worth verifying during further cleanup: help text formatting parity, boolean option edge cases, and deeply nested subcommand/alias combinations. +- Keep alias stripping behavior (`parseEnvAliasFromArgv`), `_opts` contracts, and telemetry hooks stable while iterating. diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 2d71064ba..95238e011 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -14,7 +14,6 @@ "@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", @@ -5132,92 +5131,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", @@ -6042,15 +5955,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.30001760", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", @@ -11553,15 +11457,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", @@ -11859,15 +11754,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 c4a7daa16..c24b31e3b 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,6 @@ "@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", diff --git a/src/bin/vip-logout.ts b/src/bin/vip-logout.ts index 97e97e971..beaf575e1 100755 --- a/src/bin/vip-logout.ts +++ b/src/bin/vip-logout.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { command } from '../lib/cli/command-commander'; +import command from '../lib/cli/command'; import logout from '../lib/logout'; void command( { usage: 'vip logout' } ) diff --git a/src/bin/vip-whoami.ts b/src/bin/vip-whoami.ts index 0b80adffe..aafb8ee95 100755 --- a/src/bin/vip-whoami.ts +++ b/src/bin/vip-whoami.ts @@ -2,7 +2,7 @@ import { Me } from '../graphqlTypes'; import { getCurrentUserInfo } from '../lib/api/user'; -import { command } from '../lib/cli/command-commander'; +import command from '../lib/cli/command'; import * as exit from '../lib/cli/exit'; import { trackEvent } from '../lib/tracker'; diff --git a/src/bin/vip.js b/src/bin/vip.js index 536e43746..21388c44f 100755 --- a/src/bin/vip.js +++ b/src/bin/vip.js @@ -3,13 +3,11 @@ import '../lib/node-version-check'; import chalk from 'chalk'; -import { Command } from 'commander'; import debugLib from 'debug'; import { prompt } from 'enquirer'; -import pkg from '../../package.json'; +import command, { containsAppEnvArgument } from '../lib/cli/command'; import config from '../lib/cli/config'; -import { parseEnvAliasFromArgv } from '../lib/cli/envAlias'; import Token from '../lib/token'; import { aliasUser, trackEvent } from '../lib/tracker'; @@ -27,29 +25,8 @@ const tokenURL = 'https://dashboard.wpvip.com/me/cli/token'; const customDeployToken = process.env.WPVIP_DEPLOY_TOKEN; const runCmd = async function () { - const cmd = new Command(); + const cmd = command(); cmd - .name( 'vip' ) - .version( - pkg.version, - '-v, --version', - 'Retrieve the version number of VIP-CLI currently installed on the local machine.' - ) - .helpOption( - '-h, --help', - 'Retrieve a description, examples, and available options for a (sub)command.' - ) - .option( - '-d, --debug [namespaces]', - 'Generate verbose output during command execution to help identify or fix errors or bugs.', - value => { - const namespaces = value || '*'; - debugLib.enable( namespaces ); - process.env.DEBUG = namespaces; - - return value || true; - } - ) .command( 'logout', 'Log out the current authenticated VIP-CLI user.' ) .command( 'app', @@ -72,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.' ); - await cmd.parseAsync( process.argv ); + await cmd.argv( process.argv ); }; /** @@ -84,18 +61,6 @@ function doesArgvHaveAtLeastOneParam( argv, params ) { return argv.some( arg => params.includes( arg ) ); } -/** - * @param {string[]} argv - * @returns {boolean} - */ -function containsAppEnvArgument( argv ) { - const parsedAlias = parseEnvAliasFromArgv( argv ); - - return Boolean( - parsedAlias.app || parsedAlias.env || argv.includes( '--app' ) || argv.includes( '--env' ) - ); -} - const rootCmd = async function () { let token = await Token.get(); diff --git a/src/lib/cli/command-commander.ts b/src/lib/cli/command-commander.ts deleted file mode 100644 index 7386dc1e8..000000000 --- a/src/lib/cli/command-commander.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { Command } from 'commander'; -import debugLib from 'debug'; - -import { parseEnvAliasFromArgv } from './envAlias'; -import * as exit from './exit'; -import { formatData } from './format'; -import pkg from '../../../package.json'; - -import type { OutputFormat, Tuple } from './format'; - -interface Example { - usage: string; - description: string; -} - -interface CommandOptions { - usage?: string; - requiredArgs?: number; - wildcardCommand?: boolean; - format?: boolean; -} - -type CommandResult = - | { - header?: Tuple[]; - data?: Record< string, unknown >[]; - } - | Record< string, unknown >[] - | undefined; - -type CommandCallback = ( subArgs: string[], options: Record< string, unknown > ) => unknown; - -function normalizeUsage( program: Command, usage?: string ) { - if ( ! usage ) { - return; - } - - const [ rootCommand, ...rest ] = usage.trim().split( /\s+/ ); - if ( rootCommand ) { - program.name( rootCommand ); - } - - if ( rest.length ) { - const usageValue = rest.join( ' ' ); - program.usage( usageValue.includes( '[options]' ) ? usageValue : `${ usageValue } [options]` ); - } -} - -function getOptionSpec( name: string, defaultValue?: unknown ): string { - const normalizedName = name.trim().replace( /^--?/, '' ); - if ( typeof defaultValue === 'boolean' ) { - return `--${ normalizedName }`; - } - - return `--${ normalizedName } `; -} - -function formatExamples( examples: Example[] ): string { - if ( ! examples.length ) { - return ''; - } - - const lines = examples.flatMap( example => [ - ` - ${ example.description }`, - ` $ ${ example.usage }`, - '', - ] ); - - return [ '', 'Examples:', ...lines ].join( '\n' ).trimEnd(); -} - -export default class CommanderCompatCommand { - private readonly options: Required< CommandOptions >; - private readonly program: Command; - private readonly examplesList: Example[] = []; - private readonly subcommandNames = new Set< string >(); - - constructor( opts: CommandOptions = {} ) { - this.options = { - usage: opts.usage ?? '', - requiredArgs: opts.requiredArgs ?? 0, - wildcardCommand: opts.wildcardCommand ?? false, - format: opts.format ?? false, - }; - - this.program = new Command(); - normalizeUsage( this.program, this.options.usage ); - - this.program - .version( - pkg.version, - '-v, --version', - 'Retrieve the version number of VIP-CLI currently installed on the local machine.' - ) - .helpOption( - '-h, --help', - 'Retrieve a description, examples, and available options for a (sub)command.' - ) - .option( - '-d, --debug [namespaces]', - 'Generate verbose output during command execution to help identify or fix errors or bugs.', - value => { - const namespaces = value || '*'; - debugLib.enable( namespaces ); - process.env.DEBUG = namespaces; - - return value || true; - } - ); - - if ( this.options.format ) { - this.program.option( - '--format ', - 'Render output in a particular format. Accepts “table“ (default), “csv“, and “json“.', - 'table' - ); - } - } - - public option( name: string, description: string, defaultValue?: unknown ): this { - const spec = getOptionSpec( name, defaultValue ); - - if ( undefined === defaultValue ) { - this.program.option( spec, description ); - } else { - this.program.option( spec, description, defaultValue as never ); - } - - return this; - } - - public command( name: string, description?: string ): this { - const subcommandName = name.trim().split( /\s+/ )[ 0 ]; - this.subcommandNames.add( subcommandName ); - this.program.command( name ).description( description ?? '' ); - - return this; - } - - public example( usage: string, description: string ): this { - this.examplesList.push( { - usage, - description, - } ); - - return this; - } - - public examples( examples: Example[] ): this { - for ( const example of examples ) { - this.examplesList.push( example ); - } - - return this; - } - - // eslint-disable-next-line complexity - public async argv( argv: string[], cb?: CommandCallback ): Promise< Record< string, unknown > > { - const examplesText = formatExamples( this.examplesList ); - if ( examplesText ) { - this.program.addHelpText( 'after', examplesText ); - } - - const parsedAlias = parseEnvAliasFromArgv( argv ); - await this.program.parseAsync( parsedAlias.argv ); - - const options = this.program.opts< Record< string, unknown > >(); - - if ( parsedAlias.app && ( options.app || options.env ) ) { - exit.withError( - 'Please only use an environment alias, or the --app and --env parameters, but not both' - ); - } - - if ( parsedAlias.app ) { - options.app = parsedAlias.app; - options.env = parsedAlias.env; - } - - if ( - ! this.options.wildcardCommand && - this.subcommandNames.size && - ! this.program.args.length - ) { - this.program.help(); - } - - if ( this.options.requiredArgs > this.program.args.length ) { - this.program.help( { error: true } ); - } - - if ( process.env.NODE_ENV !== 'test' ) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const updateNotifier = require( 'update-notifier' ) as ( options: { - pkg: typeof pkg; - updateCheckInterval: number; - } ) => { notify: ( options: { isGlobal: boolean } ) => void }; - - updateNotifier( { pkg, updateCheckInterval: 1000 * 60 * 60 * 24 } ).notify( { - isGlobal: true, - } ); - } - - if ( ! cb ) { - return options; - } - - let result = ( await cb( this.program.args, options ) ) as CommandResult; - if ( this.options.format && result ) { - if ( ! Array.isArray( result ) && result.header && result.data ) { - if ( options.format !== 'json' ) { - console.log( formatData( result.header, 'keyValue' ) ); - } - - result = result.data; - } - - if ( Array.isArray( result ) ) { - const sanitized = result.map( row => { - const output = { ...row }; - if ( Object.hasOwn( output, '__typename' ) ) { - delete output.__typename; - } - - return output; - } ); - - const outputFormat: OutputFormat = - options.format === 'csv' || options.format === 'json' ? options.format : 'table'; - - console.log( - formatData( - sanitized as Record< string, string | { toString: () => string } >[], - outputFormat - ) - ); - } - } - - return options; - } -} - -export function command( opts: CommandOptions = {} ): CommanderCompatCommand { - return new CommanderCompatCommand( opts ); -} diff --git a/src/lib/cli/command.js b/src/lib/cli/command.js index a88013e82..fa87fe816 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'; @@ -36,15 +39,221 @@ let _opts = {}; let alreadyConfirmedDebugAttachment = false; +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 ) { + const isArray = Array.isArray( name ); + const shortName = isArray ? name[ 0 ] : null; + const longName = isArray ? name[ 1 ] : name; + const normalizedLongName = String( longName ).trim().replace( /^--?/, '' ); + const normalizedShortName = shortName ? String( shortName ).trim().replace( /^-/, '' ) : null; + 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._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 ); + 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 +264,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 +308,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 +315,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 +340,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 +602,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 +665,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 +785,7 @@ function validateOpts( opts ) { } /** - * @returns {args} + * @returns {CommanderArgsCompat} */ export default function ( opts ) { _opts = { @@ -618,6 +800,8 @@ export default function ( opts ) { ...opts, }; + const args = new CommanderArgsCompat( _opts ); + if ( _opts.appContext || _opts.requireConfirm ) { args.option( 'app', @@ -642,15 +826,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.' ); From fc7ba9a7793c8568afce333b1f12e46e551cc046 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 12 Feb 2026 15:02:36 -0600 Subject: [PATCH 4/8] Finalize commander move --- src/bin/vip-dev-env-exec.js | 22 +++++++++++- src/lib/cli/command.js | 31 ++++++++++++++-- .../dev-environment/dev-environment-core.ts | 35 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/bin/vip-dev-env-exec.js b/src/bin/vip-dev-env-exec.js index deae0cb85..7861038c0 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/lib/cli/command.js b/src/lib/cli/command.js index fa87fe816..765b1eadd 100644 --- a/src/lib/cli/command.js +++ b/src/lib/cli/command.js @@ -38,6 +38,7 @@ 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 ) { @@ -57,12 +58,29 @@ function normalizeUsage( program, usage ) { } } -function createOptionDefinition( name, description, defaultValue, parseFn ) { +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 normalizedShortName = shortName ? String( shortName ).trim().replace( /^-/, '' ) : null; + 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 => { @@ -113,6 +131,7 @@ class CommanderArgsCompat { }; this.sub = []; this.examplesList = []; + this.usedShortNames = new Set(); this._opts = opts; this.program = new Command(); this.program.allowUnknownOption( true ); @@ -122,7 +141,13 @@ class CommanderArgsCompat { } option( name, description, defaultValue, parseFn ) { - const definition = createOptionDefinition( name, description, defaultValue, parseFn ); + const definition = createOptionDefinition( + name, + description, + defaultValue, + parseFn, + this.usedShortNames + ); const { flags, parser } = definition; if ( parser && defaultValue !== undefined ) { diff --git a/src/lib/dev-environment/dev-environment-core.ts b/src/lib/dev-environment/dev-environment-core.ts index 5a1d4db75..4d5f058bb 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,25 @@ 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 +161,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 } ); } From def09c5b2d6c6fc1601258feaae480031cf235d5 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 12 Feb 2026 15:11:40 -0600 Subject: [PATCH 5/8] Doc updates --- AGENTS.md | 4 ++++ docs/COMMANDER-MIGRATION.md | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 docs/COMMANDER-MIGRATION.md diff --git a/AGENTS.md b/AGENTS.md index 6c87f5de8..448c3762c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ Guide for future agents working on this codebase. Focus on traps, cross-cutting - `_opts` knobs: `appContext`/`envContext`/`childEnvContext` run GraphQL lookups (using `appQuery` + optional fragments) and interactive prompts when `--app/--env` are missing; `childEnvContext` forbids production. `requiredArgs` enforces positional count; `wildcardCommand` disables subcommand validation. `format` adds `--format` defaulting to table and postprocesses handler results; `requireConfirm` + `--force` gates destructive paths with `enquirer` prompts; `skipConfirmPrompt` bypasses the prompt (used in tests). - Alias handling happens before parsing: `@app` or `@app.env` is stripped from `argv` in `envAlias.ts` (only before `--`) and populates `options.app/options.env`. Using both alias and `--app/--env` exits with an error. - Global flags injected everywhere: `--help/--version/--debug`. `--debug` enables `debug` namespaces (`*` when boolean). `update-notifier` runs after validation unless `NODE_ENV=test`. +- Short-flag parity detail: if an option has no explicit short alias, the wrapper can auto-assign one from the first long-option character (for `args` compatibility), except reserved globals (`h`, `v`, `d`) and already-used short names. - Nested subcommands are dispatched by the wrapper to sibling bin scripts (`vip-config` -> `vip-config-envvar` -> `vip-config-envvar-set`) using local `dist/bin` paths when available. - Output contract: if handler returns `{header,data}` it prints header as key/value then formats `data`; if it returns an array it strips `__typename` and formats; returning `undefined` skips printing. Formatting uses `formatData` with `table|csv|json`. - Caveat: `_opts` is shared. Instantiating multiple command runners in one process (tests, composite commands) can leak settings—avoid or refactor. @@ -49,6 +50,9 @@ Guide for future agents working on this codebase. Focus on traps, cross-cutting - Implemented under `src/lib/dev-environment/**`; shells out to Lando and Docker, renders templates from `assets/dev-env.*.ejs`, and writes to per-environment folders inside `xdgData()/vip-cli` (overridden by `XDG_DATA_HOME`). Running these commands mutates local docker networks and may fetch WP/PHP version metadata from GitHub constants. - Proxy helpers live in `src/lib/http/proxy-*`; dev-env code constructs agents automatically using `VIP_PROXY`/`SOCKS_PROXY`/`HTTP_PROXY`/`VIP_USE_SYSTEM_PROXY`. Unexpected proxies can break downloads—clear those env vars when debugging. - Avoid invoking dev-env logic in unit tests unless you mock `lando`, filesystem, and network; the E2E suite covers the real paths. +- Runtime resilience safeguards: + - `startEnvironment()` performs bounded readiness checks after start/rebuild and attempts one recovery `landoStart` pass if status remains down. + - `vip-dev-env-exec` performs bounded readiness polling before failing with "running environment required", reducing false negatives under heavy Docker/Lando load. ## Import/Export/Sync Commands (high validation) - Heavy validators live in `src/lib/site-import/**` and `src/lib/validations/**`. `vip import sql` enforces file name rules, extension checks, size caps (`SQL_IMPORT_FILE_SIZE_LIMIT*`), and detects multisite; it may upload to S3 via `src/lib/client-file-uploader.ts` (expects readable file or URL and optional MD5). These paths also emit analytics; use `NODE_ENV=test` and stubs to avoid network. diff --git a/docs/COMMANDER-MIGRATION.md b/docs/COMMANDER-MIGRATION.md new file mode 100644 index 000000000..20f24eddc --- /dev/null +++ b/docs/COMMANDER-MIGRATION.md @@ -0,0 +1,48 @@ +# 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. From b1a99f27b8b544f8a8f7e26feb8fe51a5b28119c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:26:23 +0000 Subject: [PATCH 6/8] Initial plan From d36847bd350550e0cc854c39681a3df35b662842 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:32:37 +0000 Subject: [PATCH 7/8] Initial plan for startEnvironment readiness polling tests Co-authored-by: rinatkhaziev <459254+rinatkhaziev@users.noreply.github.com> --- npm-shrinkwrap.json | 86 +++++++++++++-------------------------------- 1 file changed, 24 insertions(+), 62 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 95238e011..cf287f0ba 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -303,6 +303,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2271,7 +2272,6 @@ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", @@ -2287,7 +2287,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2299,7 +2298,6 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2313,7 +2311,6 @@ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.17.0" }, @@ -2327,7 +2324,6 @@ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -2341,7 +2337,6 @@ "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -2366,7 +2361,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2378,7 +2372,6 @@ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2392,7 +2385,6 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2406,7 +2398,6 @@ "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2420,7 +2411,6 @@ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -2431,7 +2421,6 @@ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" @@ -2504,7 +2493,6 @@ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18.0" } @@ -2515,7 +2503,6 @@ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" @@ -2530,7 +2517,6 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -2545,7 +2531,6 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -4071,8 +4056,7 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/json5": { "version": "0.0.29", @@ -4988,6 +4972,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5020,7 +5005,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5817,6 +5801,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6439,6 +6424,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -6496,8 +6482,7 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", @@ -7104,6 +7089,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7230,6 +7216,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7652,7 +7639,6 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -7669,7 +7655,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7681,7 +7666,6 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -7699,7 +7683,6 @@ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -7712,8 +7695,7 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/eslint/node_modules/eslint-scope": { "version": "8.4.0", @@ -7721,7 +7703,6 @@ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -7739,7 +7720,6 @@ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -7753,7 +7733,6 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -7767,7 +7746,6 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -7932,8 +7910,7 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -7970,8 +7947,7 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fastq": { "version": "1.19.1", @@ -8033,7 +8009,6 @@ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -8087,7 +8062,6 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -8105,7 +8079,6 @@ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -8119,8 +8092,7 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/fn.name": { "version": "1.1.0", @@ -8570,6 +8542,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -8768,7 +8741,6 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -8779,7 +8751,6 @@ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -9604,6 +9575,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -11102,8 +11074,7 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -11117,16 +11088,14 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stringify-safe": { "version": "5.0.1", @@ -11198,7 +11167,6 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -11463,7 +11431,6 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -11485,7 +11452,6 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -11520,8 +11486,7 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/logform": { "version": "2.7.0", @@ -12140,7 +12105,6 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -12193,7 +12157,6 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -12245,7 +12208,6 @@ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -12521,7 +12483,6 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -12533,6 +12494,7 @@ "integrity": "sha512-X4UlrxDTH8oom9qXlcjnydsjAOD2BmB6yFmvS4Z2zdTzqqpRWb+fbqrH412+l+OUXmbzJlSXjlMFYPgYG12IAA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12652,7 +12614,6 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -12979,7 +12940,6 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -14081,6 +14041,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14226,7 +14187,6 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -14340,6 +14300,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14407,6 +14368,7 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -14651,6 +14613,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -14773,7 +14736,6 @@ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -15099,7 +15061,6 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15412,6 +15373,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 01d597940c19ea8caa043a749cf8fd906fa764ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:40:09 +0000 Subject: [PATCH 8/8] test: add startEnvironment readiness polling and retry tests Co-authored-by: rinatkhaziev <459254+rinatkhaziev@users.noreply.github.com> --- .../dev-environment/dev-environment-core.js | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/__tests__/lib/dev-environment/dev-environment-core.js b/__tests__/lib/dev-environment/dev-environment-core.js index 99c668212..02b2e15f0 100644 --- a/__tests__/lib/dev-environment/dev-environment-core.js +++ b/__tests__/lib/dev-environment/dev-environment-core.js @@ -16,12 +16,21 @@ import { resolveImportPath, readEnvironmentData, } from '../../../src/lib/dev-environment/dev-environment-core'; +import { + isEnvUp, + landoInfo, + landoRebuild, + landoStart, + getProxyContainer, +} from '../../../src/lib/dev-environment/dev-environment-lando'; import { searchAndReplace } from '../../../src/lib/search-and-replace'; +import UserError from '../../../src/lib/user-error'; import { xdgData } from '../../../src/lib/xdg-data'; jest.mock( '../../../src/lib/api/app' ); jest.mock( '../../../src/lib/search-and-replace' ); jest.mock( '../../../src/lib/dev-environment/dev-environment-cli' ); +jest.mock( '../../../src/lib/dev-environment/dev-environment-lando' ); describe( 'lib/dev-environment/dev-environment-core', () => { const cleanup = () => @@ -65,6 +74,115 @@ describe( 'lib/dev-environment/dev-environment-core', () => { return expect( promise ).rejects.toEqual( new Error( DEV_ENVIRONMENT_NOT_FOUND ) ); } ); + + describe( 'readiness polling and retry', () => { + const slug = 'readiness-test-slug'; + const instanceData = JSON.stringify( { + siteSlug: slug, + wpTitle: 'Test', + multisite: false, + wordpress: { mode: 'image', tag: 'trunk' }, + muPlugins: { mode: 'image' }, + appCode: { mode: 'image' }, + mediaRedirectDomain: '', + phpmyadmin: false, + xdebug: false, + php: 'php:8.2', + mailpit: false, + photon: false, + cron: false, + version: '2.3.2', + } ); + + beforeEach( () => { + const expectedPath = getEnvironmentPath( slug ); + const instanceDataPath = path.join( expectedPath, 'instance_data.json' ); + const _originalExistsSync = fs.existsSync; + const _originalReadFileSync = fs.readFileSync; + jest.spyOn( fs, 'existsSync' ).mockImplementation( fpath => { + if ( fpath === expectedPath ) { + return true; + } + return _originalExistsSync( fpath ); + } ); + jest.spyOn( fs, 'appendFileSync' ).mockReturnValue( undefined ); + jest.spyOn( fs, 'readFileSync' ).mockImplementation( ( filePath, ...args ) => { + if ( filePath === instanceDataPath ) { + return instanceData; + } + return _originalReadFileSync( filePath, ...args ); + } ); + jest.spyOn( fs.promises, 'stat' ).mockResolvedValue( { isFile: () => true } ); + + getProxyContainer.mockResolvedValue( { State: { Running: true } } ); + landoRebuild.mockResolvedValue( undefined ); + landoStart.mockResolvedValue( undefined ); + landoInfo.mockResolvedValue( {} ); + } ); + + it( 'should succeed without retrying when environment is up on the first readiness check', async () => { + isEnvUp.mockResolvedValue( true ); + + await startEnvironment( {}, slug, { + skipRebuild: false, + skipWpVersionsCheck: true, + } ); + + expect( isEnvUp ).toHaveBeenCalledTimes( 1 ); + expect( landoRebuild ).toHaveBeenCalledTimes( 1 ); + expect( landoStart ).not.toHaveBeenCalled(); + } ); + + it( 'should call landoStart retry when first readiness pass times out and succeed on second pass', async () => { + // First waitForEnvironmentToBeUp: all 6 attempts return false + // Second waitForEnvironmentToBeUp: succeeds on the first attempt + isEnvUp + .mockResolvedValueOnce( false ) + .mockResolvedValueOnce( false ) + .mockResolvedValueOnce( false ) + .mockResolvedValueOnce( false ) + .mockResolvedValueOnce( false ) + .mockResolvedValueOnce( false ) + .mockResolvedValueOnce( true ); + + jest.useFakeTimers(); + try { + const promise = startEnvironment( {}, slug, { + skipRebuild: false, + skipWpVersionsCheck: true, + } ); + await jest.runAllTimersAsync(); + await promise; + } finally { + jest.useRealTimers(); + } + + expect( landoRebuild ).toHaveBeenCalledTimes( 1 ); + expect( landoStart ).toHaveBeenCalledTimes( 1 ); // retry pass + expect( isEnvUp ).toHaveBeenCalledTimes( 7 ); // 6 failed + 1 success + } ); + + it( 'should throw UserError when environment never reaches a running state', async () => { + isEnvUp.mockResolvedValue( false ); // all attempts fail + + jest.useFakeTimers(); + try { + const promise = startEnvironment( {}, slug, { + skipRebuild: false, + skipWpVersionsCheck: true, + } ); + // Register rejection handler before timers to prevent unhandled rejection + const assertion = expect( promise ).rejects.toThrow( UserError ); + await jest.runAllTimersAsync(); + await assertion; + } finally { + jest.useRealTimers(); + } + + expect( landoStart ).toHaveBeenCalledTimes( 1 ); // retry pass attempted + expect( isEnvUp ).toHaveBeenCalledTimes( 12 ); // 6 + 6 attempts + } ); + } ); } ); describe( 'destroyEnvironment', () => { it( 'should throw for NON existing folder', async () => {