diff --git a/packages/commands/duckbrain b/packages/commands/duckbrain index e69f00d..37ac92b 100644 --- a/packages/commands/duckbrain +++ b/packages/commands/duckbrain @@ -2,41 +2,111 @@ clase por nombre de archivo (basename => FQCN) + */ +function project_commands(): array +{ + $classes = []; + foreach (glob(rtrim(ROOT_CORE, '/') . '/Commands/*.php') as $file) { + $base = basename($file, '.php'); + $class = 'Commands\\' . $base; + if (class_exists($class) && is_a($class, 'Interfaces\\CommandInterface', true)) { + $classes[$base] = $class; + } } - exit((new Toolbox\Cli())->run($argv)); + return $classes; } -// Comandos propios del proyecto, despachados desde ROOT_CORE/Commands. -$coreCommand = 'Commands\\' . ucfirst($command); +/** + * @param array $classes + * @return array alias => FQCN + */ +function alias_map(array $classes): array +{ + $map = []; + $byLower = []; + foreach ($classes as $base => $class) { + $byLower[strtolower($base)] = $class; + } -if (!class_exists($coreCommand) && !file_exists(ROOT_CORE . '/Commands/' . ucfirst($command) . '.php')) { - fwrite(STDERR, "Invalid command.\n"); - exit(1); + foreach ($classes as $base => $class) { + if (!method_exists($class, 'aliases')) { + continue; + } + + foreach ($class::aliases() as $alias) { + $owner = strtolower($base); + $key = strtolower((string) $alias); + + if ($key === $owner) { + continue; + } + + if (isset($map[$key]) && $map[$key] !== $class) { + fwrite(STDERR, "Conflicto de alias '{$alias}': lo declaran {$map[$key]} y {$class}.\n"); + exit(1); + } + + if (isset($byLower[$key])) { + fwrite(STDERR, "El alias '{$alias}' de {$class} colisiona con el comando {$byLower[$key]}.\n"); + exit(1); + } + + $map[$key] = $class; + } + } + + return $map; } -$cli = new $coreCommand(); +$classes = project_commands(); +$map = alias_map($classes); +$command = $argv[1] ?? null; -if ($cli instanceof Interfaces\CommandInterface) { - call_user_func_array([$cli, 'run'], array_slice($argv, 2)); +if ($command === null) { + $rev = []; + foreach ($map as $alias => $class) { + $rev[$class][] = $alias; + } + + foreach ($classes as $base => $class) { + $line = ' ' . strtolower($base); + if (($rev[$class] ?? []) !== []) { + $line .= ', ' . implode(', ', $rev[$class]); + } + fwrite(STDOUT, $line . ' — ' . $class::describe() . "\n"); + } + + exit(0); +} + +$direct = 'Commands\\' . ucfirst($command); +$class = null; +$slice = 2; + +if (class_exists($direct) && is_a($direct, 'Interfaces\\CommandInterface', true)) { + $class = $direct; } else { - fwrite(STDERR, "La clase $coreCommand no implementa CommandInterface.\n"); + $class = $map[strtolower($command)] ?? null; + $slice = $class === null ? $slice : 1; +} + +if ($class === null) { + fwrite(STDERR, "Comando desconocido: {$command}\n"); exit(1); } + +$cli = new $class(); + +call_user_func_array([$cli, 'run'], array_slice($argv, $slice)); diff --git a/packages/commands/duckbrain.json b/packages/commands/duckbrain.json index 1b56d8e..1c5c7c7 100644 --- a/packages/commands/duckbrain.json +++ b/packages/commands/duckbrain.json @@ -1,40 +1,41 @@ { - "name": "commands", - "version": "0.2.4", - "description": "CLI y migraciones para Duckbrain", - "components": { - "cli": { - "files": [ - "duckbrain", - "src/Interfaces/CommandInterface.php" - ], - "description": "CLI del proyecto (binario duckbrain)", - "core": "none" - }, - "migration": { - "files": [ - "src/Commands/Migration.php", - "src/Libs/Migration.php" - ], - "require": { - "commands:cli": "*" - }, - "core": { - "requires": ">=0.1", - "tested": "0.1" - }, - "description": "Migraciones de base de datos (mysql/pgsql/sqlite)" - }, - "toolbox": { - "files": [ - "src/Toolbox" - ], - "description": "Gestor de addons dentro del propio proyecto", - "core": "none" - } + "name": "commands", + "version": "0.2.4", + "description": "CLI y migraciones para Duckbrain", + "components": { + "cli": { + "files": [ + "duckbrain", + "src/Interfaces/CommandInterface.php" + ], + "description": "CLI del proyecto (binario duckbrain)", + "core": "none" }, - "default": [ - "cli", - "migration" - ] + "migration": { + "files": [ + "src/Commands/Migration.php", + "src/Libs/Migration.php" + ], + "require": { + "commands:cli": "*" + }, + "core": { + "requires": ">=0.1", + "tested": "0.1" + }, + "description": "Migraciones de base de datos (mysql/pgsql/sqlite)" + }, + "toolbox": { + "files": [ + "src/Toolbox", + "src/Commands/Toolbox.php" + ], + "description": "Gestor de addons dentro del propio proyecto", + "core": "none" + } + }, + "default": [ + "cli", + "migration" + ] } diff --git a/packages/commands/src/Commands/Migration.php b/packages/commands/src/Commands/Migration.php index 41ea20f..2f23f78 100644 --- a/packages/commands/src/Commands/Migration.php +++ b/packages/commands/src/Commands/Migration.php @@ -10,6 +10,11 @@ use PDO; class Migration implements CommandInterface { + public static function describe(): string + { + return 'Migraciones de base de datos (create, migrate, rollback, fresh, clear)'; + } + private const MIGRATIONS_PATH = ROOT_DIR . '/migrations/'; private string $migratonsDatabaseFile; private PDO $db; diff --git a/packages/commands/src/Commands/Toolbox.php b/packages/commands/src/Commands/Toolbox.php new file mode 100644 index 0000000..7e67e71 --- /dev/null +++ b/packages/commands/src/Commands/Toolbox.php @@ -0,0 +1,39 @@ + + */ + public static function aliases(): array + { + return [ + 'add', 'remove', 'update', 'list', 'doctor', 'catalog', 'adopt', + 'help', '--help', '-h', '--version', '-V', + ]; + } + + public function run(): void + { + $args = func_get_args(); + + exit((new Cli())->run(array_merge(['duckbrain'], array_map('strval', $args)))); + } +} diff --git a/packages/commands/src/Interfaces/CommandInterface.php b/packages/commands/src/Interfaces/CommandInterface.php index 60ad7bd..14d0bda 100644 --- a/packages/commands/src/Interfaces/CommandInterface.php +++ b/packages/commands/src/Interfaces/CommandInterface.php @@ -10,4 +10,10 @@ interface CommandInterface * @return void */ public function run(): void; + + /** + * Descripción de una línea del comando, obligatoria: fuerza a documentar + * cada comando del proyecto para que sea visible en la superficie. + */ + public static function describe(): string; } diff --git a/tests/group7_commands_test.php b/tests/group7_commands_test.php index fcfd3e6..4d496d8 100644 --- a/tests/group7_commands_test.php +++ b/tests/group7_commands_test.php @@ -262,3 +262,19 @@ Harness::it('el catálogo describe cada componente con su texto propio o heredad rrmdir($toolbox); rrmdir($proj); }); + +Harness::it('un comando del proyecto sin describe() no carga (interfaz estricta)', function () { + $proj = tmpdir('proj'); + mkdir($proj . '/src/Commands', 0777, true); + write_file($proj . '/autoload.php', "&1', $out, $code); + + ok($code !== 0 || str_contains(implode('', $out), 'Fatal'), 'la carga de un comando sin describe produce error fatal'); + + rrmdir($proj); +}); diff --git a/tests/group8_project_cli_test.php b/tests/group8_project_cli_test.php index a214ea0..4dc099d 100644 --- a/tests/group8_project_cli_test.php +++ b/tests/group8_project_cli_test.php @@ -72,3 +72,61 @@ Harness::it('la actualización del motor surte efecto en la siguiente invocació rrmdir($proj); rrmdir($core); }); + +Harness::it('el bin despacha por clase, alias, usage y guard de colisiones', function () { + $repo = $GLOBALS['g8_repo']; + $core = g7_core('0.1.0'); + $proj = tmpdir('proj'); + + (new InstallCommand(new Console()))->run([$proj, '--toolbox=' . $repo, '--core-dir=' . $core, '--yes']); + (new AddCommand(new Console()))->run(['commands:cli', 'commands:toolbox', '--root=' . $proj, '--toolbox=' . $repo, '--yes']); + + write_file($proj . '/src/Commands/Eco.php', "&1', $o, $c1); + eq(0, $c1); ok(str_contains(implode('', $o), 'ECO_OK'), 'dispatch por nombre de clase'); + + $o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain list 2>&1', $o, $c2); + eq(0, $c2); ok(str_contains(implode('', $o), 'commands:cli'), 'dispatch por alias del motor'); + + $o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain 2>&1', $o, $c3); + eq(0, $c3); $usage = implode('', $o); + ok(str_contains($usage, 'eco') && str_contains($usage, 'Comando eco de prueba'), 'usage lista clase y describe'); + ok(str_contains($usage, 'toolbox, add'), 'usage lista aliases del entry del motor'); + + $o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain noexiste 2>&1', $o, $c4); + eq(1, $c4); ok(str_contains(implode('', $o), 'Comando desconocido'), 'argumento inexistente falla limpio'); + + // alias-vs-alias: 'add' también declarado por Eco + write_file($proj . '/src/Commands/Eco.php', str_replace( + " public function run(): void", + " public static function aliases(): array { return ['add']; }\n public function run(): void", + (string) file_get_contents($proj . '/src/Commands/Eco.php') + )); + $o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain list 2>&1', $o, $c5); + eq(1, $c5); ok(str_contains(implode('', $o), 'Conflicto de alias'), 'guard alias duplicado con ambos dueños'); + + // alias-vs-clase ajena: 'eco' declarado por Toolbox está prohibido; usar un tercero + write_file($proj . '/src/Commands/Ladro.php', "&1', $o, $c6); + eq(1, $c6); ok(str_contains(implode('', $o), 'colisiona con el comando'), 'guard alias que roba nombre de clase'); + + rrmdir($proj); + rrmdir($core); +}); + +Harness::it('bin nuevo sin el motor: los aliases del motor son comando desconocido', function () { + $repo = $GLOBALS['g8_repo']; + $core = g7_core('0.1.0'); + $proj = tmpdir('proj'); + + (new InstallCommand(new Console()))->run([$proj, '--toolbox=' . $repo, '--core-dir=' . $core, '--yes']); + (new AddCommand(new Console()))->run(['commands:cli', '--root=' . $proj, '--toolbox=' . $repo, '--yes']); + + $o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain catalog 2>&1', $o, $c); + eq(1, $c); ok(str_contains(implode('', $o), 'Comando desconocido'), 'sin Commands\\Toolbox no hay delegación'); + + rrmdir($proj); + rrmdir($core); +});