snicco/better-wp-cli

The missing parts to the already awesome WP-CLI

v2.0.0-beta.9 2024-09-07 14:27 UTC

README

codecov Psalm Type-Coverage Psalm level PhpMetrics - Static Analysis PHP-Versions

BetterWPCLI is a small, zero-dependencies, PHP library that helps you build enterprise WordPress command-line applications.

BetterWPCLI does not replace or take over any functionality of the wp runner.

Instead, it sits between WP-CLI and your custom commands.

Table of contents

  1. Motivation
  2. Installation
  3. Usage
    1. Commands
      1. Synopsis
      2. Default flags
    2. Registering Commands
    3. Console Input
    4. Console Output
      1. Verbosity levels
    5. Styling command output
      1. Title
      2. Section
      3. Info
      4. Note
      5. Success
      6. Warning
      7. Error
    6. Interactive input
      1. Asking for confirmation
      2. Asking for information
      3. Hidden input
      4. Validating answers
    7. Exception handling
      1. Uncaught exceptions
      2. Logging
      3. Converting errors to exceptions
    8. Testing
  4. Contributing
  5. Issues and PR's
  6. Security
  7. Credits

Motivation

We developed this library for the WordPress related components of the Snicco project due to the following reasons:

  • WP-CLI has no native support for dependency-injection and does not support lazy-loading of commands.

  • WP-CLI encourages command configuration in meta language or by hard-coded string names.

  • WP-CLI has inconsistent and unconfigurable handling of writing to STDOUT and STDERR.

    • WP_CLI::log(), WP_CLI::success(), WP_CLI::line() write to STDOUT
    • WP_CLI::warning() and WP_CLI::error() write to STDERR.
    • Progress bars are written to STDOUT making command piping impossible.
    • Prompts for input are written to STDOUT making command piping impossible.
    • Uncaught PHP notices (or other errors) are written to STDOUT making command piping impossible.
  • WP-CLI has no error handling. Thrown exceptions go directly to the global shutdown handler (wp_die) and show up in the terminal as the dreaded "There has been a critical error on this website.Learn more about troubleshooting WordPress." . Thus, they also go to STDOUT instead of STDER.

  • WP-CLI can detect ANSI support only for STDOUT and not individually for both STDOUT and STDERR.

    • If you are redirecting STDOUT you probably don't want STDERR to lose all colorization.
  • WP-CLI commands are hard to test because its encouraged to use the static WP_CLI class directly in your command instead of using some Input/Output abstraction.

  • WP-CLI does not play nicely with static analysers like psalm and phpstan.

    • You receive two completely untyped arrays in your command classes.
    • You have no easy way of separating positional arguments from repeating positional arguments.

BetterWPCLI aims to solve all of these problems while providing you many additional features.

BetterWPCLI is specifically designed to be usable in distributed code like public plugins.

Installation

BetterWPCLI is distributed via composer.

composer require snicco/better-wp-cli

Usage

Commands

All commands extend the Command class.

One Command class is responsible for handling exactly ONE command and defining its own synopsis.

The command class reproduces the example command described in the WP-CLI commands cookbook .

use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;
use Snicco\Component\BetterWPCLI\Synopsis\Synopsis;
use Snicco\Component\BetterWPCLI\Synopsis\InputFlag;
use Snicco\Component\BetterWPCLI\Synopsis\InputOption;
use Snicco\Component\BetterWPCLI\Synopsis\InputArgument;

class ExampleCommand extends Command {
    
    // You can set an explicit command name. 
    // If a command name is not set explicitly, it's determined from the class name.
    protected static string $name = 'example';
    
    
    
    // The short description of the command that will be shown
    // when running "wp help example"
    protected static string $short_description = 'Prints a greeting'
    
    
    
    // If a long description is not set explicitly it will default to 
    // the short_description property.
    protected static string $long_description = '## EXAMPLES' . "\n\n" . 'wp example hello Newman'
    
    
    
    public function execute(Input $input, Output $output) : int {
        
        $name = $input->getArgument('name'); // (string) Always a string
        $type = $input->getOption('flag'); // (string) Always a string
        $honk = $input->getFlag('honk'); // (bool) Always a boolean
        
        // outputs a message followed by a "\n"
        $output->writeln("$type: Hello $name!");
        
        // Writes directly to the output stream without newlines
        //$output->write('You are about');
        //$output->write(' to honk');
        
        // Writes to "\n" chars
        //$output->newLine(2);
        
        if($honk) {
            $output->writeln("Honk");
        }
        
        // (This is equivalent to returning int(0))
        return Command::SUCCESS;

        // (This is equivalent to returning int(1))
        // return Command::FAILURE;

        // (This is equivalent to returning int(2))
        // return Command::INVALID
    }
    
    
    public static function synopsis() : Synopsis{
      
      return new Synopsis(
            new InputArgument(
                'name', 
                'The name of the person to great', 
                InputArgument::REQUIRED
            ),
            
            // You can combine options by using bit flags.
            
            // new InputArgument(
            //    'some-other-arg', 
            //    'This is another arg', 
            //    InputArgument::REQUIRED | InputArgument::REPEATING
            //),
            
            new InputOption(
                'type', 
                'Whether or not to greet the person with success or error.', 
                InputOption::OPTIONAL, 
                'success',
                ['success', 'error']
            ),
            new InputFlag('honk')
      );
      
    }
    
}

