italix/console

Framework CLI runner: commands listed not discovered, dependencies declared like a controller's, PSR-11 container, exit codes that mean something

Maintainers

Package info

github.com/italix-net/console

pkg:composer/italix/console

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.0.0 2026-08-29 21:44 UTC

This package is not auto-updated.

Last update: 2026-08-30 22:29:53 UTC


README

PHP Version License

One CLI for the whole framework. Commands are listed, not discovered; dependencies are declared, not reflected; exit codes mean something.

One dependency: psr/container, which is interface-only.

php bin/ix                       # every command, grouped
php bin/ix migrate --dry-run     # what would run, without running it
php bin/ix encode:lint           # template output that is not encoded
php bin/ix version               # every Italix library and its version

Writing a command

namespace App\Console;

use Italix\Console\{BaseCommand, Input, Output};
use Italix\Orm\DataManager;

final class PurgeTokensCommand extends BaseCommand
{
    /** Injected by the runner — protected, because BaseCommand assigns it */
    protected DataManager $dm;

    public static function name_code(): string  { return 'tokens:purge'; }
    public static function summary(): string { return 'Delete expired verification tokens.'; }

    public static function depends_on(): array
    {
        return ['dm' => DataManager::class];
    }

    public function run(Input $in, Output $out): int
    {
        $days_n = $in->int_option('older-than', 30);

        if ($in->flag('dry-run')) {
            $out->comment("Would purge tokens older than {$days_n} days.");

            return 0;
        }

        $done_n = $this->dm->execute('DELETE FROM …')->rowCount();
        $out->success("{$done_n} tokens purged.");

        return 0;
    }
}

Register it in conf.php:

'console.commands' => [
    \App\Console\MigrateCommand::class,
    \App\Console\PurgeTokensCommand::class,
],

Injected properties must be protected. A private property of the subclass is invisible from BaseCommand's scope. The runner says so by name rather than letting PHP's "Cannot access private property" stand as the whole explanation.

Input

Written Read as
--max=100 option('max')'100', int_option('max')100
--force flag('force')true, has_option('force')true
--no-cache flag('cache')false, has_option('cache')true
-vqf three flags: v, q, f
-- stops option parsing; everything after is an argument
anything else a positional argument, in order

flag() reads 0, false, no and off as false.

One trap worth knowing: --max with no value does not become 0. option() and int_option() fall back to the default, because a bare --max and an omitted --max diverging into a zero nobody asked for is how a worker ends up processing no jobs and reporting success. has_option() still reports it as given.

Output

$out->line('plain');           // stdout
$out->info('cyan');            // stdout
$out->success('green');        // stdout
$out->comment('dim');          // stdout
$out->warn('yellow');          // stderr
$out->error('red');            // stderr
$out->table(['a', 'b'], $rows);

Diagnostics go to stderr. A command whose errors land on stdout cannot be piped: ix jobs:status | grep failed would match its own error message.

Colour only when a terminal is watching. Escape codes are emitted when the stream is a TTY and suppressed otherwise, so a CI log gets text rather than \e[32m. NO_COLOR is honoured.

Output::in_memory() returns one that writes into memory; fetch() and fetch_errors() read it back. That is how the suite asserts on which stream a message went to.

Exit codes

Code Meaning
0 the command succeeded
1 the command failed, or threw
2 usage error — unknown verb, bad registration, missing container

The 1/2 split is the one encode-lint already used. run() returning a code the runner passes through verbatim is the point: a command that fails with 0 is the CLI equivalent of a validation rule that never runs.

An uncaught exception prints class, message, file and line, then suggests -v for the trace.

Wiring it up

bin/ix boots the same configuration the web entry point does and takes the container from the engine:

$config = require "{$site_dir}/conf.php";
$config['base_dir'] = $base_dir;
$config['host']     = $host;

$engine = new Engine($config);

exit(console($config['console.commands'] ?? [], $engine->registry(), 'ix', 'Acme')->run($argv));

Engine::registry() builds the container without the dispatcher, so ix version does not write a route cache file as a side effect.

That shared container is the fix for a real bug: the old run_migration.php rebuilt the database credentials from $_ENV with its own defaults, so a change in conf.php never reached migrations.

Deliberately not

  • No auto-discovery. Commands are listed in conf.php, like routes. A command that appears because a file was copied into place is a command nobody decided to ship.
  • No interactive prompts, no progress bars. Both assume a human is watching; these verbs run from cron and CI as often as from a terminal.
  • No option schema. A command asks for what it wants with the default it wants, rather than maintaining a second description of every flag.