first commit.

This commit is contained in:
kj
2026-10-05 15:08:39 -03:00
commit b7799f9fff
93 changed files with 8966 additions and 0 deletions
@@ -0,0 +1,47 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class AddCommand extends Command
{
public static function describe(): string
{
return 'Instala componentes o paquetes (alta con copia real)';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
if ($parsed['args'] === []) {
$this->console->error('Indica al menos un componente o paquete.');
return 1;
}
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$installer = new Installer($root, $toolbox, $this->console, $this->rootCore($parsed['options'], $root));
try {
$result = $installer->add($parsed['args'], $this->behaviorOptions($parsed['options']));
} catch (ToolboxException $e) {
$this->console->error($e->getMessage());
return 1;
}
foreach ($result['installed'] as $id) {
$this->console->ok($id);
}
foreach ($result['warnings'] as $warning) {
$this->console->warn($warning);
}
foreach ($result['aborted'] as $aborted) {
$this->console->error('abortado: ' . $aborted);
}
return $result['aborted'] === [] ? 0 : 1;
}
}
@@ -0,0 +1,152 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Destination;
use Toolbox\DirectorySource;
use Toolbox\Drift;
use Toolbox\FileExpander;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class AdoptCommand extends Command
{
public static function describe(): string
{
return 'Adopta el core y las copias existentes y las registra en el lock';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$rootCore = $this->rootCore($parsed['options'], $root) ?? $root . '/src';
$dryRun = $this->flag($parsed['options'], 'dry-run');
$installer = new Installer($root, $toolbox, $this->console, $rootCore);
$catalog = $installer->catalog();
$lock = $installer->lock();
$source = new DirectorySource(rtrim($toolbox, '/') . '/packages');
$adopted = [];
$divergent = [];
foreach ($catalog->packages() as $package) {
foreach ($package->components() as $name => $component) {
$id = $package->name . ':' . $name;
if ($lock->has($id)) {
continue;
}
$files = [];
$present = 0;
$hasLocal = false;
foreach (FileExpander::expand($source, $package->name, $component->files) as $file) {
$relative = Destination::relative($file, $root, $rootCore, $component->destinations);
$absolute = $root . '/' . $relative;
if (!is_file($absolute)) {
continue;
}
$present++;
$canonical = $source->read($package->name, $file);
if ($canonical !== null) {
// Se registra el hash CANÓNICO: si la copia local difiere,
// queda marcada como edición local (drift) y `update`
// preguntará antes de pisarla.
$files[$relative] = 'sha256:' . hash('sha256', $canonical);
if (file_get_contents($absolute) !== $canonical) {
$hasLocal = true;
}
} else {
$files[$relative] = Drift::hash($absolute);
$hasLocal = true;
}
}
if ($present === 0) {
continue;
}
$lock->setComponent($id, [
'package' => $package->name,
'version' => $package->version,
'toolbox_commit' => null,
'mode' => 'copy',
'origin' => $hasLocal ? 'local' : 'adopted',
'files' => $files,
]);
$adopted[] = $id;
if ($hasLocal) {
$divergent[] = $id;
}
}
}
$coreAdopted = $this->adoptCore($parsed['options'], $root, $lock, $dryRun);
if (!$dryRun) {
$lock->save($root);
}
foreach ($adopted as $id) {
$mark = in_array($id, $divergent, true) ? ' (con ediciones locales)' : '';
$this->console->ok('adoptado ' . $id . $mark);
}
if ($coreAdopted > 0) {
$this->console->ok("adoptado el core ({$coreAdopted} archivos)");
}
if ($adopted === [] && $coreAdopted === 0) {
$this->console->out('No se encontró nada que adoptar.');
}
return 0;
}
/**
* Registra el core existente: sólo los archivos que también existen en el
* core de referencia, con su hash actual. No toca archivos.
*
* @param array<string, string|bool> $options
*/
private function adoptCore(array $options, string $root, $lock, bool $dryRun): int
{
if ($lock->core() !== null) {
return 0;
}
try {
$provider = $this->coreProvider($options);
} catch (ToolboxException $e) {
$this->console->warn('no se pudo resolver el core para adoptar: ' . $e->getMessage());
return 0;
}
$files = [];
foreach ($provider->files() as $relative) {
$absolute = $root . '/' . $relative;
if (is_file($absolute)) {
$files[$relative] = Drift::hash($absolute);
}
}
if ($files === []) {
return 0;
}
if (!$dryRun) {
$lock->setCore([
'version' => $provider->version(),
'commit' => $provider->commit(),
'files' => $files,
]);
}
return count($files);
}
}
@@ -0,0 +1,44 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Catalog;
use Toolbox\Lock;
final class CatalogCommand extends Command
{
public static function describe(): string
{
return 'Lista los paquetes y componentes disponibles';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$catalog = Catalog::fromRoot($toolbox);
$lock = Lock::load($root);
$rows = [['PAQUETE', 'COMPONENTE', 'ESTADO', 'DESCRIPCIÓN']];
foreach ($catalog->packages() as $package) {
$first = true;
foreach ($package->components() as $name => $component) {
$id = $package->name . ':' . $name;
$installed = $lock->has($id) ? 'instalado' : '-';
$rows[] = [
$first ? $package->name . ' (' . $package->version . ')' : '',
$name,
$installed,
$first ? $package->description : '',
];
$first = false;
}
}
$this->console->table($rows);
return 0;
}
}
@@ -0,0 +1,107 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Config;
use Toolbox\Console;
use Toolbox\CoreProvider;
abstract class Command implements CommandInterface
{
public function __construct(protected readonly Console $console)
{
}
/**
* @param list<string> $args
* @return array{options: array<string, string|bool>, args: list<string>}
*/
protected function parse(array $args): array
{
$options = [];
$positional = [];
foreach ($args as $arg) {
if (str_starts_with($arg, '--')) {
$token = substr($arg, 2);
if (str_contains($token, '=')) {
[$key, $value] = explode('=', $token, 2);
$options[$key] = $value;
} else {
$options[$token] = true;
}
continue;
}
$positional[] = $arg;
}
return ['options' => $options, 'args' => $positional];
}
/**
* @param array<string, string|bool> $options
*/
protected function option(array $options, string $key, ?string $default = null): ?string
{
$value = $options[$key] ?? null;
if ($value === true || $value === null) {
return $default;
}
return (string) $value;
}
/**
* @param array<string, string|bool> $options
*/
protected function flag(array $options, string $key): bool
{
return array_key_exists($key, $options);
}
protected function root(array $options): string
{
return rtrim($this->option($options, 'root', getcwd() ?: '.') ?? '.', '/');
}
protected function toolboxDir(array $options): string
{
return rtrim($this->option($options, 'toolbox', getcwd() ?: '.') ?? '.', '/');
}
protected function rootCore(array $options, string $root): ?string
{
$core = $this->option($options, 'core');
return $core === null ? null : rtrim($core, '/');
}
protected function coreProvider(array $options): CoreProvider
{
$dir = $this->option($options, 'core-dir');
if ($dir !== null) {
return CoreProvider::fromDirectory($dir);
}
return CoreProvider::fromUrl(Config::coreUrl(), Config::cacheDir(), $this->console);
}
/**
* @param array<string, string|bool> $options
* @return array<string, bool>
*/
protected function behaviorOptions(array $options): array
{
return [
'force' => $this->flag($options, 'force'),
'backup' => $this->flag($options, 'backup'),
'keepLocal' => $this->flag($options, 'keep-local'),
'dryRun' => $this->flag($options, 'dry-run'),
'yes' => $this->flag($options, 'yes') || $this->flag($options, 'y'),
'link' => $this->flag($options, 'link'),
];
}
}
@@ -0,0 +1,13 @@
<?php
namespace Toolbox\Commands;
interface CommandInterface
{
/**
* @param list<string> $args
*/
public function run(array $args): int;
public static function describe(): string;
}
@@ -0,0 +1,62 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Catalog;
use Toolbox\Compatibility;
use Toolbox\Lock;
final class DoctorCommand extends Command
{
public static function describe(): string
{
return 'Matriz de compatibilidad de los componentes instalados con el core';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$lock = Lock::load($root);
$catalog = Catalog::fromRoot($toolbox);
$coreVersion = $lock->core()['version'] ?? null;
if ($lock->components() === []) {
$this->console->out('No hay componentes instalados.');
return 0;
}
$rows = [['COMPONENTE', 'VER', 'CORE REQ', 'CORE PROBADO', 'CORE ACTUAL', 'ESTADO']];
foreach ($lock->components() as $id => $entry) {
[$package, $component] = array_pad(explode(':', $id, 2), 2, null);
$requires = null;
$tested = null;
if ($component !== null && $package !== null && $catalog->has($package)) {
$manifest = $catalog->get($package);
if ($manifest->hasComponent($component)) {
$requires = $manifest->coreRequirement($component);
$tested = $manifest->testedCore($component);
}
}
$result = Compatibility::evaluateCore($coreVersion, $requires, $tested);
$rows[] = [
$id,
(string) ($entry['version'] ?? ''),
$requires ?? '—',
$tested ?? '—',
$coreVersion ?? '—',
Compatibility::label($result['status']),
];
}
$this->console->table($rows);
return 0;
}
}
@@ -0,0 +1,43 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class InstallCommand extends Command
{
public static function describe(): string
{
return 'Descarga el core Duckbrain en un directorio (bootstrap)';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$dir = rtrim($parsed['args'][0] ?? '.', '/');
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
$this->console->error('No se pudo crear el directorio: ' . $dir);
return 1;
}
$toolbox = $this->toolboxDir($parsed['options']);
$installer = new Installer($dir, $toolbox, $this->console, $this->rootCore($parsed['options'], $dir));
try {
$provider = $this->coreProvider($parsed['options']);
$result = $installer->installCore($provider, $this->behaviorOptions($parsed['options']));
} catch (ToolboxException $e) {
$this->console->error($e->getMessage());
return 1;
}
$this->console->ok('core ' . ($result['version'] ?? '?') . ' en ' . $dir);
foreach ($result['aborted'] as $aborted) {
$this->console->error('abortado: ' . $aborted);
}
return $result['aborted'] === [] ? 0 : 1;
}
}
@@ -0,0 +1,42 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class ListCommand extends Command
{
public static function describe(): string
{
return 'Lista el core y los componentes instalados';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$installer = new Installer($root, $toolbox, $this->console, $this->rootCore($parsed['options'], $root));
$rows = [['TIPO', 'ID', 'VERSIÓN', 'MODO']];
$core = $installer->lock()->core();
if ($core !== null) {
$rows[] = ['core', 'core', (string) ($core['version'] ?? '?'), 'copy'];
}
foreach ($installer->lock()->components() as $id => $entry) {
$rows[] = [$entry['package'], $id, $entry['version'], $entry['mode']];
}
if (count($rows) === 1) {
$this->console->out('No hay nada instalado.');
return 0;
}
$this->console->table($rows);
return 0;
}
}
@@ -0,0 +1,44 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class RemoveCommand extends Command
{
public static function describe(): string
{
return 'Quita componentes instalados (sólo sus archivos)';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
if ($parsed['args'] === []) {
$this->console->error('Indica al menos un componente (paquete:componente).');
return 1;
}
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$installer = new Installer($root, $toolbox, $this->console, $this->rootCore($parsed['options'], $root));
try {
$result = $installer->remove($parsed['args'], $this->behaviorOptions($parsed['options']));
} catch (ToolboxException $e) {
$this->console->error($e->getMessage());
return 1;
}
foreach ($result['removed'] as $id) {
$this->console->ok('quitado ' . $id);
}
foreach ($result['aborted'] as $aborted) {
$this->console->error('abortado (edición local): ' . $aborted);
}
return $result['aborted'] === [] ? 0 : 1;
}
}
@@ -0,0 +1,37 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Config;
use Toolbox\SelfUpdater;
use Toolbox\ToolboxException;
final class SelfUpdateCommand extends Command
{
public static function describe(): string
{
return 'Actualiza el instalador global (script/phar)';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$from = $this->option($parsed['options'], 'from', Config::updateUrl());
$target = $this->option($parsed['options'], 'target', Config::binPath());
$temp = $this->option($parsed['options'], 'temp', sys_get_temp_dir());
if ($from === null || $target === null) {
$this->console->error('Faltan --from o --target.');
return 1;
}
try {
(new SelfUpdater($this->console))->update($from, $target, $temp);
} catch (ToolboxException $e) {
$this->console->error($e->getMessage());
return 1;
}
return 0;
}
}
@@ -0,0 +1,62 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class UpdateCommand extends Command
{
public static function describe(): string
{
return 'Actualiza el core, componentes o todo el proyecto';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$options = $this->behaviorOptions($parsed['options']);
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$installer = new Installer($root, $toolbox, $this->console, $this->rootCore($parsed['options'], $root));
try {
if (($parsed['args'][0] ?? null) === 'core') {
$provider = $this->coreProvider($parsed['options']);
$result = $installer->updateCore($provider, $options);
$this->console->ok('core ' . ($result['version'] ?? '?'));
foreach ($result['aborted'] as $aborted) {
$this->console->error('abortado: ' . $aborted);
}
return $result['aborted'] === [] ? 0 : 1;
}
$ids = $parsed['args'] !== []
? $parsed['args']
: array_keys($installer->lock()->components());
if ($ids === []) {
$this->console->out('No hay componentes instalados que actualizar.');
return 0;
}
$result = $installer->update($ids, $options);
} catch (ToolboxException $e) {
$this->console->error($e->getMessage());
return 1;
}
foreach ($result['installed'] as $id) {
$this->console->ok('actualizado ' . $id);
}
foreach ($result['warnings'] as $warning) {
$this->console->warn($warning);
}
foreach ($result['aborted'] as $aborted) {
$this->console->error('abortado: ' . $aborted);
}
return $result['aborted'] === [] ? 0 : 1;
}
}