Synopsis

The Synopsis value object helps you to create the command synopsis using a clear PHP API.

The Synopsis has a rich set of validation rules that are only implicit in the WP-CLI. This helps you prevent certain gotchas right away like:

  • Having duplicate names for arguments/options/flags.
  • Registering a positional argument after a repeating argument.
  • Setting a default value that is not in the list of allowed values.
  • ...

A Synopsis consists of zero or more positional arguments, options or flags.

These a represented by their respective classes:

Default flags

The Command class has several inbuilt flags that you can use in your commands.

You can automatically add them to all your commands by adding them to the parent synopsis.

This is totally optional.

use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Synopsis\Synopsis;
use Snicco\Component\BetterWPCLI\Synopsis\InputArgument;

class MyCommand extends Command {

    public static function synopsis() : Synopsis{
        return parent::synopsis()->with([
            new InputArgument(
                'name', 
                'The name of the person to great', 
                InputArgument::REQUIRED
            ),
        ]);
    }
}

This will add the following command synopsis:

Synopsis

Registering commands

Commands are registered by using the WPCLIApplication class.

if(!defined('WP_CLI')) {
    return;
}

use Snicco\Component\BetterWPCLI\WPCLIApplication;
use Snicco\Component\BetterWPCLI\CommandLoader\ArrayCommandLoader;

// The namespace will be prepended to all your commands automatically.
$command_namespace = 'snicco';

// The command loader is responsible for lazily loading your commands.
// The second argument is a callable that should return an instance of
// a command by its name. This should typically be a call to your dependency injection container.

// This array can come from a configuration file.
$command_classes = [
    ExampleCommand::class,
    FooCommand::class,
    BarCommand::class,
];

$container = /* Your dependency injection container or another factory class */
$factory = function (string $command_class) use ($container) {
    return $container->get($command_class);
}

$command_loader = new ArrayCommandLoader($command_classes, $factory);

$application = new WPCLIApplication($command_namespace);

$application->registerCommands();

Console Input

Console input is abstracted away through an Input interface.

All commands will receive an instance of Input that holds all the passed arguments.

use Snicco\Component\BetterWPCLI\Input\Input
use Snicco\Component\BetterWPCLI\Output\Output
use Snicco\Component\BetterWPCLI\Synopsis\Synopsis;
use Snicco\Component\BetterWPCLI\Synopsis\InputFlag;
use Snicco\Component\BetterWPCLI\Synopsis\InputOption;
use Snicco\Component\BetterWPCLI\Synopsis\InputArgument;

// ...
public static function synopsis(): Synopsis
{
    return new Synopsis(
        new InputArgument(
            'role',
            'The role that should be assigned to the users',
        ),
        new InputArgument(
            'ids',
            'A list of user ids that should be assigned to passed role',
            InputArgument::REQUIRED | InputArgument::REPEATING
        ),
        new InputFlag(
            'notify',
            'Send the user an email about his new role'
        ),
        new InputOption(
            'some-option',
        ),
    );
}

// ...
public function execute(Input $input, Output $output): int
{
    $output->writeln([
        'Changing user roles',
        '===================',
    ]);
    
    // Arguments are retrieved by their name.
    $role = $input->getArgument('role');  // (string)
    
    // The second argument is returned if the option/argument was not passed. 
    $option = $input->getOption('some-option', 'some-default-value'); // (string)
    
    $users = $input->getRepeatingArgument('ids'); // (string[]) and array of ids.
    
    $notify = $input->getFlag('notify', false);
    
    foreach($users as $id) {
        
        // assign role here
        if($notify) {
            // send email here
        }
    }            
    
        
    return Command::SUCCESS;
}

Console Output

Console output is abstracted away through an Output interface.

All commands will receive an instance of Output.

Its recommend that you write use this class in your commands to write to the output stream.

This way your commands will stay testable as you can just substitute this Output interface with a test double.

However, there is nothing preventing you from using the WP_CLI class in your commands.

use Snicco\Component\BetterWPCLI\Input\Input
use Snicco\Component\BetterWPCLI\Output\Output

