Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e020f0ea6 | ||
|
|
a37d4f89de | ||
|
|
c82e095b75 | ||
|
|
8168865f3a | ||
|
|
4b80b885e0 | ||
|
|
e2b9fc0392 | ||
|
|
a60e1eebb7 | ||
|
|
4014551aae | ||
|
|
6028abe51c | ||
|
|
c2931245bb | ||
|
|
5eb019726a | ||
|
|
74da6f9c64 |
+1
-1
@@ -65,7 +65,7 @@ por el componente que pides; los metadatos del paquete no se instalan.
|
|||||||
El instalador global arranca un proyecto desde cero y no necesita nada previo:
|
El instalador global arranca un proyecto desde cero y no necesita nada previo:
|
||||||
|
|
||||||
#+BEGIN_SRC sh
|
#+BEGIN_SRC sh
|
||||||
git clone git@git.kj2.me:kj/duckbrain-toolbox.git
|
git clone https://git.kj2.me/kj/duckbrain-toolbox.git
|
||||||
ln -s "$PWD/duckbrain-toolbox/bin/duckbrain" ~/.local/bin/duckbrain
|
ln -s "$PWD/duckbrain-toolbox/bin/duckbrain" ~/.local/bin/duckbrain
|
||||||
|
|
||||||
duckbrain install mi-proyecto # descarga el núcleo Duckbrain
|
duckbrain install mi-proyecto # descarga el núcleo Duckbrain
|
||||||
|
|||||||
+95
-25
@@ -2,41 +2,111 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// CLI del proyecto Duckbrain. Requiere el autoloader del core (proyecto ya
|
// CLI del proyecto Duckbrain. Requiere el autoloader del core (proyecto ya
|
||||||
// armado). Delega en el motor `Toolbox\` si el componente opcional
|
// armado). Despachador genérico: el primer argumento resuelve primero como
|
||||||
// `commands:toolbox` está instalado; en caso contrario, sólo ofrece los
|
// nombre de clase del namespace Commands del proyecto y, si no, como alias
|
||||||
// comandos propios del proyecto (p. ej. migraciones).
|
// declarado por una de esas clases (`public static function aliases():
|
||||||
require_once('./autoload.php');
|
// array`). Un alias duplicado —o que robe el nombre de otra clase— aborta
|
||||||
|
// con error explícito listando ambos dueños. Los comandos implementan
|
||||||
|
// Interfaces\CommandInterface (run + describe obligatorios).
|
||||||
|
|
||||||
|
require_once './autoload.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $classes
|
||||||
|
* @return array<string, string> alias => FQCN
|
||||||
|
*/
|
||||||
|
function alias_map(array $classes): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
$byLower = [];
|
||||||
|
foreach ($classes as $base => $class) {
|
||||||
|
$byLower[strtolower($base)] = $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$classes = project_commands();
|
||||||
|
$map = alias_map($classes);
|
||||||
$command = $argv[1] ?? null;
|
$command = $argv[1] ?? null;
|
||||||
|
|
||||||
$toolboxCommands = [
|
if ($command === null) {
|
||||||
'add', 'remove', 'update', 'list', 'doctor', 'catalog', 'adopt',
|
$rev = [];
|
||||||
'help', '--help', '-h', '--version', '-V',
|
foreach ($map as $alias => $class) {
|
||||||
];
|
$rev[$class][] = $alias;
|
||||||
|
|
||||||
if ($command === null || in_array($command, $toolboxCommands, true)) {
|
|
||||||
if (!class_exists('Toolbox\\Cli')) {
|
|
||||||
fwrite(STDERR, "El componente 'commands:toolbox' no está instalado.\n");
|
|
||||||
fwrite(STDERR, "Añádelo con el instalador global: duckbrain add commands:toolbox\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exit((new Toolbox\Cli())->run($argv));
|
foreach ($classes as $base => $class) {
|
||||||
|
$line = ' ' . strtolower($base);
|
||||||
|
if (($rev[$class] ?? []) !== []) {
|
||||||
|
$line .= ', ' . implode(', ', $rev[$class]);
|
||||||
|
}
|
||||||
|
fwrite(STDOUT, $line . ' — ' . $class::describe() . "\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comandos propios del proyecto, despachados desde ROOT_CORE/Commands.
|
exit(0);
|
||||||
$coreCommand = 'Commands\\' . ucfirst($command);
|
|
||||||
|
|
||||||
if (!class_exists($coreCommand) && !file_exists(ROOT_CORE . '/Commands/' . ucfirst($command) . '.php')) {
|
|
||||||
fwrite(STDERR, "Invalid command.\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$cli = new $coreCommand();
|
$direct = 'Commands\\' . ucfirst($command);
|
||||||
|
$class = null;
|
||||||
|
$slice = 2;
|
||||||
|
|
||||||
if ($cli instanceof Interfaces\CommandInterface) {
|
if (class_exists($direct) && is_a($direct, 'Interfaces\\CommandInterface', true)) {
|
||||||
call_user_func_array([$cli, 'run'], array_slice($argv, 2));
|
$class = $direct;
|
||||||
} else {
|
} 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);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cli = new $class();
|
||||||
|
|
||||||
|
call_user_func_array([$cli, 'run'], array_slice($argv, $slice));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "commands",
|
"name": "commands",
|
||||||
"version": "0.2.3",
|
"version": "0.2.8",
|
||||||
"description": "CLI y migraciones para Duckbrain",
|
"description": "CLI y migraciones para Duckbrain",
|
||||||
"components": {
|
"components": {
|
||||||
"cli": {
|
"cli": {
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
},
|
},
|
||||||
"toolbox": {
|
"toolbox": {
|
||||||
"files": [
|
"files": [
|
||||||
"src/Toolbox"
|
"src/Toolbox",
|
||||||
|
"src/Commands/Toolbox.php"
|
||||||
],
|
],
|
||||||
"description": "Gestor de addons dentro del propio proyecto",
|
"description": "Gestor de addons dentro del propio proyecto",
|
||||||
"core": "none"
|
"core": "none"
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ use PDO;
|
|||||||
|
|
||||||
class Migration implements CommandInterface
|
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 const MIGRATIONS_PATH = ROOT_DIR . '/migrations/';
|
||||||
private string $migratonsDatabaseFile;
|
private string $migratonsDatabaseFile;
|
||||||
private PDO $db;
|
private PDO $db;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Commands;
|
||||||
|
|
||||||
|
use Interfaces\CommandInterface;
|
||||||
|
use Toolbox\Cli;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point del gestor de addons dentro del proyecto: el bin `duckbrain`
|
||||||
|
* despacha aquí todo alias del motor (add, remove, update, list, doctor,
|
||||||
|
* catalog, adopt, help y variantes de versión/usage). Vive en el namespace
|
||||||
|
* Commands del proyecto para que el binario siga siendo un despachador
|
||||||
|
* genérico: no conoce la lista de comandos de nadie más que de los propios.
|
||||||
|
*/
|
||||||
|
class Toolbox implements CommandInterface
|
||||||
|
{
|
||||||
|
public static function describe(): string
|
||||||
|
{
|
||||||
|
return 'Gestor de addons del proyecto (motor duckbrain-toolbox)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function aliases(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'add', 'remove', 'update', 'list', 'doctor', 'catalog', 'adopt', 'commands',
|
||||||
|
'help', '--help', '-h', '--version', '-V',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$args = func_get_args();
|
||||||
|
|
||||||
|
exit((new Cli())->run(array_merge(['duckbrain'], array_map('strval', $args))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,4 +10,10 @@ interface CommandInterface
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function run(): 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Toolbox;
|
|||||||
use Toolbox\Commands\AddCommand;
|
use Toolbox\Commands\AddCommand;
|
||||||
use Toolbox\Commands\AdoptCommand;
|
use Toolbox\Commands\AdoptCommand;
|
||||||
use Toolbox\Commands\CatalogCommand;
|
use Toolbox\Commands\CatalogCommand;
|
||||||
|
use Toolbox\Commands\CommandsCommand;
|
||||||
use Toolbox\Commands\DoctorCommand;
|
use Toolbox\Commands\DoctorCommand;
|
||||||
use Toolbox\Commands\InstallCommand;
|
use Toolbox\Commands\InstallCommand;
|
||||||
use Toolbox\Commands\ListCommand;
|
use Toolbox\Commands\ListCommand;
|
||||||
@@ -24,6 +25,7 @@ final class Cli
|
|||||||
'doctor' => DoctorCommand::class,
|
'doctor' => DoctorCommand::class,
|
||||||
'catalog' => CatalogCommand::class,
|
'catalog' => CatalogCommand::class,
|
||||||
'adopt' => AdoptCommand::class,
|
'adopt' => AdoptCommand::class,
|
||||||
|
'commands' => CommandsCommand::class,
|
||||||
'self-update' => SelfUpdateCommand::class,
|
'self-update' => SelfUpdateCommand::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ final class AdoptCommand extends Command
|
|||||||
'toolbox_commit' => null,
|
'toolbox_commit' => null,
|
||||||
'mode' => 'copy',
|
'mode' => 'copy',
|
||||||
'origin' => $hasLocal ? 'local' : 'adopted',
|
'origin' => $hasLocal ? 'local' : 'adopted',
|
||||||
|
'description' => $package->descriptionOf($component->name),
|
||||||
'files' => $files,
|
'files' => $files,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ final class AdoptCommand extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$coreAdopted = $this->adoptCore($parsed['options'], $root, $lock, $dryRun);
|
[$coreAdopted, $coreHasLocal] = $this->adoptCore($parsed['options'], $root, $lock, $dryRun);
|
||||||
|
|
||||||
if (!$dryRun) {
|
if (!$dryRun) {
|
||||||
$lock->save($root);
|
$lock->save($root);
|
||||||
@@ -99,7 +100,8 @@ final class AdoptCommand extends Command
|
|||||||
$this->console->ok('adoptado ' . $id . $mark);
|
$this->console->ok('adoptado ' . $id . $mark);
|
||||||
}
|
}
|
||||||
if ($coreAdopted > 0) {
|
if ($coreAdopted > 0) {
|
||||||
$this->console->ok("adoptado el core ({$coreAdopted} archivos)");
|
$mark = $coreHasLocal ? ' (con ediciones locales)' : '';
|
||||||
|
$this->console->ok("adoptado el core ({$coreAdopted} archivos){$mark}");
|
||||||
}
|
}
|
||||||
if ($adopted === [] && $coreAdopted === 0) {
|
if ($adopted === [] && $coreAdopted === 0) {
|
||||||
$this->console->out('No se encontró nada que adoptar.');
|
$this->console->out('No se encontró nada que adoptar.');
|
||||||
@@ -110,33 +112,42 @@ final class AdoptCommand extends Command
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Registra el core existente: sólo los archivos que también existen en el
|
* Registra el core existente: sólo los archivos que también existen en el
|
||||||
* core de referencia, con su hash actual. No toca archivos.
|
* core de referencia, con su hash CANÓNICO. Si la copia local difiere,
|
||||||
|
* el archivo queda marcado como edición local y `update core` preguntará
|
||||||
|
* antes de pisarla (mismo contrato que `adoptComponent`).
|
||||||
*
|
*
|
||||||
* @param array<string, string|bool> $options
|
* @param array<string, string|bool> $options
|
||||||
|
* @return array{0:int,1:bool}
|
||||||
*/
|
*/
|
||||||
private function adoptCore(array $options, string $root, $lock, bool $dryRun): int
|
private function adoptCore(array $options, string $root, $lock, bool $dryRun): array
|
||||||
{
|
{
|
||||||
if ($lock->core() !== null) {
|
if ($lock->core() !== null) {
|
||||||
return 0;
|
return [0, false];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$provider = $this->coreProvider($options);
|
$provider = $this->coreProvider($options);
|
||||||
} catch (ToolboxException $e) {
|
} catch (ToolboxException $e) {
|
||||||
$this->console->warn('no se pudo resolver el core para adoptar: ' . $e->getMessage());
|
$this->console->warn('no se pudo resolver el core para adoptar: ' . $e->getMessage());
|
||||||
return 0;
|
return [0, false];
|
||||||
}
|
}
|
||||||
|
|
||||||
$files = [];
|
$files = [];
|
||||||
|
$hasLocal = false;
|
||||||
foreach ($provider->files() as $relative) {
|
foreach ($provider->files() as $relative) {
|
||||||
$absolute = $root . '/' . $relative;
|
$absolute = $root . '/' . $relative;
|
||||||
if (is_file($absolute)) {
|
if (!is_file($absolute)) {
|
||||||
$files[$relative] = Drift::hash($absolute);
|
continue;
|
||||||
|
}
|
||||||
|
$canonical = $provider->read($relative);
|
||||||
|
$files[$relative] = 'sha256:' . hash('sha256', $canonical);
|
||||||
|
if (file_get_contents($absolute) !== $canonical) {
|
||||||
|
$hasLocal = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($files === []) {
|
if ($files === []) {
|
||||||
return 0;
|
return [0, false];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$dryRun) {
|
if (!$dryRun) {
|
||||||
@@ -144,9 +155,10 @@ final class AdoptCommand extends Command
|
|||||||
'version' => $provider->version(),
|
'version' => $provider->version(),
|
||||||
'commit' => $provider->commit(),
|
'commit' => $provider->commit(),
|
||||||
'files' => $files,
|
'files' => $files,
|
||||||
|
'origin' => $hasLocal ? 'local' : 'adopted',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return count($files);
|
return [count($files), $hasLocal];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Toolbox\Commands;
|
||||||
|
|
||||||
|
use Toolbox\Lock;
|
||||||
|
use Toolbox\Ownership;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Superficie ejecutable del proyecto: comandos en src/Commands con sus
|
||||||
|
* aliases, descripciones y origen. A diferencia del despachador del bin,
|
||||||
|
* las colisiones de alias aquí se REPORTAN (marcadas con !) y no abortan.
|
||||||
|
*/
|
||||||
|
final class CommandsCommand extends Command
|
||||||
|
{
|
||||||
|
public static function describe(): string
|
||||||
|
{
|
||||||
|
return 'Lista los comandos disponibles del proyecto';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(array $args): int
|
||||||
|
{
|
||||||
|
$parsed = $this->parse($args);
|
||||||
|
$root = $this->root($parsed['options']);
|
||||||
|
|
||||||
|
$autoload = $root . '/autoload.php';
|
||||||
|
if (!defined('ROOT_CORE') && is_file($autoload)) {
|
||||||
|
$previous = getcwd();
|
||||||
|
chdir($root);
|
||||||
|
try {
|
||||||
|
include_once $autoload;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// el proyecto no arranca: se lista sin clases cargables
|
||||||
|
}
|
||||||
|
if ($previous !== false) {
|
||||||
|
chdir($previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$coreDir = defined('ROOT_CORE') ? rtrim(ROOT_CORE, '/') : $root . '/src';
|
||||||
|
$dir = $coreDir . '/Commands';
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
$this->console->out('El proyecto no tiene comandos en src/Commands.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lock = Lock::load($root);
|
||||||
|
$ownership = Ownership::fromLock($lock);
|
||||||
|
|
||||||
|
$info = [];
|
||||||
|
$aliasOwners = [];
|
||||||
|
|
||||||
|
foreach (glob($dir . '/*.php') as $file) {
|
||||||
|
$base = basename($file, '.php');
|
||||||
|
$class = 'Commands\\' . $base;
|
||||||
|
|
||||||
|
if (!class_exists($class, true) || !is_a($class, 'Interfaces\\CommandInterface', true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aliases = method_exists($class, 'aliases') ? $class::aliases() : [];
|
||||||
|
foreach ($aliases as $alias) {
|
||||||
|
$aliasOwners[strtolower((string) $alias)][] = $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
$owner = $ownership->ownerOf('src/Commands/' . $base . '.php');
|
||||||
|
$origin = 'proyecto';
|
||||||
|
if ($owner === 'core') {
|
||||||
|
$origin = 'core';
|
||||||
|
} elseif ($owner !== null) {
|
||||||
|
$origin = $owner . '@' . ($lock->component($owner)['version'] ?? '?');
|
||||||
|
}
|
||||||
|
|
||||||
|
$info[$base] = [
|
||||||
|
'class' => $class,
|
||||||
|
'describe' => $class::describe(),
|
||||||
|
'aliases' => $aliases,
|
||||||
|
'origin' => $origin,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$collisions = [];
|
||||||
|
foreach ($aliasOwners as $alias => $owners) {
|
||||||
|
$uniq = array_unique($owners);
|
||||||
|
if (count($uniq) > 1) {
|
||||||
|
$collisions[$alias] = $uniq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($info as $base => $row) {
|
||||||
|
foreach ($row['aliases'] as $alias) {
|
||||||
|
$aKey = strtolower((string) $alias);
|
||||||
|
foreach (array_keys($info) as $other) {
|
||||||
|
if (strtolower($other) !== $aKey || $other === $base) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$owners = array_values(array_unique([...($collisions[$aKey] ?? []), $row['class'], $info[$other]['class']]));
|
||||||
|
$collisions[$aKey] = $owners;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [['COMANDO', 'ALIASES', 'DESCRIPCIÓN', 'ORIGEN']];
|
||||||
|
foreach ($info as $base => $row) {
|
||||||
|
$marks = [];
|
||||||
|
foreach ($row['aliases'] as $alias) {
|
||||||
|
$marks[] = (isset($collisions[strtolower((string) $alias)]) ? $alias . '!' : (string) $alias);
|
||||||
|
}
|
||||||
|
$marked = $marks === [] ? '—' : implode(', ', $marks);
|
||||||
|
if ($marked !== '—' && isset($collisions[strtolower($base)])) {
|
||||||
|
$marked .= ' !';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [strtolower($base), $marked, $row['describe'], $row['origin']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->console->table($rows);
|
||||||
|
|
||||||
|
foreach ($collisions as $alias => $owners) {
|
||||||
|
$this->console->warn("alias '{$alias}' declarado o robado por: " . implode(', ', $owners));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,15 +19,21 @@ final class ListCommand extends Command
|
|||||||
$toolbox = $this->toolboxDir($parsed['options']);
|
$toolbox = $this->toolboxDir($parsed['options']);
|
||||||
$installer = new Installer($root, $toolbox, $this->console, $this->rootCore($parsed['options'], $root));
|
$installer = new Installer($root, $toolbox, $this->console, $this->rootCore($parsed['options'], $root));
|
||||||
|
|
||||||
$rows = [['TIPO', 'ID', 'VERSIÓN', 'MODO']];
|
$rows = [['TIPO', 'ID', 'VERSIÓN', 'MODO', 'DESCRIPCIÓN']];
|
||||||
|
|
||||||
$core = $installer->lock()->core();
|
$core = $installer->lock()->core();
|
||||||
if ($core !== null) {
|
if ($core !== null) {
|
||||||
$rows[] = ['core', 'core', (string) ($core['version'] ?? '?'), 'copy'];
|
$rows[] = ['core', 'core', (string) ($core['version'] ?? '?'), 'copy', ''];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($installer->lock()->components() as $id => $entry) {
|
foreach ($installer->lock()->components() as $id => $entry) {
|
||||||
$rows[] = [$entry['package'], $id, $entry['version'], $entry['mode']];
|
$rows[] = [
|
||||||
|
$entry['package'],
|
||||||
|
$id,
|
||||||
|
$entry['version'],
|
||||||
|
$entry['mode'],
|
||||||
|
$entry['description'] ?? '—',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($rows) === 1) {
|
if (count($rows) === 1) {
|
||||||
|
|||||||
@@ -8,18 +8,18 @@ namespace Toolbox;
|
|||||||
*/
|
*/
|
||||||
final class Config
|
final class Config
|
||||||
{
|
{
|
||||||
public const VERSION = '0.2.3';
|
public const VERSION = '0.2.8';
|
||||||
|
|
||||||
public static function toolboxUrl(): string
|
public static function toolboxUrl(): string
|
||||||
{
|
{
|
||||||
return self::env('DUCKBRAIN_TOOLBOX_URL')
|
return self::env('DUCKBRAIN_TOOLBOX_URL')
|
||||||
?: 'git@git.kj2.me:kj/duckbrain-toolbox.git';
|
?: 'https://git.kj2.me/kj/duckbrain-toolbox.git';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function coreUrl(): string
|
public static function coreUrl(): string
|
||||||
{
|
{
|
||||||
return self::env('DUCKBRAIN_CORE_URL')
|
return self::env('DUCKBRAIN_CORE_URL')
|
||||||
?: 'git@git.kj2.me:kj/duckbrain.git';
|
?: 'https://git.kj2.me/kj/duckbrain.git';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ final class Installer
|
|||||||
'version' => $version,
|
'version' => $version,
|
||||||
'toolbox_commit' => $commit,
|
'toolbox_commit' => $commit,
|
||||||
'mode' => $mode,
|
'mode' => $mode,
|
||||||
|
'description' => $manifest->descriptionOf($componentName),
|
||||||
'files' => $result['files'],
|
'files' => $result['files'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -283,6 +284,13 @@ final class Installer
|
|||||||
}
|
}
|
||||||
|
|
||||||
$absolute = $this->root . '/' . $relative;
|
$absolute = $this->root . '/' . $relative;
|
||||||
|
|
||||||
|
if ($options['dryRun'] ?? false) {
|
||||||
|
$actions[$relative] = 'planned';
|
||||||
|
$installed[$relative] = 'sha256:' . hash('sha256', $provider->read($relative));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$action = $this->sync->write($absolute, $provider->read($relative), $expected[$relative] ?? null, $options);
|
$action = $this->sync->write($absolute, $provider->read($relative), $expected[$relative] ?? null, $options);
|
||||||
$actions[$relative] = $action;
|
$actions[$relative] = $action;
|
||||||
|
|
||||||
@@ -299,6 +307,10 @@ final class Installer
|
|||||||
if (isset($installed[$relative]) || isset($protected[$relative]) || in_array($relative, $files, true)) {
|
if (isset($installed[$relative]) || isset($protected[$relative]) || in_array($relative, $files, true)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($options['dryRun'] ?? false) {
|
||||||
|
$actions[$relative] = 'remove-planned';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$action = $this->sync->remove($this->root . '/' . $relative, $hash, $options);
|
$action = $this->sync->remove($this->root . '/' . $relative, $hash, $options);
|
||||||
if ($action === FileSync::ABORTED) {
|
if ($action === FileSync::ABORTED) {
|
||||||
$aborted[] = $relative;
|
$aborted[] = $relative;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ final class Lock
|
|||||||
'toolbox_commit' => $entry['toolbox_commit'] ?? null,
|
'toolbox_commit' => $entry['toolbox_commit'] ?? null,
|
||||||
'mode' => (string) ($entry['mode'] ?? 'copy'),
|
'mode' => (string) ($entry['mode'] ?? 'copy'),
|
||||||
'origin' => (string) ($entry['origin'] ?? 'toolbox'),
|
'origin' => (string) ($entry['origin'] ?? 'toolbox'),
|
||||||
|
'description' => $entry['description'] ?? null,
|
||||||
'files' => is_array($entry['files'] ?? null) ? $entry['files'] : [],
|
'files' => is_array($entry['files'] ?? null) ? $entry['files'] : [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "htmx",
|
"name": "htmx",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"description": "Motor de vistas basado en componentes y HTMX",
|
"description": "Motor de vistas basado en componentes y HTMX",
|
||||||
"core": {
|
"core": {
|
||||||
"requires": ">=0.1",
|
"requires": ">=0.1",
|
||||||
|
|||||||
@@ -13,29 +13,6 @@ namespace Libs;
|
|||||||
*/
|
*/
|
||||||
class HTMX extends HTMLComponent
|
class HTMX extends HTMLComponent
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Valid HTMX swap styles.
|
|
||||||
*/
|
|
||||||
public const SWAP_STYLES = [
|
|
||||||
'innerHTML', 'outerHTML', 'outerSync',
|
|
||||||
'beforebegin', 'before',
|
|
||||||
'afterbegin', 'prepend',
|
|
||||||
'beforeend', 'append',
|
|
||||||
'afterend', 'after',
|
|
||||||
'delete', 'none',
|
|
||||||
'innerMorph', 'outerMorph',
|
|
||||||
'textContent',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid HTMX swap option keys (used as key:value after the style).
|
|
||||||
*/
|
|
||||||
public const SWAP_OPTIONS = [
|
|
||||||
'swap', 'settle', 'transition', 'ignoreTitle', 'strip',
|
|
||||||
'focusScroll', 'swapEmpty', 'scroll', 'show', 'showTarget',
|
|
||||||
'scrollTarget', 'target',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a component
|
* Renders a component
|
||||||
*
|
*
|
||||||
@@ -266,8 +243,6 @@ class HTMX extends HTMLComponent
|
|||||||
* @param bool $excludeBoosted Exclude if the request is boosted (default: false)
|
* @param bool $excludeBoosted Exclude if the request is boosted (default: false)
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*
|
|
||||||
* @throws \InvalidArgumentException On an unknown style or option.
|
|
||||||
*/
|
*/
|
||||||
public static function swapOob(string $value = 'true', bool $excludeBoosted = false): string
|
public static function swapOob(string $value = 'true', bool $excludeBoosted = false): string
|
||||||
{
|
{
|
||||||
@@ -275,10 +250,6 @@ class HTMX extends HTMLComponent
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($value !== 'true') {
|
|
||||||
$value = static::validateSwap($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return static::isHtmx() ? 'hx-swap-oob="' . $value . '"' : '';
|
return static::isHtmx() ? 'hx-swap-oob="' . $value . '"' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,8 +289,6 @@ class HTMX extends HTMLComponent
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$swap = static::validateSwap($swap);
|
|
||||||
|
|
||||||
return '<hx-partial hx-target="' . htmlspecialchars($target)
|
return '<hx-partial hx-target="' . htmlspecialchars($target)
|
||||||
. '" hx-swap="' . htmlspecialchars($swap) . '">'
|
. '" hx-swap="' . htmlspecialchars($swap) . '">'
|
||||||
. $content
|
. $content
|
||||||
@@ -381,52 +350,10 @@ class HTMX extends HTMLComponent
|
|||||||
* @param string $rule
|
* @param string $rule
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*
|
|
||||||
* @throws \InvalidArgumentException On an unknown style or option.
|
|
||||||
*/
|
*/
|
||||||
public static function reswap(string $rule = 'innerHTML'): void
|
public static function reswap(string $rule = 'innerHTML'): void
|
||||||
{
|
{
|
||||||
header('HX-Reswap: ' . static::validateSwap($rule));
|
header('HX-Reswap: ' . $rule);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a HTMX swap value ("style [key:value]...").
|
|
||||||
*
|
|
||||||
* @param string $value
|
|
||||||
*
|
|
||||||
* @return string The normalized value.
|
|
||||||
*
|
|
||||||
* @throws \InvalidArgumentException On an unknown style or option.
|
|
||||||
*/
|
|
||||||
public static function validateSwap(string $value): string
|
|
||||||
{
|
|
||||||
$parts = preg_split(
|
|
||||||
'/\s+/',
|
|
||||||
trim($value),
|
|
||||||
-1,
|
|
||||||
PREG_SPLIT_NO_EMPTY
|
|
||||||
);
|
|
||||||
|
|
||||||
if (empty($parts)) {
|
|
||||||
throw new \InvalidArgumentException('Empty swap value.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!in_array($parts[0], static::SWAP_STYLES, true)) {
|
|
||||||
throw new \InvalidArgumentException(
|
|
||||||
'Invalid swap style: ' . $parts[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (array_slice($parts, 1) as $option) {
|
|
||||||
$key = explode(':', $option)[0];
|
|
||||||
if (!in_array($key, static::SWAP_OPTIONS, true)) {
|
|
||||||
throw new \InvalidArgumentException(
|
|
||||||
'Invalid swap option: ' . $option
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode(' ', $parts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Toolbox\Config;
|
||||||
|
|
||||||
|
Harness::suite('group12');
|
||||||
|
|
||||||
|
Harness::it('los defaults de lectura son HTTPS anónimos y los env ganan', function () {
|
||||||
|
putenv('DUCKBRAIN_TOOLBOX_URL');
|
||||||
|
putenv('DUCKBRAIN_CORE_URL');
|
||||||
|
|
||||||
|
ok(str_starts_with(Config::toolboxUrl(), 'https://'), 'toolboxUrl por defecto en https');
|
||||||
|
ok(str_starts_with(Config::coreUrl(), 'https://'), 'coreUrl por defecto en https');
|
||||||
|
|
||||||
|
putenv('DUCKBRAIN_CORE_URL=ssh://git@example.test/kj/duckbrain.git');
|
||||||
|
eq('ssh://git@example.test/kj/duckbrain.git', Config::coreUrl(), 'el override gana sobre el default');
|
||||||
|
|
||||||
|
putenv('DUCKBRAIN_CORE_URL');
|
||||||
|
});
|
||||||
@@ -100,3 +100,52 @@ Harness::it('destino explícito sobreescribe la regla', function () {
|
|||||||
$dest = Destination::relative('src/Libs/Crypto.php', '/proj', '/proj/src', ['src/Libs/Crypto.php' => 'elsewhere/Crypto.php']);
|
$dest = Destination::relative('src/Libs/Crypto.php', '/proj', '/proj/src', ['src/Libs/Crypto.php' => 'elsewhere/Crypto.php']);
|
||||||
eq('elsewhere/Crypto.php', $dest);
|
eq('elsewhere/Crypto.php', $dest);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Harness::it('el lock persiste la descripción y tolera locks sin el campo', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
$lock = Lock::empty();
|
||||||
|
$lock->setComponent('db:sql', [
|
||||||
|
'package' => 'db',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'mode' => 'copy',
|
||||||
|
'description' => 'SQL',
|
||||||
|
'files' => [],
|
||||||
|
]);
|
||||||
|
$lock->save($dir);
|
||||||
|
|
||||||
|
eq('SQL', Lock::load($dir)->component('db:sql')['description'], 'roundtrip de description');
|
||||||
|
|
||||||
|
$raw = json_decode((string) file_get_contents($dir . '/.duckbrain/lock.json'), true);
|
||||||
|
unset($raw['components']['db:sql']['description']);
|
||||||
|
file_put_contents($dir . '/.duckbrain/lock.json', json_encode($raw));
|
||||||
|
|
||||||
|
eq(null, Lock::load($dir)->component('db:sql')['description'], 'lock antiguo sin campo se lee tolerante');
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('add captura la descripción efectiva del catálogo en el lock', function () {
|
||||||
|
$root = tmpdir('proj');
|
||||||
|
$tool = tmpdir('tool');
|
||||||
|
write_file($tool . '/packages/mix/duckbrain.json', json_encode([
|
||||||
|
'name' => 'mix',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'description' => 'Descripción del paquete',
|
||||||
|
'components' => [
|
||||||
|
'own' => ['files' => ['src/Libs/Own.php'], 'description' => 'Descripción propia'],
|
||||||
|
'inh' => ['files' => ['src/Libs/Inh.php']],
|
||||||
|
],
|
||||||
|
'default' => ['own', 'inh'],
|
||||||
|
]));
|
||||||
|
write_file($tool . '/packages/mix/src/Libs/Own.php', "<?php\n");
|
||||||
|
write_file($tool . '/packages/mix/src/Libs/Inh.php', "<?php\n");
|
||||||
|
|
||||||
|
(new Toolbox\Installer($root, $tool, new Toolbox\Console()))->add(['mix'], ['yes' => true]);
|
||||||
|
|
||||||
|
$lock = Lock::load($root);
|
||||||
|
eq('Descripción propia', $lock->component('mix:own')['description'], 'propia gana en el lock');
|
||||||
|
eq('Descripción del paquete', $lock->component('mix:inh')['description'], 'sin propia, hereda del paquete');
|
||||||
|
|
||||||
|
rrmdir($root);
|
||||||
|
rrmdir($tool);
|
||||||
|
});
|
||||||
|
|||||||
@@ -194,6 +194,48 @@ Harness::it('--dry-run no escribe archivos ni lock', function () {
|
|||||||
rrmdir($tool);
|
rrmdir($tool);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Harness::it('update core --dry-run no escribe archivos', function () {
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$tool = g7_toolbox();
|
||||||
|
$core = g7_core('1.0.0', 'router-v1');
|
||||||
|
$core2 = g7_core('2.0.0', 'router-v2');
|
||||||
|
|
||||||
|
(new InstallCommand(new Console()))->run([$proj, '--toolbox=' . $tool, '--core-dir=' . $core, '--yes']);
|
||||||
|
|
||||||
|
$routerBefore = (string) file_get_contents($proj . '/src/Libs/Router.php');
|
||||||
|
(new UpdateCommand(new Console()))->run(['core', '--root=' . $proj, '--toolbox=' . $tool, '--core-dir=' . $core2, '--yes', '--dry-run']);
|
||||||
|
|
||||||
|
ok(str_contains($routerBefore, 'router-v1'), 'estado inicial correcto');
|
||||||
|
ok(str_contains((string) file_get_contents($proj . '/src/Libs/Router.php'), 'router-v1'), 'dry-run no escribe el fichero');
|
||||||
|
eq('1.0.0', Lock::load($proj)->core()['version'], 'dry-run no actualiza la version en el lock');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($tool);
|
||||||
|
rrmdir($core);
|
||||||
|
rrmdir($core2);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('update core --dry-run aborta por drift sin escribir', function () {
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$tool = g7_toolbox();
|
||||||
|
$core = g7_core('1.0.0', 'router-v1');
|
||||||
|
$core2 = g7_core('2.0.0', 'router-v2');
|
||||||
|
|
||||||
|
(new InstallCommand(new Console()))->run([$proj, '--toolbox=' . $tool, '--core-dir=' . $core, '--yes']);
|
||||||
|
write_file($proj . '/config.php', "<?php\n// customizado\n");
|
||||||
|
|
||||||
|
(new UpdateCommand(new Console()))->run(['core', '--root=' . $proj, '--toolbox=' . $tool, '--core-dir=' . $core2, '--yes', '--dry-run']);
|
||||||
|
|
||||||
|
ok(!is_file($proj . '/config.php.bak'), 'dry-run no crea backups');
|
||||||
|
ok(str_contains((string) file_get_contents($proj . '/config.php'), 'customizado'), 'dry-run conserva la edicion local');
|
||||||
|
ok(str_contains((string) file_get_contents($proj . '/src/Libs/Router.php'), 'router-v1'), 'dry-run no toca ficheros sin drift');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($tool);
|
||||||
|
rrmdir($core);
|
||||||
|
rrmdir($core2);
|
||||||
|
});
|
||||||
|
|
||||||
Harness::it('core sin tags: versión desconocida y commit pineado', function () {
|
Harness::it('core sin tags: versión desconocida y commit pineado', function () {
|
||||||
$proj = tmpdir('proj');
|
$proj = tmpdir('proj');
|
||||||
$tool = g7_toolbox();
|
$tool = g7_toolbox();
|
||||||
@@ -262,3 +304,19 @@ Harness::it('el catálogo describe cada componente con su texto propio o heredad
|
|||||||
rrmdir($toolbox);
|
rrmdir($toolbox);
|
||||||
rrmdir($proj);
|
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', "<?php\nspl_autoload_register(function (string \$c): void {\n \$f = \$c === 'Commands\\\\SinDoc' ? ROOT_CORE . '/Commands/SinDoc.php' : null;\n if (\$f !== null && is_file(\$f)) { require \$f; }\n});\ndefine('ROOT_CORE', __DIR__ . '/src');\ndefine('ROOT_DIR', __DIR__);\n");
|
||||||
|
write_file($proj . '/src/Commands/SinDoc.php', "<?php\nnamespace Commands;\nclass SinDoc implements \\Interfaces\\CommandInterface { public function run(): void {} }\n");
|
||||||
|
write_file($proj . '/src/Interfaces/CommandInterface.php', "<?php\nnamespace Interfaces;\ninterface CommandInterface { public function run(): void; public static function describe(): string; }\n");
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$code = 0;
|
||||||
|
exec('cd ' . escapeshellarg($proj) . ' && php -r \'require "autoload.php"; class_exists("Commands\\\\SinDoc") ? print("loaded") : print("missing");\' 2>&1', $out, $code);
|
||||||
|
|
||||||
|
ok($code !== 0 || str_contains(implode('', $out), 'Fatal'), 'la carga de un comando sin describe produce error fatal');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
});
|
||||||
|
|||||||
@@ -72,3 +72,138 @@ Harness::it('la actualización del motor surte efecto en la siguiente invocació
|
|||||||
rrmdir($proj);
|
rrmdir($proj);
|
||||||
rrmdir($core);
|
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', "<?php\n\nnamespace Commands;\n\nclass Eco implements \\Interfaces\\CommandInterface\n{\n public static function describe(): string { return 'Comando eco de prueba'; }\n public function run(): void { echo 'ECO_OK' . PHP_EOL; }\n}\n");
|
||||||
|
|
||||||
|
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain eco 2>&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', "<?php\n\nnamespace Commands;\n\nclass Ladro implements \\Interfaces\\CommandInterface\n{\n public static function describe(): string { return 'ladrador'; }\n public static function aliases(): array { return ['eco']; }\n public function run(): void {}\n}\n");
|
||||||
|
write_file($proj . '/src/Commands/Eco.php', str_replace(" public static function aliases(): array { return ['add']; }\n", '', (string) file_get_contents($proj . '/src/Commands/Eco.php')));
|
||||||
|
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain 2>&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);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('list muestra la descripción del lock por componente, con — si falta', function () {
|
||||||
|
$root = tmpdir('proj');
|
||||||
|
$tool = tmpdir('tool');
|
||||||
|
write_file($tool . '/packages/mix/duckbrain.json', json_encode([
|
||||||
|
'name' => 'mix',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'description' => 'Descripción del paquete',
|
||||||
|
'components' => [
|
||||||
|
'own' => ['files' => ['src/Libs/Own.php'], 'description' => 'Descripción propia'],
|
||||||
|
'inh' => ['files' => ['src/Libs/Inh.php']],
|
||||||
|
'old' => ['files' => ['src/Libs/Old.php']],
|
||||||
|
],
|
||||||
|
'default' => ['own', 'inh', 'old'],
|
||||||
|
]));
|
||||||
|
foreach (['Own', 'Inh', 'Old'] as $c) {
|
||||||
|
write_file($tool . "/packages/mix/src/Libs/{$c}.php", "<?php\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
(new Toolbox\Installer($root, $tool, new Console()))->add(['mix'], ['yes' => true]);
|
||||||
|
|
||||||
|
// simular lock viejo: quitar la descripción de `old`
|
||||||
|
$raw = json_decode((string) file_get_contents($root . '/.duckbrain/lock.json'), true);
|
||||||
|
unset($raw['components']['mix:old']['description']);
|
||||||
|
file_put_contents($root . '/.duckbrain/lock.json', json_encode($raw));
|
||||||
|
|
||||||
|
$out = fopen('php://memory', 'rw');
|
||||||
|
$code = (new Toolbox\Commands\ListCommand(new Console($out)))->run(['--root=' . $root, '--toolbox=' . $tool]);
|
||||||
|
rewind($out);
|
||||||
|
$text = (string) stream_get_contents($out);
|
||||||
|
|
||||||
|
eq(0, $code);
|
||||||
|
ok((bool) preg_match('/mix:own.*Descripción propia/', $text), 'fila propia con su descripción');
|
||||||
|
ok((bool) preg_match('/mix:inh.*Descripción del paquete/', $text), 'fila heredada con la del paquete');
|
||||||
|
ok((bool) preg_match('/mix:old.*—/', $text), 'lock viejo sin campo muestra —');
|
||||||
|
|
||||||
|
rrmdir($root);
|
||||||
|
rrmdir($tool);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('commands lista la superficie con origen y reporta 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', 'commands:toolbox', '--root=' . $proj, '--toolbox=' . $repo, '--yes']);
|
||||||
|
|
||||||
|
$eco = "<?php\n\nnamespace Commands;\n\nclass Eco implements \\Interfaces\\CommandInterface\n{\n public static function describe(): string { return 'eco local'; }\n public static function aliases(): array { return ['hola']; }\n public function run(): void {}\n}\n";
|
||||||
|
write_file($proj . '/src/Commands/Eco.php', $eco);
|
||||||
|
|
||||||
|
// sin colisión: funciona desde el bin del proyecto
|
||||||
|
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain commands 2>&1', $o, $code);
|
||||||
|
eq(0, $code, 'bin del proyecto sirve commands');
|
||||||
|
$text = implode("\n", $o);
|
||||||
|
ok(str_contains($text, 'eco') && str_contains($text, 'hola'), 'comando propio con su alias');
|
||||||
|
ok(str_contains($text, 'proyecto'), 'origen proyecto');
|
||||||
|
ok(str_contains($text, 'commands:migration@'), 'origen componente@versión desde el lock');
|
||||||
|
|
||||||
|
// con colisión (roba el nombre de clase migration): el motor REPORTA, el bin aborta
|
||||||
|
write_file($proj . '/src/Commands/Eco.php', str_replace("['hola']", "['migration']", $eco));
|
||||||
|
|
||||||
|
$out = fopen('php://memory', 'rw');
|
||||||
|
$code = (new Toolbox\Commands\CommandsCommand(new Console($out, $out)))->run(['--root=' . $proj]);
|
||||||
|
rewind($out);
|
||||||
|
$text = (string) stream_get_contents($out);
|
||||||
|
|
||||||
|
eq(0, $code, 'la vista no aborta ante colisiones');
|
||||||
|
ok(str_contains($text, 'migration!'), 'fila marcada con !');
|
||||||
|
ok(str_contains($text, "WARN: alias 'migration'"), 'warn con los dueños');
|
||||||
|
|
||||||
|
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain list 2>&1', $o, $c2);
|
||||||
|
eq(1, $c2, 'el despachador sí aborta ante colisión (guard del bin)');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($core);
|
||||||
|
});
|
||||||
|
|||||||
@@ -121,3 +121,31 @@ Harness::it('adopt registra el core existente con sus hashes', function () {
|
|||||||
rrmdir($proj);
|
rrmdir($proj);
|
||||||
rrmdir($core);
|
rrmdir($core);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Harness::it('adopt del core registra hash canónico y marca ediciones locales', function () {
|
||||||
|
$repo = $GLOBALS['g9_repo'];
|
||||||
|
$core = g7_core('0.1.0');
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
|
||||||
|
// Copia idéntica de Router.php + config.php customizado (proyecto heredado).
|
||||||
|
write_file($proj . '/src/Libs/Router.php', (string) file_get_contents($core . '/src/Libs/Router.php'));
|
||||||
|
write_file($proj . '/config.php', "<?php\n// credenciales del proyecto\n");
|
||||||
|
|
||||||
|
(new AdoptCommand(new Console()))->run(['--root=' . $proj, '--toolbox=' . $repo, '--core-dir=' . $core]);
|
||||||
|
|
||||||
|
$lock = Lock::load($proj);
|
||||||
|
$coreHashRouter = $lock->core()['files']['src/Libs/Router.php'];
|
||||||
|
$coreHashConfig = $lock->core()['files']['config.php'];
|
||||||
|
|
||||||
|
$canonicalRouter = (string) file_get_contents($core . '/src/Libs/Router.php');
|
||||||
|
$canonicalConfig = (string) file_get_contents($core . '/config.php');
|
||||||
|
|
||||||
|
eq('sha256:' . hash('sha256', $canonicalRouter), $coreHashRouter, 'hash de Router.php = canónico');
|
||||||
|
eq('sha256:' . hash('sha256', $canonicalConfig), $coreHashConfig, 'hash de config.php = canónico (aunque el local difiera)');
|
||||||
|
eq('local', $lock->core()['origin'] ?? null, 'origin = local porque hay ediciones locales');
|
||||||
|
|
||||||
|
ok(file_get_contents($proj . '/config.php') !== $canonicalConfig, 'la copia local customizada no se tocó');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($core);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user