// ...
protected function execute(Input $input, Output $output): int
{
    // outputs multiple lines to the console (adding "\n" at the end of each line)
    $output->writeln([
        'Starting the command',
        '============',
        '',
    ]);

    // outputs a message followed by a "\n"
    $output->writeln('Doing something!');

    // outputs a message without adding a "\n" at the end of the line
    $output->write('You are about to ');
    $output->write('do something here');
    
    // Outputs 3 "\n" chars.
    $output->newLine(3);    
    
    // You can also use the WP_CLI class. 
    // WP_CLI::debug('doing something');
        
    return Command::SUCCESS;
}

Verbosity levels

BetterWPCLI has a concept of verbosity levels to allow the user to choose how detailed the command output should be.

See: default flags for instructions of adding the flags to your commands.

WP-CLI has a similar concept but only allows you to choose between quiet (no output) and debug (extremely verbose output including wp-cli internals.)

BetterWPCLI has the following five verbosity levels which can be either set per command or by using a SHELL_VERBOSITY environment value.

(Command line arguments have a higher priority then SHELL_VERBOSITY and --debug and --quiet overwrite all values unique to BetterWPCLI.)

It is possible to print specific information only for specific verbosity levels.

use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Verbosity;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;

class AssignUserRoles extends Command {
    
    public function execute(Input $input,Output $output) : int{
        
        $output->writeln('Always printed', Verbosity::QUIET);
        
        $output->writeln('only printed for verbosity normal and above', Verbosity::NORMAL);
        
        $output->writeln('only printed for verbosity verbose and above', Verbosity::VERBOSE);
        
        $output->writeln('only printed for verbosity very-verbose and above', Verbosity::VERY_VERBOSE);
        
        $output->writeln('only printed for verbosity debug', Verbosity::DEBUG);
        
        return Command::SUCCESS;
    }
    
    // .. synopsis defined here.
    
}

Styling command output

BetterWPCLI provides you a utility class SniccoStyle that you can instantiate in your commands.

This class contains many helpers methods for creating rich console output. The style is based on the styling of the symfony/console package.

Color support is automatically detected based on the operating system, whether the command is piped and the provided flags like: --no-color, --no-ansi. See: default flags.

This class will write to STDERR unless you configure it not too.

You should use the Output instance to write important information to STDOUT.

Important information is information that could in theory be piped into other commands.

If your command does not output such information just return Command::SUCCESS and don't output anything. Silence is Golden.

use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;

   // ... 
   protected function execute(Input $input, Output $output): int
    {
        $io = new SniccoStyle($input, $output);
        // ...
        
        // Not so important information
        //$io->title('Command title);
        
        $output->writeln('Some important command output that should be piped.'); 
        
        return Command::SUCCESS;
    }

Title

The title() method should be used once at the start of a command.

$io->title('This is the command title');

Title

Section

The section() method can be used to separate multiple coherent sections of a command

$io->section('This is a new section');

Section

Info

The info() method can be used signalize successful completion of a section.

$io->info('This is an info');

Info

Note

The note() method can be used to draw extra attention to the message. Use this sparingly.

$io->note('This is a note');

Note

Text

The text() method output regular text without colorization.

// Passing an array is optional.
$io->text(['This is a text', 'This is another text']);

Text

Success

The success() method should be used once at the end of a command.

// Passing an array is optional.
$io->success(['This command', 'was successful']);

Success

Warning

The warning() method should be used once at the end of a command.

// Passing an array is optional.
$io->warning(['This command', 'displays a warning']);

Warning

Error

The error() method should be used once at the end of a command if it failed.

// Passing an array is optional.
$io->error(['This command', 'did not work']);

Error

Interactive input

The SniccoStyle class provides several methods to get more information from the user.

If the command was run with the --no-interaction flag the default answer will be used automatically. See: default flags.

All output produced by interactive questions is written to STDERR.

Asking for confirmation

use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;

   // ... 
   protected function execute(Input $input, Output $output): int
    {
        $io = new SniccoStyle($input, $output);
        
        // The second argument is the default value
        if(!$io->confirm('Are you sure that you want to continue', false)) {
        
            $io->warning('Command aborted');
            
            return Command::SUCCESS;
        }
        // Proceed
        
        return Command::SUCCESS;
    }

Confirmation

Asking for information

use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;

   // ... 
   protected function execute(Input $input, Output $output): int
    {
        $io = new SniccoStyle($input, $output);
        
        $domain = $io->ask('Please tell use your company domain', 'snicco.io');
        
        $output->writeln('Your domain is: '. $domain);
    }

Information

Hidden input

You can also ask a question and hide the response.

This will be done by changing the stty mode of the terminal.

If stty is not available, it will fall back to visible input unless you configure it otherwise.

use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;
use Snicco\Component\BetterWPCLI\Output\Output;
use Snicco\Component\BetterWPCLI\Question\Question;

   // ... 
   protected function execute(Input $input, Output $output): int
    {
        $io = new SniccoStyle($input, $output);
        
        // This will fall back to visible input if stty is not available.
        // e.g. on Windows
        $secret = $io->askHidden('What is your secret?')
        
        $question = (new Question('What is your secret'))
                        ->withHiddenInput()
                        ->withFallbackVisibleInput(false);
        
        // This will throw an exception if hidden input can not be ensured.
        $secret = $io->askQuestion($question);
        
        //
    }

Validating the provided input

You can validate the provided answer of the user. If the validation fails the user will be presented with the same question again.

You can also set a maximum amount of attempts. If the maximum attempts are exceeded an InvalidAnswer exception will be thrown.

use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;
use Snicco\Component\BetterWPCLI\Output\Output;
use Snicco\Component\BetterWPCLI\Question\Question;
use Snicco\Component\BetterWPCLI\Exception\InvalidAnswer;

   // ... 
   protected function execute(Input $input, Output $output): int
    {
        $io = new SniccoStyle($input, $output);
        
        $validator = function (string $answer) :void {
            if(strlen($answer) < 5) {
                throw new InvalidAnswer('The name must have at least 6 characters.');
            }
        };
        
        $attempts = 2; 
                
        $question = new Question('Please enter a name', 'default_name', $validator, $attempts);
        
        $answer = $io->askQuestion($question);
    }

Validation

Exception handling

BetterWPCLI comes with very solid exception/error-handling.

This behaviour is however totally isolated and only applies to YOUR commands. Core commands or commands by other plugins are not affected in any way.

Uncaught exceptions

If your command throws an uncaught exception two things will happen:

  1. The exception is displayed in STDERR while taking the current verbosity into consideration.
  2. The exception is logged using the Logger interface. (This is the third argument passed into the WPCLIApplication)

This is how exceptions are displayed with different verbosity levels:

VERBOSTIY::NORMAL:

verbosity normal

VERBOSTIY::VERBOSE:

verbosity verbose

VERBOSTIY::VERY_VERBOSE and above:

verbosity very verbose

You can disable catching exceptions although this is not recommended.

use Snicco\Component\BetterWPCLI\WPCLIApplication;;

$command_loader = new ArrayCommandLoader($command_classes, $factory);

$application = new WPCLIApplication($command_namespace);

// This disables exception handling.
//All exceptions are now handled globally by WordPress again.
$application->catchException(false);

$application->registerCommands();

Logging

By default a StdErrLogger is used to log exceptions using error_log.

This class is suitable for usage in distributed code as it will log exceptions to the location configured in WP_DEBUG_LOG. If you want to use a custom logger you have to pass it as the third argument when creating your WPCLIApplication.

The Logger will create a log record for all uncaught exceptions during your command lifecycle + all commands that return a non-zero exit code.

Converting errors to exceptions

In a normal WP-CLI command errors such as notices, warnings and deprecations are not handled at all. Instead, they bubble up to the global PHP error handler.

It is a best practice to treat notices and warnings as exceptions.

BetterWPCLI will promote all errors during YOUR command to instances of ErrorException.

The following code:

use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;

   // ... 
   protected function execute(Input $input, Output $output): int
    {
        $arr = ['foo'];
        
        $foo = $arr[1];
        
        //
        
        return Command::SUCCESS;
    }

will throw an exception and exit with code 1.

notices-to-exceptions

By default, all error including deprecations are promoted to exceptions.

If you find this to strict for your production environment you can customize the behaviour.

use Snicco\Component\BetterWPCLI\WPCLIApplication;;

$command_loader = new ArrayCommandLoader($command_classes, $factory);

$application = new WPCLIApplication($command_namespace);

// This is the default setting
$application->throwExceptionsAt(E_ALL);

// Throw exceptions for all errors expect deprecations.
$application->throwExceptionsAt(E_ALL - E_DEPRECATED - E_USER_DEPRECATED);

// This disables the behaviour entirely (NOT RECOMMENDED)
$application->throwExceptionsAt(0);

$application->registerCommands();

Testing

This package comes with dedicated testing utilities which are in a separate package snicco/better-wp-cli-testing.

use Snicco\Component\BetterWPCLI\Testing\CommandTester;

$tester = new CommandTester(new CreateUserCommand());

$tester->run(['calvin', 'calvin@snicco.io'], ['send-email' => true]);

$tester->assertCommandIsSuccessful();

$tester->assertStatusCode(0);

$tester->seeInStdout('User created!');

$tester->dontSeeInStderr('Fail');

Contributing

This repository is a read-only split of the development repo of the Snicco project.

This is how you can contribute.

Reporting issues and sending pull requests

Please report issues in the Snicco monorepo.

Security

If you discover a security vulnerability within BetterWPAPI, please follow our disclosure procedure.

Credits

Inspecting the source of the symfony console symfony/console was invaluable to developing this library.