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
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env php
<?php
// CLI del proyecto Duckbrain. Requiere el autoloader del core (proyecto ya
// armado). Delega en el motor `Toolbox\` si el componente opcional
// `commands:toolbox` está instalado; en caso contrario, sólo ofrece los
// comandos propios del proyecto (p. ej. migraciones).
require_once('./autoload.php');
$command = $argv[1] ?? null;
$toolboxCommands = [
'add', 'remove', 'update', 'list', 'doctor', 'catalog', 'adopt',
'help', '--help', '-h', '--version', '-V',
];
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));
}
// Comandos propios del proyecto, despachados desde ROOT_CORE/Commands.
$coreCommand = 'Commands\\' . ucfirst($command);
if (!class_exists($coreCommand) && !file_exists(ROOT_CORE . '/Commands/' . ucfirst($command) . '.php')) {
exit("Invalid command.\n");
}
$cli = new $coreCommand();
if ($cli instanceof Interfaces\CommandInterface) {
call_user_func_array([$cli, 'run'], array_slice($argv, 2));
} else {
exit("Invalid command.\n");
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "commands",
"version": "0.1.0",
"description": "CLI y migraciones para Duckbrain",
"components": {
"cli": {
"files": [
"duckbrain",
"src/Interfaces/CommandInterface.php"
]
},
"migration": {
"files": [
"src/Commands/Migration.php",
"src/Libs/Migration.php"
],
"require": {
"commands:cli": "*"
}
},
"toolbox": {
"files": [
"src/Toolbox"
]
}
},
"default": [
"cli",
"migration"
]
}
@@ -0,0 +1,259 @@
<?php
namespace Commands;
use Interfaces\CommandInterface;
use Libs\Database;
use Libs\Migration as LibsMigration;
use Override;
use PDO;
class Migration implements CommandInterface
{
private const MIGRATIONS_PATH = ROOT_DIR . '/migrations/';
private string $migratonsDatabaseFile;
private PDO $db;
public function __construct(public bool $verbose = true)
{
$key = md5(DB_TYPE . '/' . DB_HOST . '/' . DB_NAME . '/' . DB_USER);
$this->migratonsDatabaseFile = "migrations-{$key}.sqlite";
$this->db = Database::getInstance(
type: 'sqlite',
name: static::MIGRATIONS_PATH . $this->migratonsDatabaseFile,
);
}
#[Override]
public function run(): void
{
$args = func_get_args();
$this->ensureDatabaseExists();
$command = $args[0] ?? '';
switch ($command) {
case 'migrate':
$this->migrate();
break;
case 'rollback':
$this->rollback((int)($args[1] ?? 0));
break;
case 'create':
$this->create((string)($args[1] ?? 'migration'));
break;
case 'fresh':
$this->fresh();
break;
case 'clear':
$this->dropAllTables();
break;
default:
$this->log('Invalid migration command.');
break;
}
}
/**
* Ensures that the migrations table exists in the database.
*
* @return void
*/
public function ensureDatabaseExists(): void
{
$this->db->query(
"CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY, migration TEXT, batch INTEGER)"
);
}
/**
* Runs all pending migrations.
*
* @return void
*/
public function migrate(): void
{
$files = glob(rtrim(static::MIGRATIONS_PATH, '/') . '/*.php');
$quantity = 1;
foreach ($files as $file) {
// Verificamos si la migración ya ha sigo realizada
$filename = basename($file);
$prepared = $this->db->prepare("SELECT id FROM migrations WHERE migration = ?");
$prepared->execute([$filename]);
if ((bool) $prepared->fetch()) {
continue;
}
// Obtener la migración
$migration = require $file;
if ($migration instanceof LibsMigration) {
if ($this->verbose) {
$this->log('Running migration: %s', $filename);
}
$migration->up();
// agregar la migracion a la base de datos
$stmt = $this->db->prepare("INSERT INTO migrations (migration, batch) VALUES (?, ?)");
$stmt->execute([$filename, $quantity]);
$quantity++;
}
}
$this->log("Migrations completed successfully.");
}
/**
* Rolls back a specified number of migrations or by default rollback lates batch of it.
*
* @param int $steps The number of migrations to rollback.
*
* @return void
*/
public function rollback(int $steps = 0): void
{
if ($steps <= 0) {
$steps = $this->db->query(
"SELECT batch FROM migrations ORDER BY id DESC LIMIT 1"
)->fetchColumn();
if ($steps === false) {
$this->log("No migrations to rollback.");
return;
}
}
$stmt = $this->db->query("SELECT migration FROM migrations ORDER BY id DESC LIMIT $steps");
$migrations = $stmt->fetchAll(PDO::FETCH_COLUMN);
foreach ($migrations as $filename) {
$file = static::MIGRATIONS_PATH . $filename;
if (file_exists($file)) {
$migration = require $file;
if ($migration instanceof LibsMigration) {
$this->log('Rolling back migration: %s', $filename);
$migration->down();
}
}
$this->db->prepare("DELETE FROM migrations WHERE migration = ?")->execute([$filename]);
}
$this->log('Rollback completed successfully.');
}
/**
* Creates a new migration file.
*
* @param string $name
*
* @return void
*/
public function create(string $name = 'migration'): void
{
$filename = date('Y_m_d_His') . '_' . $name . '.php';
$filepath = static::MIGRATIONS_PATH . $filename;
$template = <<<'TEMPLATE'
<?php
use Libs\Migration;
return new class () extends Migration {
#[Override]
public function up(): void
{
}
#[Override]
public function down(): void
{
}
};
TEMPLATE;
file_put_contents($filepath, $template);
$this->log('%s migration created.', $filename);
}
/**
* Refreshes the database by deleting the database and running all migrations.
*
* @return void
*/
public function fresh(): void
{
$this->dropAllTables();
$this->migrate();
$this->log("Database refreshed successfully.");
}
/**
* Drops all tables from the main database and clears the migrations control table.
*
* @return void
* @throws \Exception If the database driver is not supported.
*/
private function dropAllTables(): void
{
if (DB_TYPE == 'sqlite') {
$mainDatabase = Database::getInstance(
type: DB_TYPE,
name: DB_NAME
);
} else {
$mainDatabase = Database::getInstance(
DB_TYPE,
DB_HOST,
DB_NAME,
DB_USER,
DB_PASS
);
}
$driver = $mainDatabase->getAttribute(PDO::ATTR_DRIVER_NAME);
// Query to get all tables based on the driver
$query = match ($driver) {
'mysql' => "SHOW TABLES",
'pgsql' => "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname != 'pg_catalog' AND schemaname != 'information_schema'",
'sqlite' => "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
default => throw new \Exception("Driver $driver not supported for fresh command"),
};
$stmt = $mainDatabase->query($query);
$tables = $stmt->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
// Disable foreign key checks to avoid constraint errors
if ($driver === 'mysql') {
$mainDatabase->exec("SET FOREIGN_KEY_CHECKS = 0");
}
if ($driver === 'pgsql') {
$mainDatabase->exec("SET session_replication_role = 'replica'");
}
$quotedTable = match ($driver) {
'mysql' => "`$table`",
'pgsql', 'sqlite' => "\"$table\"",
default => $table,
};
$cascade = $driver === 'sqlite' ? '' : ' CASCADE';
$mainDatabase->exec("DROP TABLE IF EXISTS $quotedTable$cascade");
}
if ($driver === 'mysql') {
$mainDatabase->exec("SET FOREIGN_KEY_CHECKS = 1");
}
if ($driver === 'pgsql') {
$mainDatabase->exec("SET session_replication_role = 'origin'");
}
// Clear the migration control table in the SQLite DB
$this->db->exec("DELETE FROM migrations");
$this->log("Database schema cleared. Ready for new migrations.");
}
private function log(string $msg, mixed ...$args): void
{
if ($this->verbose) {
printf("$msg\n", ...$args);
}
}
}
@@ -0,0 +1,13 @@
<?php
namespace Interfaces;
interface CommandInterface
{
/**
* Corre el comando CLI
*
* @return void
*/
public function run(): void;
}
+986
View File
@@ -0,0 +1,986 @@
<?php
namespace Libs;
use PDO;
/**
* Migration - DuckBrain
*
* Migration base class.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
abstract class Migration
{
public PDO $database;
public function __construct()
{
if (DB_TYPE == 'sqlite') {
$this->database = Database::getInstance(
type: DB_TYPE,
name: DB_NAME
);
} else {
$this->database = Database::getInstance(
DB_TYPE,
DB_HOST,
DB_NAME,
DB_USER,
DB_PASS
);
}
}
/**
* Run the migration.
*/
public function up(): void
{
}
/**
* Reverse the migration.
*/
public function down(): void
{
}
/**
* Create a table.
*
* @param string $dbTableName The name of the table to create
* @param array<string> $colums The columns to create
*
* @return void
*/
public function create(string $dbTableName, array $colums = []): void
{
$columns = implode(', ', $colums);
$sql = "CREATE TABLE IF NOT EXISTS " . $this->quoteIdentifier($dbTableName) . " ($columns)";
$this->database->exec($sql);
}
/**
* Drop a table.
*
* @param string $tableName The name of the table to drop
*
* @return void
*/
public function dropTable(string $tableName): void
{
$this->database->exec("DROP TABLE IF EXISTS " . $this->quoteIdentifier($tableName));
}
/**
* Add a column to an existing table.
*
* @param string $tableName The name of the table
* @param string $columnDefinition The column definition (output of string(), integer(), etc.)
*
* @return void
*/
public function addColumn(string $tableName, string $columnDefinition): void
{
$this->database->exec("ALTER TABLE " . $this->quoteIdentifier($tableName) . " ADD COLUMN $columnDefinition");
}
/**
* Add multiple columns to an existing table.
*
* @param string $tableName The name of the table
* @param array<string> $columns The column definitions (output of string(), integer(), etc.)
*
* @return void
*/
public function addColumns(string $tableName, array $columns = []): void
{
foreach ($columns as $column) {
$this->addColumn($tableName, $column);
}
}
/**
* Drop a column from a table.
*
* @param string $tableName The name of the table
* @param string $columnName The name of the column to drop
*
* @return void
*/
public function dropColumn(string $tableName, string $columnName): void
{
$this->database->exec("ALTER TABLE " . $this->quoteIdentifier($tableName) . " DROP COLUMN " . $this->quoteIdentifier($columnName));
}
/**
* Drop multiple columns from a table.
*
* @param string $tableName The name of the table
* @param string[] $columnNames The names of the columns to drop
*
* @return void
*/
public function dropColumns(string $tableName, array $columnNames): void
{
foreach ($columnNames as $columnName) {
$this->dropColumn($tableName, $columnName);
}
}
/**
* Rename a table.
*
* @param string $from The current name of the table
* @param string $to The new name of the table
*
* @return void
*/
public function renameTable(string $from, string $to): void
{
$this->database->exec("ALTER TABLE " . $this->quoteIdentifier($from) . " RENAME TO " . $this->quoteIdentifier($to));
}
/**
* Rename a column.
*
* @param string $tableName The name of the table
* @param string $from The current name of the column
* @param string $to The new name of the column
*
* @return void
*/
public function renameColumn(string $tableName, string $from, string $to): void
{
$this->database->exec("ALTER TABLE " . $this->quoteIdentifier($tableName) . " RENAME COLUMN " . $this->quoteIdentifier($from) . " TO " . $this->quoteIdentifier($to));
}
/**
* Modify a column definition.
*
* @param string $tableName The name of the table
* @param string $columnDefinition The new column definition (output of string(), integer(), etc.)
*
* @return void
*/
public function modifyColumn(string $tableName, string $columnDefinition): void
{
$sql = match (DB_TYPE) {
'mysql' => "ALTER TABLE " . $this->quoteIdentifier($tableName) . " MODIFY COLUMN $columnDefinition",
'pgsql' => $this->pgModifyColumn($tableName, $columnDefinition),
default => throw new \RuntimeException('modifyColumn is not supported on SQLite'),
};
$this->database->exec($sql);
}
/**
* Helper method to handle PostgreSQL column modification.
*
* @param string $tableName The name of the table
* @param string $columnDefinition The new column definition
*
* @return string
*/
private function pgModifyColumn(string $tableName, string $columnDefinition): string
{
preg_match('/^[`"](\w+)[`"]\s+(.+)$/', $columnDefinition, $m);
$column = $m[1] ?? '';
$rest = $m[2] ?? '';
$type = preg_replace('/\s+(NOT\s+NULL|DEFAULT\s+.+)$/i', '', $rest);
$sql = "ALTER TABLE \"$tableName\" ALTER COLUMN \"$column\" TYPE $type USING \"$column\"::$type";
if (preg_match('/NOT\s+NULL/i', $rest)) {
$sql .= "; ALTER TABLE \"$tableName\" ALTER COLUMN \"$column\" SET NOT NULL";
}
if (preg_match('/DEFAULT\s+(.+)$/i', $rest, $d)) {
$sql .= "; ALTER TABLE \"$tableName\" ALTER COLUMN \"$column\" SET DEFAULT {$d[1]}";
}
return $sql;
}
/**
* Empty a table.
*
* @param string $tableName The name of the table to truncate
*
* @return void
*/
public function truncate(string $tableName): void
{
if (DB_TYPE === 'sqlite') {
$this->database->exec("DELETE FROM " . $this->quoteIdentifier($tableName));
} else {
$this->database->exec("TRUNCATE TABLE " . $this->quoteIdentifier($tableName));
}
}
/**
* Check if a table exists.
*
* @param string $tableName The name of the table
*
* @return bool
*/
public function hasTable(string $tableName): bool
{
$sql = match (DB_TYPE) {
'mysql' => "SHOW TABLES LIKE '$tableName'",
'pgsql' => "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = '$tableName'",
default => "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$tableName'",
};
return (bool) $this->database->query($sql)->fetchColumn();
}
/**
* Check if a column exists in a table.
*
* @param string $tableName The name of the table
* @param string $columnName The name of the column
*
* @return bool
*/
public function hasColumn(string $tableName, string $columnName): bool
{
if (DB_TYPE === 'sqlite') {
$stmt = $this->database->query("PRAGMA table_info('$tableName')");
$columns = $stmt->fetchAll(PDO::FETCH_ASSOC);
return in_array($columnName, array_column($columns, 'name'));
}
// phpcs:disable Generic.Files.LineLength.TooLong
$sql = match (DB_TYPE) {
'mysql' => "SHOW COLUMNS FROM " . $this->quoteIdentifier($tableName) . " LIKE '$columnName'",
'pgsql' => "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = '$tableName' AND column_name = '$columnName'",
};
// phpcs:disable Generic.Files.LineLength.TooLong
return (bool) $this->database->query($sql)->fetchColumn();
}
/**
* Add an index to a table.
*
* @param string $tableName The name of the table
* @param string|array<string> $columns The column(s) to index
* @param string|null $name The index name (auto-generated if null)
* @param bool $unique Whether the index is unique
*
* @return void
*/
public function addIndex(
string $tableName,
string|array $columns,
?string $name = null,
bool $unique = false
): void {
$columns = (array) $columns;
$colList = implode(', ', array_map([$this, 'qi'], $columns));
$name ??= 'idx_' . $tableName . '_' . implode('_', $columns);
$unique = $unique ? 'UNIQUE' : '';
$this->database->exec("CREATE $unique INDEX IF NOT EXISTS " . $this->quoteIdentifier($name) . " ON " . $this->quoteIdentifier($tableName) . " ($colList)");
}
/**
* Add a unique index to a table.
*
* @param string $tableName The name of the table
* @param string|array<string> $columns The column(s) to index
* @param string|null $name The index name (auto-generated if null)
*
* @return void
*/
public function addUnique(
string $tableName,
string|array $columns,
?string $name = null
): void {
$this->addIndex($tableName, $columns, $name, unique: true);
}
/**
* Drop an index.
*
* @param string $tableName The name of the table
* @param string $name The index name
*
* @return void
*/
public function dropIndex(string $tableName, string $name): void
{
if (DB_TYPE === 'mysql') {
$this->database->exec("DROP INDEX IF EXISTS " . $this->quoteIdentifier($name) . " ON " . $this->quoteIdentifier($tableName));
} else {
$this->database->exec("DROP INDEX IF EXISTS " . $this->quoteIdentifier($name));
}
}
/**
* Add a foreign key constraint.
*
* @param string $tableName The name of the table
* @param string $column The column that references another table
* @param string $refTable The referenced table
* @param string $refColumn The referenced column
* @param string|null $name The constraint name (auto-generated if null)
*
* @return void
*/
public function addForeign(
string $tableName,
string $column,
string $refTable,
string $refColumn,
?string $name = null
): void {
if (DB_TYPE === 'sqlite') {
//throw new \RuntimeException('addForeign is not supported on SQLite');
return;
}
$name ??= "fk_{$tableName}_{$column}";
$sql = "ALTER TABLE " . $this->quoteIdentifier($tableName) . " ADD CONSTRAINT " . $this->quoteIdentifier($name) . " FOREIGN KEY (" . $this->quoteIdentifier($column) . ") REFERENCES " . $this->quoteIdentifier($refTable) . " (" . $this->quoteIdentifier($refColumn) . ")";
$this->database->exec($sql);
}
/**
* Drop a foreign key constraint.
*
* @param string $tableName The name of the table
* @param string $name The constraint name
*
* @return void
*/
public function dropForeign(string $tableName, string $name): void
{
if (DB_TYPE === 'sqlite') {
throw new \RuntimeException('dropForeign is not supported on SQLite');
}
$clause = match (DB_TYPE) {
'mysql' => "DROP FOREIGN KEY " . $this->quoteIdentifier($name),
'pgsql' => "DROP CONSTRAINT " . $this->quoteIdentifier($name),
default => "DROP CONSTRAINT " . $this->quoteIdentifier($name),
};
$this->database->exec("ALTER TABLE " . $this->quoteIdentifier($tableName) . " $clause");
}
/**
* Create a string column (VARCHAR).
*
* @param string $name The name of the column
* @param int $chars The maximum length of the string
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function string(
string $name,
int $chars = 255,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'sqlite' => 'TEXT',
default => "VARCHAR($chars)",
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a char column.
*
* @param string $name The name of the column
* @param int $chars The fixed length
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function char(
string $name,
int $chars = 255,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'sqlite' => 'TEXT',
default => "CHAR($chars)",
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a tiny integer column.
*
* @param string $name The name of the column
* @param int|null $default The default value
* @param bool $notNull Whether the column is not null
* @param bool $unsigned Whether the column is unsigned (MySQL only)
*
* @return string
*/
public function tinyInteger(
string $name,
?int $default = null,
bool $notNull = true,
bool $unsigned = false
): string {
$type = 'TINYINT';
if (DB_TYPE === 'mysql' && $unsigned) {
$type .= ' UNSIGNED';
}
$type = match (DB_TYPE) {
'pgsql' => 'SMALLINT',
'sqlite' => 'INTEGER',
default => $type,
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a small integer column.
*
* @param string $name The name of the column
* @param int|null $default The default value
* @param bool $notNull Whether the column is not null
* @param bool $unsigned Whether the column is unsigned (MySQL only)
*
* @return string
*/
public function smallInteger(
string $name,
?int $default = null,
bool $notNull = true,
bool $unsigned = false
): string {
$type = 'SMALLINT';
if (DB_TYPE === 'mysql' && $unsigned) {
$type .= ' UNSIGNED';
}
$type = match (DB_TYPE) {
'sqlite' => 'INTEGER',
default => $type,
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a medium integer column.
*
* @param string $name The name of the column
* @param int|null $default The default value
* @param bool $notNull Whether the column is not null
* @param bool $unsigned Whether the column is unsigned (MySQL only)
*
* @return string
*/
public function mediumInteger(
string $name,
?int $default = null,
bool $notNull = true,
bool $unsigned = false
): string {
$type = 'MEDIUMINT';
if (DB_TYPE === 'mysql' && $unsigned) {
$type .= ' UNSIGNED';
}
$type = match (DB_TYPE) {
'pgsql' => 'INTEGER',
'sqlite' => 'INTEGER',
default => $type,
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create an integer column.
*
* @param string $name The name of the column
* @param int|null $default The default value
* @param bool $notNull Whether the column is not null
* @param bool $unsigned Whether the column is unsigned (MySQL only)
*
* @return string
*/
public function integer(
string $name,
?int $default = null,
bool $notNull = true,
bool $unsigned = false
): string {
$type = 'INT';
if (DB_TYPE === 'mysql' && $unsigned) {
$type .= ' UNSIGNED';
}
$type = match (DB_TYPE) {
'pgsql' => 'INTEGER',
'sqlite' => 'INTEGER',
default => $type,
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a big integer column.
*
* @param string $name The name of the column
* @param int|null $default The default value
* @param bool $notNull Whether the column is not null
* @param bool $unsigned Whether the column is unsigned (MySQL only)
*
* @return string
*/
public function bigInteger(
string $name,
?int $default = null,
bool $notNull = true,
bool $unsigned = false
): string {
$type = 'BIGINT';
if (DB_TYPE === 'mysql' && $unsigned) {
$type .= ' UNSIGNED';
}
$type = match (DB_TYPE) {
'sqlite' => 'INTEGER',
default => $type,
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a float column.
*
* @param string $name The name of the column
* @param int $precision The total number of digits
* @param int $scale The number of digits after the decimal point
* @param float|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function float(
string $name,
int $precision = 10,
int $scale = 2,
?float $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => "FLOAT($precision, $scale)",
'pgsql' => 'REAL',
default => 'REAL',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a double column.
*
* @param string $name The name of the column
* @param int $precision The total number of digits
* @param int $scale The number of digits after the decimal point
* @param float|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function double(
string $name,
int $precision = 10,
int $scale = 2,
?float $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => "DOUBLE($precision, $scale)",
'pgsql' => 'DOUBLE PRECISION',
default => 'REAL',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a decimal column.
*
* @param string $name The name of the column
* @param int $precision The total number of digits
* @param int $scale The number of digits after the decimal point
* @param float|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function decimal(
string $name,
int $precision = 10,
int $scale = 2,
?float $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => "DECIMAL($precision, $scale)",
'pgsql' => "NUMERIC($precision, $scale)",
default => 'NUMERIC',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a boolean column.
*
* @param string $name The name of the column
* @param bool|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function boolean(
string $name,
?bool $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => 'TINYINT(1)',
'pgsql' => 'BOOLEAN',
default => 'INTEGER',
};
$sql = $this->quoteIdentifier($name) . " $type";
$defaultSql = null;
if ($default !== null) {
$defaultSql = match (DB_TYPE) {
'pgsql' => $default ? "'t'" : "'f'",
default => $default ? '1' : '0',
};
}
return $this->suffix($sql, $notNull, $defaultSql);
}
/**
* Create a date column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function date(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'sqlite' => 'TEXT',
default => 'DATE',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a time column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function time(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'sqlite' => 'TEXT',
default => 'TIME',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a datetime column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function dateTime(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'pgsql' => 'TIMESTAMP',
'sqlite' => 'TEXT',
default => 'DATETIME',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a timestamp column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function timestamp(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'pgsql' => 'TIMESTAMPTZ',
'sqlite' => 'TEXT',
default => 'TIMESTAMP',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a text column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function text(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'sqlite' => 'TEXT',
default => 'TEXT',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a medium text column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function mediumText(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => 'MEDIUMTEXT',
default => 'TEXT',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a long text column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function longText(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => 'LONGTEXT',
default => 'TEXT',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a JSON column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function json(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => 'JSON',
'pgsql' => 'JSONB',
default => 'TEXT',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a binary column.
*
* @param string $name The name of the column
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function binary(
string $name,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => 'BLOB',
'pgsql' => 'BYTEA',
default => 'BLOB',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, null);
}
/**
* Create an enum column.
*
* @param string $name The name of the column
* @param array<string> $values The allowed values
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function enum(
string $name,
array $values,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => "ENUM('" . implode("', '", $values) . "')",
default => "VARCHAR(255)",
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Make a column a primary key (auto-increment for integer types).
*
* @param string $columnDefinition The column definition (output of bigInteger(), uuid(), etc.)
*
* @return string
*/
public function primary(string $columnDefinition): string
{
preg_match('/^[`"](\w+)[`"]\s+(\w+)/', $columnDefinition, $m);
$name = $m[1];
$type = strtoupper($m[2]);
$isInteger = in_array($type, ['TINYINT', 'SMALLINT', 'MEDIUMINT', 'INT', 'INTEGER', 'BIGINT']);
if ($isInteger) {
return match (DB_TYPE) {
'pgsql' => match ($type) {
'SMALLINT' => $this->quoteIdentifier($name) . " SMALLSERIAL PRIMARY KEY",
'INT', 'INTEGER' => $this->quoteIdentifier($name) . " SERIAL PRIMARY KEY",
default => $this->quoteIdentifier($name) . " BIGSERIAL PRIMARY KEY",
},
'sqlite' => $this->quoteIdentifier($name) . " INTEGER PRIMARY KEY AUTOINCREMENT",
default => $this->quoteIdentifier($name) . " {$type} UNSIGNED AUTO_INCREMENT PRIMARY KEY",
};
}
$clean = preg_replace('/\s+(NOT\s+NULL)?(\s+DEFAULT\s+\S+)?\s*$/', '', $columnDefinition);
return "$clean PRIMARY KEY";
}
/**
* Create a UUID column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function uuid(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'pgsql' => 'UUID',
'sqlite' => 'TEXT',
default => 'CHAR(36)',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Quote identifier by database type.
*
* @param string $value
*
* @return string
*/
private function quoteIdentifier(string $value): string
{
return match (DB_TYPE) {
'pgsql' => "\"$value\"",
default => "`$value`",
};
}
/**
* Build the column suffix (NOT NULL, DEFAULT).
*
* @param string $sql The column definition without suffix
* @param bool $notNull Whether the column is not null
* @param string|null $default The default value clause (already formatted), or null for no default
*
* @return string
*/
private function suffix(
string $sql,
bool $notNull,
?string $default = null
): string {
if ($notNull) {
$sql .= ' NOT NULL';
}
if ($default !== null) {
$sql .= " DEFAULT $default";
}
return $sql;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Toolbox;
/**
* Catálogo de paquetes descubiertos en un directorio `packages/`.
*/
final class Catalog
{
/** @var array<string, Manifest>|null */
private ?array $cache = null;
public function __construct(private readonly string $packagesDir)
{
}
public static function fromRoot(string $root): self
{
return new self(rtrim($root, '/') . '/packages');
}
/** @return array<string, Manifest> */
public function packages(): array
{
if ($this->cache !== null) {
return $this->cache;
}
$packages = [];
foreach (glob($this->packagesDir . '/*/duckbrain.json') ?: [] as $manifestPath) {
$manifest = Manifest::fromFile($manifestPath);
$packages[$manifest->name] = $manifest;
}
ksort($packages);
return $this->cache = $packages;
}
public function has(string $name): bool
{
return isset($this->packages()[$name]);
}
public function get(string $name): Manifest
{
if (!$this->has($name)) {
throw new ToolboxException("Paquete desconocido en el catálogo: '{$name}'");
}
return $this->packages()[$name];
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Toolbox;
use Toolbox\Commands\AddCommand;
use Toolbox\Commands\AdoptCommand;
use Toolbox\Commands\CatalogCommand;
use Toolbox\Commands\DoctorCommand;
use Toolbox\Commands\InstallCommand;
use Toolbox\Commands\ListCommand;
use Toolbox\Commands\RemoveCommand;
use Toolbox\Commands\SelfUpdateCommand;
use Toolbox\Commands\UpdateCommand;
final class Cli
{
/** @var array<string, class-string> */
private const COMMANDS = [
'install' => InstallCommand::class,
'add' => AddCommand::class,
'remove' => RemoveCommand::class,
'update' => UpdateCommand::class,
'list' => ListCommand::class,
'doctor' => DoctorCommand::class,
'catalog' => CatalogCommand::class,
'adopt' => AdoptCommand::class,
'self-update' => SelfUpdateCommand::class,
];
public function __construct(private ?Console $console = null)
{
$this->console ??= new Console();
}
/**
* @param list<string> $argv
*/
public function run(array $argv): int
{
$args = array_slice($argv, 1);
$command = $args[0] ?? null;
if ($command === null || in_array($command, ['help', '--help', '-h'], true)) {
$this->printHelp();
return 0;
}
if (in_array($command, ['--version', '-V'], true)) {
$this->console->out('duckbrain-toolbox ' . Config::VERSION);
return 0;
}
if (!isset(self::COMMANDS[$command])) {
$this->console->error("Comando desconocido: {$command}");
$this->printHelp();
return 1;
}
$class = self::COMMANDS[$command];
if (!class_exists($class)) {
$this->console->error("El comando '{$command}' no está disponible en esta instalación.");
return 1;
}
return (new $class($this->console))->run(array_slice($args, 1));
}
public function printHelp(): void
{
$this->console->out('duckbrain-toolbox ' . Config::VERSION);
$this->console->out('');
$this->console->out('Uso: duckbrain <comando> [opciones]');
$this->console->out('');
$this->console->out('Comandos:');
foreach (self::COMMANDS as $name => $class) {
if (!class_exists($class)) {
continue;
}
$description = method_exists($class, 'describe') ? $class::describe() : '';
$this->console->out(sprintf(' %-9s %s', $name, $description));
}
}
}
@@ -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;
}
}
@@ -0,0 +1,77 @@
<?php
namespace Toolbox;
/**
* Modelo de compatibilidad estilo WordPress: un rango mínimo requerido y uno
* máximo probado contra el core, más el requisito de PHP. Sólo produce avisos.
*/
final class Compatibility
{
public const OK = 'ok';
public const UNTESTED = 'untested';
public const INSUFFICIENT = 'insufficient';
public const UNKNOWN = 'unknown';
/**
* @return array{status:string, message:string}
*/
public static function evaluateCore(
?string $coreVersion,
?string $requires,
?string $tested
): array {
if ($coreVersion === null) {
return ['status' => self::UNKNOWN, 'message' => 'versión del core desconocida (sin tags)'];
}
if ($requires === null && $tested === null) {
return ['status' => self::UNKNOWN, 'message' => 'sin datos de compatibilidad'];
}
if ($requires !== null && !Semver::satisfies($coreVersion, $requires)) {
return [
'status' => self::INSUFFICIENT,
'message' => "requiere core {$requires} (tienes {$coreVersion})",
];
}
if ($tested !== null && Semver::compare($coreVersion, $tested) > 0) {
return [
'status' => self::UNTESTED,
'message' => "probado hasta {$tested} (tienes {$coreVersion})",
];
}
return ['status' => self::OK, 'message' => 'compatible'];
}
/**
* @return array{status:string, message:string}
*/
public static function evaluatePhp(?string $requires, string $phpVersion): array
{
if ($requires === null) {
return ['status' => self::UNKNOWN, 'message' => 'sin datos de PHP'];
}
if (!Semver::satisfies($phpVersion, $requires)) {
return [
'status' => self::INSUFFICIENT,
'message' => "requiere PHP {$requires} (tienes {$phpVersion})",
];
}
return ['status' => self::OK, 'message' => 'compatible'];
}
public static function label(string $status): string
{
return match ($status) {
self::OK => 'OK',
self::UNTESTED => 'NO PROBADO',
self::INSUFFICIENT => 'INSUFICIENTE',
default => 'SIN DATOS',
};
}
}
@@ -0,0 +1,64 @@
<?php
namespace Toolbox;
final class Component
{
/**
* @param list<string> $files
* @param array<string, string> $requires
* @param array<string, string>|null $destinations
*/
public function __construct(
public readonly string $name,
public readonly array $files,
public readonly array $requires = [],
public readonly ?string $coreRequires = null,
public readonly ?string $coreTested = null,
public readonly ?array $destinations = null,
public readonly bool $override = false,
) {
}
public static function fromArray(string $name, array $data): self
{
if (!isset($data['files']) || !is_array($data['files']) || $data['files'] === []) {
throw new ToolboxException("El componente '{$name}' debe declarar 'files' no vacío");
}
foreach ($data['files'] as $file) {
if (!is_string($file) || $file === '') {
throw new ToolboxException("El componente '{$name}' tiene un archivo inválido");
}
}
$requires = [];
foreach (($data['require'] ?? []) as $id => $constraint) {
if (!is_string($id) || !is_string($constraint)) {
throw new ToolboxException("El componente '{$name}' tiene un 'require' inválido");
}
$requires[$id] = $constraint;
}
$destinations = null;
if (isset($data['destinations'])) {
if (!is_array($data['destinations'])) {
throw new ToolboxException("El componente '{$name}' tiene 'destinations' inválido");
}
$destinations = [];
foreach ($data['destinations'] as $from => $to) {
$destinations[$from] = $to;
}
}
return new self(
name: $name,
files: array_values($data['files']),
requires: $requires,
coreRequires: $data['core']['requires'] ?? null,
coreTested: $data['core']['tested'] ?? null,
destinations: $destinations,
override: (bool) ($data['override'] ?? false),
);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Toolbox;
/**
* Constantes y ubicaciones del toolbox. Las URLs de origen van embebidas y sólo
* pueden sobreescribirse mediante variables de entorno, pensadas para pruebas.
*/
final class Config
{
public const VERSION = '0.1.0';
public static function toolboxUrl(): string
{
return self::env('DUCKBRAIN_TOOLBOX_URL')
?: 'git@git.kj2.me:kj/duckbrain-toolbox.git';
}
public static function coreUrl(): string
{
return self::env('DUCKBRAIN_CORE_URL')
?: 'git@git.kj2.me:kj/duckbrain.git';
}
public static function updateUrl(): string
{
return self::env('DUCKBRAIN_UPDATE_URL')
?: 'https://git.kj2.me/kj/duckbrain-toolbox/releases/download/latest';
}
public static function binPath(): string
{
$running = \Phar::running(false);
if ($running !== '') {
return $running;
}
$script = $_SERVER['argv'][0] ?? '';
return $script === '' ? '' : (realpath($script) ?: $script);
}
public static function cacheDir(): string
{
$override = self::env('DUCKBRAIN_CACHE_DIR');
if ($override !== '') {
return rtrim($override, '/') . '/toolbox';
}
$home = self::env('HOME') ?: sys_get_temp_dir();
$xdg = self::env('XDG_CACHE_HOME') ?: $home . '/.cache';
return rtrim($xdg, '/') . '/duckbrain/toolbox';
}
private static function env(string $name): string
{
$value = getenv($name);
return $value === false ? '' : $value;
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace Toolbox;
final class Console
{
/** @var resource */
private $outStream;
/** @var resource */
private $errStream;
/**
* @param resource|null $out
* @param resource|null $err
*/
public function __construct($out = null, $err = null)
{
$this->outStream = $out ?? STDOUT;
$this->errStream = $err ?? STDERR;
}
public function out(string $message = ''): void
{
fwrite($this->outStream, $message . PHP_EOL);
}
public function warn(string $message): void
{
fwrite($this->errStream, 'WARN: ' . $message . PHP_EOL);
}
public function error(string $message): void
{
fwrite($this->errStream, 'ERROR: ' . $message . PHP_EOL);
}
public function ok(string $message): void
{
fwrite($this->outStream, ' OK: ' . $message . PHP_EOL);
}
public function confirm(string $question, bool $assumeYes = false): bool
{
if ($assumeYes) {
return true;
}
fwrite($this->outStream, $question . ' [y/N] ');
$answer = trim((string) fgets(STDIN));
return in_array(strtolower($answer), ['y', 'yes', 's', 'si', 'sí'], true);
}
/**
* @param array<int, array<int, string>> $rows
*/
public function table(array $rows): void
{
if ($rows === []) {
return;
}
$widths = [];
foreach ($rows as $row) {
foreach ($row as $i => $cell) {
$widths[$i] = max($widths[$i] ?? 0, strlen($cell));
}
}
foreach ($rows as $row) {
$cells = [];
foreach ($row as $i => $cell) {
$cells[] = str_pad($cell, $widths[$i]);
}
$this->out(' ' . rtrim(implode(' ', $cells)));
}
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace Toolbox;
/**
* Copia (o enlaza) los archivos de un componente al proyecto respetando la
* regla de destino, el modo y el drift.
*/
final class Copier
{
public function __construct(private readonly FileSync $sync = new FileSync())
{
}
/**
* @param list<string> $files rutas relativas al paquete
* @param array<string, string> $expectedFiles rel -> hash previo (lock)
* @param array<string, string>|null $destinations
* @param array{force?:bool, backup?:bool, keepLocal?:bool, dryRun?:bool} $options
* @return array{files: array<string,string>, actions: array<string,string>, aborted: list<string>}
*/
public function install(
array $files,
string $sourceDir,
string $rootDir,
string $rootCore,
?array $destinations,
string $mode,
array $options = [],
array $expectedFiles = []
): array {
$installed = [];
$actions = [];
$aborted = [];
foreach ($files as $file) {
$relative = Destination::relative($file, $rootDir, $rootCore, $destinations);
$source = rtrim($sourceDir, '/') . '/' . ltrim($file, '/');
$target = rtrim($rootDir, '/') . '/' . $relative;
$expected = $expectedFiles[$relative] ?? null;
if (($options['dryRun'] ?? false)) {
$actions[$relative] = 'planned';
continue;
}
if ($mode === 'link') {
$action = $this->link($source, $target, $options);
} else {
$contents = file_get_contents($source);
if ($contents === false) {
throw new ToolboxException("No se pudo leer el archivo de origen: {$source}");
}
$action = $this->sync->write($target, $contents, $expected, $options);
}
$actions[$relative] = $action;
if ($action === FileSync::ABORTED) {
$aborted[] = $relative;
continue;
}
$installed[$relative] = is_file($source) ? Drift::hash($source) : '';
}
return ['files' => $installed, 'actions' => $actions, 'aborted' => $aborted];
}
/**
* @param array{force?:bool, keepLocal?:bool} $options
*/
private function link(string $source, string $target, array $options): string
{
if (is_link($target)) {
if (readlink($target) === $source) {
return FileSync::WRITTEN;
}
unlink($target);
} elseif (is_file($target)) {
if ($options['keepLocal'] ?? false) {
return FileSync::SKIPPED;
}
if (!($options['force'] ?? false)) {
return FileSync::ABORTED;
}
unlink($target);
}
if (!is_dir(dirname($target))) {
mkdir(dirname($target), 0777, true);
}
if (!symlink($source, $target)) {
throw new ToolboxException("No se pudo enlazar {$target} -> {$source}");
}
return FileSync::WRITTEN;
}
}
@@ -0,0 +1,187 @@
<?php
namespace Toolbox;
/**
* Provee los archivos del core. La versión se resuelve por tags semver
* (`vX.Y.Z`); no hay archivo VERSION. Si no hay tags, la versión es desconocida
* y se trabaja sobre la rama por defecto, fijando el commit en el lock.
*/
final class CoreProvider
{
private function __construct(private readonly string $dir)
{
}
public static function fromDirectory(string $dir): self
{
if (!is_dir($dir)) {
throw new ToolboxException("El directorio del core no existe: {$dir}");
}
return new self(rtrim($dir, '/'));
}
public static function fromUrl(string $url, string $cacheDir, Console $console): self
{
$dir = rtrim($cacheDir, '/') . '/core';
if (!is_dir($dir . '/.git')) {
if (!is_dir(dirname($dir))) {
mkdir(dirname($dir), 0777, true);
}
$console->out(' clonando core desde ' . $url);
exec('git clone -q --tags ' . escapeshellarg($url) . ' ' . escapeshellarg($dir) . ' 2>&1', $out, $code);
if ($code !== 0) {
throw new ToolboxException('No se pudo clonar el core: ' . implode("\n", $out));
}
} else {
exec('git -C ' . escapeshellarg($dir) . ' fetch -q --tags 2>&1');
}
$provider = new self($dir);
$version = $provider->version();
// Tags opcionales: si hay, se usa la release más alta; si no, la rama por defecto.
if ($version !== null) {
exec('git -C ' . escapeshellarg($dir) . ' checkout -q ' . escapeshellarg('v' . $version) . ' 2>&1');
} else {
exec('git -C ' . escapeshellarg($dir) . ' checkout -q 2>&1');
exec('git -C ' . escapeshellarg($dir) . ' pull -q --ff-only 2>/dev/null');
}
return $provider;
}
public function dir(): string
{
return $this->dir;
}
public function isGit(): bool
{
return is_dir($this->dir . '/.git');
}
/**
* Versión más alta entre los tags `vX.Y.Z`, o null si no hay tags.
*/
public function version(): ?string
{
if (!$this->isGit()) {
return null;
}
exec('git -C ' . escapeshellarg($this->dir) . ' tag -l ' . escapeshellarg('v*') . ' 2>/dev/null', $tags, $code);
if ($code !== 0) {
return null;
}
$best = null;
foreach ($tags as $tag) {
$candidate = ltrim(trim($tag), 'vV');
try {
Semver::parse($candidate);
} catch (ToolboxException) {
continue;
}
if ($best === null || Semver::compare($candidate, $best) > 0) {
$best = $candidate;
}
}
return $best;
}
public function commit(): ?string
{
if (!$this->isGit()) {
return null;
}
exec('git -C ' . escapeshellarg($this->dir) . ' rev-parse HEAD 2>/dev/null', $out, $code);
$commit = trim($out[0] ?? '');
return $code === 0 && $commit !== '' ? $commit : null;
}
/**
* Prefijos/exactos que no forman parte del core distribuible: herramientas
* del repositorio, control de versiones y documentación. `.htaccess` NO se
* excluye (sí es del core).
*/
private const EXCLUDED = [
'.git/', '.github/', '.opencode/', '.openspec/', '.idea/', '.vscode/',
'node_modules/', 'vendor/', 'dist/',
'.gitignore', '.gitattributes', '.editorconfig', '.DS_Store', 'Thumbs.db',
'readme.org', 'README.org', 'readme.md', 'README.md', 'readme.txt',
];
/**
* Archivos del core distribuible. Si la fuente es un repositorio git se
* parte de sus archivos RASTREADOS (`git ls-files`, los mismos del remoto);
* si no, se recorre el directorio. En ambos casos se filtra la lista negra
* (herramientas y documentación).
*
* @return list<string>
*/
public function files(): array
{
$files = $this->isGit() ? $this->trackedFiles() : $this->walkFiles();
$files = array_values(array_filter($files, fn (string $file) => !$this->isExcluded($file)));
sort($files);
return $files;
}
/**
* @return list<string>
*/
private function trackedFiles(): array
{
exec('git -C ' . escapeshellarg($this->dir) . ' ls-files 2>/dev/null', $out, $code);
if ($code !== 0) {
return $this->walkFiles();
}
return array_values(array_filter($out, fn (string $file) => $file !== ''));
}
/**
* @return list<string>
*/
private function walkFiles(): array
{
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $item) {
if ($item->isDir()) {
continue;
}
$files[] = substr($item->getPathname(), strlen($this->dir) + 1);
}
return $files;
}
private function isExcluded(string $relative): bool
{
foreach (self::EXCLUDED as $excluded) {
if ($relative === $excluded || str_starts_with($relative, $excluded)) {
return true;
}
}
return false;
}
public function read(string $relative): string
{
$path = $this->dir . '/' . ltrim($relative, '/');
return (string) file_get_contents($path);
}
}
@@ -0,0 +1,43 @@
<?php
namespace Toolbox;
/**
* Regla de destino: `src/**` -> ROOT_CORE (sin el prefijo `src/`); el resto ->
* ROOT_DIR. Admite un mapa explícito por componente.
*/
final class Destination
{
public static function resolve(
string $source,
string $rootDir,
string $rootCore,
?array $explicit = null
): string {
$target = self::relative($source, $rootDir, $rootCore, $explicit);
return rtrim($rootDir, '/') . '/' . $target;
}
public static function relative(
string $source,
string $rootDir,
string $rootCore,
?array $explicit = null
): string {
if ($explicit !== null && isset($explicit[$source])) {
return ltrim($explicit[$source], '/');
}
$source = ltrim($source, '/');
if (str_starts_with($source, 'src/')) {
$inCore = substr($source, 4);
$prefix = rtrim(str_replace(rtrim($rootDir, '/') . '/', '', $rootCore), '/');
return ($prefix === '' ? '' : $prefix . '/') . $inCore;
}
return $source;
}
}
@@ -0,0 +1,25 @@
<?php
namespace Toolbox;
/**
* Fuente de paquetes en disco: `packages/<pkg>/<archivo>`.
*/
final class DirectorySource implements PackageSource
{
public function __construct(private readonly string $packagesDir)
{
}
public function baseDir(string $package): string
{
return rtrim($this->packagesDir, '/') . '/' . $package;
}
public function read(string $package, string $file): ?string
{
$path = $this->baseDir($package) . '/' . ltrim($file, '/');
return is_file($path) ? (string) file_get_contents($path) : null;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Toolbox;
/**
* Detección de ediciones locales comparando hashes contra el lock.
*/
final class Drift
{
public const MATCH = 'match';
public const DRIFTED = 'drifted';
public const MISSING = 'missing';
public static function hash(string $absolutePath): string
{
return 'sha256:' . hash_file('sha256', $absolutePath);
}
public static function status(string $absolutePath, ?string $expected): string
{
if (!is_file($absolutePath)) {
return self::MISSING;
}
if ($expected === null) {
return self::MATCH;
}
return self::hash($absolutePath) === $expected ? self::MATCH : self::DRIFTED;
}
public static function isDrifted(string $absolutePath, ?string $expected): bool
{
return self::status($absolutePath, $expected) === self::DRIFTED;
}
}
@@ -0,0 +1,43 @@
<?php
namespace Toolbox;
/**
* Expande las entradas de `files` de un componente: una entrada que apunta a un
* directorio se convierte en la lista de sus archivos.
*/
final class FileExpander
{
/**
* @param list<string> $files
* @return list<string>
*/
public static function expand(PackageSource $source, string $package, array $files): array
{
$base = rtrim($source->baseDir($package), '/');
$out = [];
foreach ($files as $entry) {
$entry = ltrim($entry, '/');
$abs = $base . '/' . $entry;
if (is_dir($abs)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $item) {
if ($item->isDir()) {
continue;
}
$out[] = substr($item->getPathname(), strlen($base) + 1);
}
} else {
$out[] = $entry;
}
}
sort($out);
return array_values(array_unique($out));
}
}
@@ -0,0 +1,92 @@
<?php
namespace Toolbox;
/**
* Escribe archivos respetando el drift: sin edición local escribe; con edición
* decide según `--force`, `--backup`, `--keep-local` o aborta.
*/
final class FileSync
{
public const WRITTEN = 'written';
public const OVERWRITTEN = 'overwritten';
public const BACKED_UP = 'backed_up';
public const SKIPPED = 'skipped';
public const ABORTED = 'aborted';
/**
* @param array{force?:bool, backup?:bool, keepLocal?:bool} $options
*/
public function write(string $path, string $contents, ?string $expected, array $options = []): string
{
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0777, true);
}
$existed = is_file($path);
$drifted = $existed && Drift::isDrifted($path, $expected);
if (!$drifted) {
// Sin hash previo (p. ej. primera canonicalización de un proyecto
// heredado) un archivo existente también puede respaldarse.
$changed = $existed && (string) file_get_contents($path) !== $contents;
if ($changed && ($options['backup'] ?? false)) {
copy($path, $path . '.bak');
file_put_contents($path, $contents);
return self::BACKED_UP;
}
file_put_contents($path, $contents);
return $existed ? self::OVERWRITTEN : self::WRITTEN;
}
if ($options['force'] ?? false) {
file_put_contents($path, $contents);
return self::OVERWRITTEN;
}
if ($options['backup'] ?? false) {
copy($path, $path . '.bak');
file_put_contents($path, $contents);
return self::BACKED_UP;
}
if ($options['keepLocal'] ?? false) {
return self::SKIPPED;
}
return self::ABORTED;
}
/**
* @param array{force?:bool, backup?:bool, keepLocal?:bool} $options
*/
public function remove(string $path, ?string $expected, array $options = []): string
{
if (!is_file($path)) {
return self::SKIPPED;
}
$drifted = Drift::isDrifted($path, $expected);
if ($drifted && !($options['force'] ?? false)) {
if ($options['keepLocal'] ?? false) {
return self::SKIPPED;
}
if ($options['backup'] ?? false) {
copy($path, $path . '.bak');
} else {
return self::ABORTED;
}
}
unlink($path);
return self::OVERWRITTEN;
}
}
+367
View File
@@ -0,0 +1,367 @@
<?php
namespace Toolbox;
/**
* Motor de gestión compartido por el instalador global y el CLI del proyecto:
* alta, baja, actualización de core/componentes, listado y diagnóstico.
*/
final class Installer
{
private readonly Catalog $catalog;
private readonly Resolver $resolver;
private readonly PackageSource $source;
private readonly FileSync $sync;
private readonly Copier $copier;
private ProjectManifest $manifest;
private Lock $lock;
private readonly string $root;
private readonly string $rootCore;
public function __construct(
string $root,
private readonly string $toolboxDir,
private readonly Console $console,
?string $rootCore = null,
) {
$this->root = rtrim($root, '/');
$this->rootCore = $rootCore !== null ? rtrim($rootCore, '/') : $this->root . '/src';
$this->catalog = Catalog::fromRoot($this->toolboxDir);
$this->resolver = new Resolver($this->catalog);
$this->source = new DirectorySource(rtrim($this->toolboxDir, '/') . '/packages');
$this->sync = new FileSync();
$this->copier = new Copier($this->sync);
$this->manifest = ProjectManifest::load($this->root);
$this->lock = Lock::load($this->root);
}
public function catalog(): Catalog
{
return $this->catalog;
}
public function lock(): Lock
{
return $this->lock;
}
public function manifest(): ProjectManifest
{
return $this->manifest;
}
public function coreVersion(): ?string
{
return $this->lock->core()['version'] ?? null;
}
/**
* @param list<string> $requests
* @param array<string, bool|string> $options
* @return array{installed:list<string>, warnings:list<string>, aborted:list<string>, actions:array<string,string>}
*/
public function add(array $requests, array $options): array
{
return $this->installRequests($requests, $options, false);
}
/**
* @param list<string> $requests
* @param array<string, bool|string> $options
* @return array{installed:list<string>, warnings:list<string>, aborted:list<string>, actions:array<string,string>}
*/
public function update(array $requests, array $options): array
{
return $this->installRequests($requests, $options, true);
}
/**
* @param list<string> $requests
* @param array<string, bool|string> $options
* @return array{installed:list<string>, warnings:list<string>, aborted:list<string>, actions:array<string,string>}
*/
private function installRequests(array $requests, array $options, bool $isUpdate): array
{
$clean = [];
$constraints = [];
foreach ($requests as $request) {
[$id, $constraint] = array_pad(explode('@', $request, 2), 2, null);
$clean[] = $id;
if ($constraint !== null) {
$constraints[$id] = $constraint;
}
}
$resolved = $this->resolver->resolve($clean);
$warnings = [];
$installed = [];
$aborted = [];
$actions = [];
$post = [];
foreach ($resolved as $entry) {
$package = $entry['package'];
$componentName = $entry['component'];
$manifest = $this->catalog->get($package);
$component = $manifest->component($componentName);
$id = $package . ':' . $componentName;
[$version, $commit] = $this->resolveVersion($package);
$compat = Compatibility::evaluateCore(
$this->coreVersion(),
$manifest->coreRequirement($componentName),
$manifest->testedCore($componentName)
);
if ($compat['status'] !== Compatibility::OK) {
$warnings[] = $id . ': ' . $compat['message'];
$assumeYes = (bool) ($options['yes'] ?? false);
if (!$assumeYes && !$this->console->confirm(" {$id}: {$compat['message']}. ¿Continuar?", false)) {
$aborted[] = $id;
continue;
}
}
$mode = ($options['link'] ?? false) ? 'link' : 'copy';
$expected = $this->lock->component($id)['files'] ?? [];
$files = FileExpander::expand($this->source, $package, $component->files);
$result = $this->copier->install(
$files,
$this->source->baseDir($package),
$this->root,
$this->rootCore,
$component->destinations,
$mode,
$options,
$expected
);
foreach ($result['actions'] as $rel => $action) {
$actions[$rel] = $action;
}
if ($result['aborted'] !== []) {
foreach ($result['aborted'] as $rel) {
$aborted[] = $rel;
}
continue;
}
if (!($options['dryRun'] ?? false)) {
$this->lock->setComponent($id, [
'package' => $package,
'version' => $version,
'toolbox_commit' => $commit,
'mode' => $mode,
'files' => $result['files'],
]);
}
$installed[] = $id;
$post[$package] = $manifest;
}
foreach ($constraints as $key => $constraint) {
if (str_contains($key, ':')) {
if (!$isUpdate) {
$this->manifest->setConstraint($key, $constraint);
}
continue;
}
foreach ($installed as $id) {
if (str_starts_with($id, $key . ':') && !$isUpdate) {
$this->manifest->setConstraint($id, $constraint);
}
}
}
Ownership::fromLock($this->lock)->assertNoCollisions();
if (!($options['dryRun'] ?? false)) {
$this->lock->save($this->root);
$this->manifest->save($this->root);
foreach ($post as $manifest) {
if ($manifest->postInstall !== []) {
PostInstall::run(
$manifest->postInstall,
$this->console,
(bool) ($options['yes'] ?? false),
$options['postRunner'] ?? null
);
}
}
}
return ['installed' => $installed, 'warnings' => $warnings, 'aborted' => $aborted, 'actions' => $actions];
}
/**
* @param list<string> $ids
* @param array<string, bool|string> $options
* @return array{removed:list<string>, aborted:list<string>}
*/
public function remove(array $ids, array $options): array
{
$removed = [];
$aborted = [];
foreach ($ids as $id) {
$entry = $this->lock->component($id);
if ($entry === null) {
$aborted[] = $id;
continue;
}
$componentAbort = false;
foreach ($entry['files'] as $relative => $hash) {
$action = $this->sync->remove($this->root . '/' . $relative, $hash, $options);
if ($action === FileSync::ABORTED) {
$aborted[] = $relative;
$componentAbort = true;
}
}
if ($componentAbort) {
continue;
}
$this->lock->removeComponent($id);
$this->manifest->removeConstraint($id);
$removed[] = $id;
}
if (!($options['dryRun'] ?? false)) {
$this->lock->save($this->root);
$this->manifest->save($this->root);
}
return ['removed' => $removed, 'aborted' => $aborted];
}
/**
* @param array<string, bool|string> $options
* @return array{files:array<string,string>, actions:array<string,string>, aborted:list<string>, version:?string}
*/
public function installCore(CoreProvider $provider, array $options): array
{
return $this->writeCore($provider, $options, false);
}
/**
* @param array<string, bool|string> $options
* @return array{files:array<string,string>, actions:array<string,string>, aborted:list<string>, version:?string}
*/
public function updateCore(CoreProvider $provider, array $options): array
{
return $this->writeCore($provider, $options, true);
}
/**
* @param array<string, bool|string> $options
* @return array{files:array<string,string>, actions:array<string,string>, aborted:list<string>, version:?string}
*/
private function writeCore(CoreProvider $provider, array $options, bool $removeStale): array
{
$protected = $this->componentOwnedPaths();
$expected = $this->lock->core()['files'] ?? [];
$files = $provider->files();
$installed = [];
$actions = [];
$aborted = [];
foreach ($files as $relative) {
if (isset($protected[$relative])) {
continue;
}
$absolute = $this->root . '/' . $relative;
$action = $this->sync->write($absolute, $provider->read($relative), $expected[$relative] ?? null, $options);
$actions[$relative] = $action;
if ($action === FileSync::ABORTED) {
$aborted[] = $relative;
continue;
}
$installed[$relative] = Drift::hash($absolute);
}
if ($removeStale) {
foreach ($expected as $relative => $hash) {
if (isset($installed[$relative]) || isset($protected[$relative]) || in_array($relative, $files, true)) {
continue;
}
$action = $this->sync->remove($this->root . '/' . $relative, $hash, $options);
if ($action === FileSync::ABORTED) {
$aborted[] = $relative;
}
}
}
if ($aborted === [] && !($options['dryRun'] ?? false)) {
$this->lock->setCore([
'version' => $provider->version() ?? $this->coreVersion(),
'commit' => $provider->commit() ?? ($this->lock->core()['commit'] ?? null),
'files' => $installed,
]);
$this->lock->save($this->root);
}
return ['files' => $installed, 'actions' => $actions, 'aborted' => $aborted, 'version' => $provider->version()];
}
/** @return array<string, true> */
private function componentOwnedPaths(): array
{
$paths = [];
foreach ($this->lock->components() as $entry) {
foreach (array_keys($entry['files'] ?? []) as $relative) {
$paths[$relative] = true;
}
}
return $paths;
}
/**
* @return array{0:string, 1:?string}
*/
private function resolveVersion(string $package): array
{
if (is_dir($this->toolboxDir . '/.git')) {
$resolver = new TagResolver(new ToolboxRepo($this->toolboxDir, ''), $this->yanked($package));
$latest = $resolver->latest($package);
if ($latest !== null) {
return [$latest['version'], $latest['commit']];
}
}
return [$this->catalog->get($package)->version, null];
}
/**
* @return list<string>
*/
private function yanked(string $package): array
{
$file = $this->toolboxDir . '/yanked.json';
if (!is_file($file)) {
return [];
}
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) {
return [];
}
$list = $data[$package] ?? [];
return is_array($list) ? array_values($list) : [];
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace Toolbox;
/**
* `.duckbrain/lock.json`: estado resuelto (core + componentes con hashes).
*/
final class Lock
{
private const DIR = '.duckbrain';
private const FILE = 'lock.json';
/**
* @param array{version?:string, commit?:string, files?:array<string,string>}|null $core
* @param array<string, array{package:string, version:string, toolbox_commit:?string, mode:string, files:array<string,string>}> $components
*/
private function __construct(
private ?array $core,
private array $components,
) {
}
public static function path(string $root): string
{
return rtrim($root, '/') . '/' . self::DIR . '/' . self::FILE;
}
public static function empty(): self
{
return new self(null, []);
}
public static function load(string $root): self
{
$path = self::path($root);
if (!is_file($path)) {
return self::empty();
}
$data = json_decode((string) file_get_contents($path), true);
if (!is_array($data)) {
throw new ToolboxException("Lock inválido: {$path}");
}
$components = [];
foreach (($data['components'] ?? []) as $id => $entry) {
$components[$id] = [
'package' => (string) ($entry['package'] ?? ''),
'version' => (string) ($entry['version'] ?? ''),
'toolbox_commit' => $entry['toolbox_commit'] ?? null,
'mode' => (string) ($entry['mode'] ?? 'copy'),
'origin' => (string) ($entry['origin'] ?? 'toolbox'),
'files' => is_array($entry['files'] ?? null) ? $entry['files'] : [],
];
}
return new self($data['core'] ?? null, $components);
}
/** @return array{version?:string, commit?:string, files?:array<string,string>}|null */
public function core(): ?array
{
return $this->core;
}
/** @return array<string, array{package:string, version:string, toolbox_commit:?string, mode:string, files:array<string,string>}> */
public function components(): array
{
return $this->components;
}
public function has(string $id): bool
{
return isset($this->components[$id]);
}
public function component(string $id): ?array
{
return $this->components[$id] ?? null;
}
/** @param array{version?:string, commit?:string, files?:array<string,string>} $core */
public function setCore(array $core): void
{
$this->core = $core;
}
/** @param array{package:string, version:string, toolbox_commit:?string, mode:string, files:array<string,string>} $entry */
public function setComponent(string $id, array $entry): void
{
$this->components[$id] = $entry;
}
public function removeComponent(string $id): void
{
unset($this->components[$id]);
}
public function save(string $root): void
{
$dir = rtrim($root, '/') . '/' . self::DIR;
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
$data = ['core' => $this->core, 'components' => $this->components];
file_put_contents(
self::path($root),
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace Toolbox;
final class Manifest
{
/**
* @param array<string, Component> $components
* @param list<string> $default
*/
private function __construct(
public readonly string $name,
public readonly string $version,
public readonly string $description,
private readonly array $components,
private readonly ?array $default,
public readonly ?string $coreRequires,
public readonly ?string $coreTested,
public readonly ?string $php,
public readonly array $postInstall,
) {
}
public static function fromFile(string $path): self
{
if (!is_file($path)) {
throw new ToolboxException("No se encontró el manifiesto: {$path}");
}
$data = json_decode((string) file_get_contents($path), true);
if (!is_array($data)) {
throw new ToolboxException("Manifiesto JSON inválido: {$path}");
}
return self::fromArray($data, $path);
}
public static function fromArray(array $data, string $origin = 'duckbrain.json'): self
{
$name = $data['name'] ?? null;
$version = $data['version'] ?? null;
if (!is_string($name) || $name === '') {
throw new ToolboxException("El manifiesto '{$origin}' no declara 'name'");
}
if (!is_string($version) || $version === '') {
throw new ToolboxException("El manifiesto '{$origin}' no declara 'version'");
}
Semver::parse($version);
if (!isset($data['components']) || !is_array($data['components']) || $data['components'] === []) {
throw new ToolboxException("El manifiesto '{$origin}' no declara 'components'");
}
$components = [];
foreach ($data['components'] as $componentName => $componentData) {
if (!is_string($componentName) || !is_array($componentData)) {
throw new ToolboxException("Componente inválido en '{$origin}'");
}
$components[$componentName] = Component::fromArray($componentName, $componentData);
}
$default = null;
if (array_key_exists('default', $data)) {
if (!is_array($data['default'])) {
throw new ToolboxException("El manifiesto '{$origin}' tiene 'default' inválido");
}
$default = array_values($data['default']);
foreach ($default as $componentName) {
if (!is_string($componentName) || !isset($components[$componentName])) {
throw new ToolboxException("'default' referencia un componente inexistente en '{$origin}'");
}
}
}
$postInstall = [];
foreach (($data['post-install'] ?? []) as $command) {
if (!is_string($command)) {
throw new ToolboxException("'post-install' inválido en '{$origin}'");
}
$postInstall[] = $command;
}
return new self(
name: $name,
version: $version,
description: is_string($data['description'] ?? null) ? $data['description'] : '',
components: $components,
default: $default,
coreRequires: $data['core']['requires'] ?? null,
coreTested: $data['core']['tested'] ?? null,
php: is_string($data['php'] ?? null) ? $data['php'] : null,
postInstall: $postInstall,
);
}
/** @return array<string, Component> */
public function components(): array
{
return $this->components;
}
public function hasComponent(string $name): bool
{
return isset($this->components[$name]);
}
public function component(string $name): Component
{
if (!isset($this->components[$name])) {
throw new ToolboxException("El paquete '{$this->name}' no tiene el componente '{$name}'");
}
return $this->components[$name];
}
/**
* Expande una petición (componente o conjunto por defecto) a nombres.
*
* @return list<string>
*/
public function expand(?string $component): array
{
if ($component !== null) {
if (!$this->hasComponent($component)) {
throw new ToolboxException("El paquete '{$this->name}' no tiene el componente '{$component}'");
}
return [$component];
}
if ($this->default === null) {
throw new ToolboxException(
"El paquete '{$this->name}' no declara 'default'; indica un componente explícito"
);
}
return $this->default;
}
public function coreRequirement(?string $component = null): ?string
{
if ($component !== null) {
return $this->component($component)->coreRequires ?? $this->coreRequires;
}
return $this->coreRequires;
}
public function testedCore(?string $component = null): ?string
{
if ($component !== null) {
return $this->component($component)->coreTested ?? $this->coreTested;
}
return $this->coreTested;
}
}
@@ -0,0 +1,86 @@
<?php
namespace Toolbox;
/**
* Mapa de propiedad: cada ruta instalada pertenece al core o a un componente.
*/
final class Ownership
{
/** @var array<string, string> path => owner id ('core' o 'pkg:comp') */
private array $owners = [];
/** @var array<string, list<string>> path => owners en conflicto */
private array $collisions = [];
public static function fromLock(Lock $lock): self
{
$ownership = new self();
$core = $lock->core();
foreach (($core['files'] ?? []) as $path => $hash) {
$ownership->claim('core', $path);
}
foreach ($lock->components() as $id => $entry) {
foreach (($entry['files'] ?? []) as $path => $hash) {
$ownership->claim($id, $path);
}
}
return $ownership;
}
private function claim(string $owner, string $path): void
{
if (!isset($this->owners[$path])) {
$this->owners[$path] = $owner;
return;
}
if ($this->owners[$path] !== $owner) {
$this->collisions[$path][] = $this->owners[$path];
$this->collisions[$path][] = $owner;
}
}
public function ownerOf(string $path): ?string
{
return $this->owners[$path] ?? null;
}
/** @return list<string> */
public function pathsOf(string $owner): array
{
return array_keys(array_filter($this->owners, fn ($o) => $o === $owner));
}
/**
* @return array<string, list<string>>
*/
public function collisions(): array
{
$result = [];
foreach ($this->collisions as $path => $owners) {
$result[$path] = array_values(array_unique($owners));
}
return $result;
}
public function assertNoCollisions(): void
{
$collisions = $this->collisions();
if ($collisions === []) {
return;
}
$parts = [];
foreach ($collisions as $path => $owners) {
$parts[] = $path . ' (' . implode(', ', $owners) . ')';
}
throw new ToolboxException('Colisión de propiedad: ' . implode('; ', $parts));
}
}
@@ -0,0 +1,10 @@
<?php
namespace Toolbox;
interface PackageSource
{
public function baseDir(string $package): string;
public function read(string $package, string $file): ?string;
}
@@ -0,0 +1,47 @@
<?php
namespace Toolbox;
/**
* Ejecuta los comandos `post-install` de un paquete, mostrándolos y pidiendo
* confirmación salvo en modo `--yes`.
*/
final class PostInstall
{
/**
* @param list<string> $commands
* @param callable(string):int|null $runner
* @return list<string> comandos ejecutados
*/
public static function run(
array $commands,
Console $console,
bool $assumeYes = false,
?callable $runner = null
): array {
$executed = [];
foreach ($commands as $command) {
$console->out(' post-install: ' . $command);
if (!$console->confirm(' ¿Ejecutar?', $assumeYes)) {
$console->warn('post-install omitido: ' . $command);
continue;
}
if ($runner !== null) {
$runner($command);
} else {
passthru($command, $code);
if (($code ?? 0) !== 0) {
throw new ToolboxException("post-install falló: {$command}");
}
}
$executed[] = $command;
}
return $executed;
}
}
@@ -0,0 +1,95 @@
<?php
namespace Toolbox;
/**
* `.duckbrain/manifest.json`: estado deseado (restricciones de versión).
*/
final class ProjectManifest
{
private const DIR = '.duckbrain';
private const FILE = 'manifest.json';
/**
* @param array<string, string> $constraints
*/
private function __construct(
public ?string $coreConstraint,
private array $constraints,
) {
}
public static function path(string $root): string
{
return rtrim($root, '/') . '/' . self::DIR . '/' . self::FILE;
}
public static function load(string $root): self
{
$path = self::path($root);
if (!is_file($path)) {
return new self(null, []);
}
$data = json_decode((string) file_get_contents($path), true);
if (!is_array($data)) {
throw new ToolboxException("Manifest de proyecto inválido: {$path}");
}
$constraints = [];
foreach (($data['components'] ?? []) as $id => $constraint) {
$constraints[$id] = (string) $constraint;
}
return new self(
isset($data['core']['constraint']) ? (string) $data['core']['constraint'] : null,
$constraints
);
}
/** @return array<string, string> */
public function constraints(): array
{
return $this->constraints;
}
public function constraint(string $id): ?string
{
return $this->constraints[$id] ?? null;
}
public function setConstraint(string $id, ?string $constraint): void
{
if ($constraint === null) {
unset($this->constraints[$id]);
return;
}
$this->constraints[$id] = $constraint;
}
public function removeConstraint(string $id): void
{
unset($this->constraints[$id]);
}
public function save(string $root): void
{
$dir = rtrim($root, '/') . '/' . self::DIR;
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
$data = ['core' => [], 'components' => []];
if ($this->coreConstraint !== null) {
$data['core']['constraint'] = $this->coreConstraint;
}
$data['components'] = $this->constraints;
file_put_contents(
self::path($root),
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
}
}
@@ -0,0 +1,99 @@
<?php
namespace Toolbox;
/**
* Resuelve peticiones (`paquete` o `paquete:componente`) expandiendo el
* conjunto por defecto y las dependencias entre componentes, en orden de
* dependencia (las dependencias primero).
*/
final class Resolver
{
/** @var array<string, true> */
private array $visiting = [];
/** @var array<string, array{package:string, component:string}> */
private array $resolved = [];
public function __construct(private readonly Catalog $catalog)
{
}
/**
* @param list<string> $requests
* @return list<array{package:string, component:string}>
*/
public function resolve(array $requests): array
{
$this->visiting = [];
$this->resolved = [];
foreach ($requests as $request) {
[$packageName, $component] = self::parseRequest($request);
$manifest = $this->catalog->get($packageName);
foreach ($manifest->expand($component) as $componentName) {
$this->resolveComponent($packageName, $componentName);
}
}
return array_values($this->resolved);
}
private function resolveComponent(string $packageName, string $componentName): void
{
$id = $packageName . ':' . $componentName;
if (isset($this->resolved[$id])) {
return;
}
if (isset($this->visiting[$id])) {
throw new ToolboxException("Ciclo de dependencias detectado en '{$id}'");
}
$manifest = $this->catalog->get($packageName);
$component = $manifest->component($componentName);
$this->visiting[$id] = true;
foreach ($component->requires as $requiredId => $constraint) {
[$requiredPackage, $requiredComponent] = self::parseRequest($requiredId, $packageName);
$requiredManifest = $this->catalog->get($requiredPackage);
if (!$requiredManifest->hasComponent($requiredComponent)) {
throw new ToolboxException(
"'{$id}' requiere '{$requiredPackage}:{$requiredComponent}', que no existe"
);
}
if (!Semver::satisfies($requiredManifest->version, $constraint)) {
throw new ToolboxException(sprintf(
"'%s' requiere '%s' %s, pero la versión disponible es %s",
$id,
$requiredId,
$constraint,
$requiredManifest->version
));
}
$this->resolveComponent($requiredPackage, $requiredComponent);
}
unset($this->visiting[$id]);
$this->resolved[$id] = ['package' => $packageName, 'component' => $componentName];
}
/**
* @return array{0:string, 1:?string}
*/
public static function parseRequest(string $request, ?string $defaultPackage = null): array
{
if (str_contains($request, ':')) {
[$package, $component] = explode(':', $request, 2);
return [$package, $component];
}
return [$defaultPackage ?? $request, null];
}
}
@@ -0,0 +1,51 @@
<?php
namespace Toolbox;
/**
* Autoactualización del instalador global: descarga el phar, verifica su
* sha256 contra el publicado y reemplaza el binario de forma atómica.
*/
final class SelfUpdater
{
public function __construct(private readonly Console $console)
{
}
public function update(string $from, string $target, string $tempDir): void
{
$source = rtrim($from, '/') . '/duckbrain.phar';
$sumUrl = $source . '.sha256';
$this->console->out(' descargando ' . $source);
$data = @file_get_contents($source);
if ($data === false) {
throw new ToolboxException("No se pudo descargar el instalador: {$source}");
}
$expected = trim((string) @file_get_contents($sumUrl));
if ($expected === '') {
throw new ToolboxException("No se pudo leer el sha256 publicado: {$sumUrl}");
}
$actual = hash('sha256', $data);
if (!hash_equals($expected, $actual)) {
throw new ToolboxException('sha256 inválido; actualización abortada sin tocar el binario');
}
if (!is_dir($tempDir)) {
mkdir($tempDir, 0777, true);
}
$temp = rtrim($tempDir, '/') . '/duckbrain.phar.' . bin2hex(random_bytes(4));
file_put_contents($temp, $data);
chmod($temp, 0755);
if (!rename($temp, $target)) {
@unlink($temp);
throw new ToolboxException("No se pudo reemplazar el instalador: {$target}");
}
$this->console->ok('instalador actualizado (' . $target . ')');
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace Toolbox;
/**
* Comparación y evaluación de rangos semver, sin dependencias externas.
*/
final class Semver
{
/**
* @return array{major:int, minor:int, patch:int, pre:string, meta:string}
*/
public static function parse(string $version): array
{
$version = ltrim(trim($version), 'vV');
$pattern = '/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.\-]+))?(?:\+([0-9A-Za-z.\-]+))?$/';
if (!preg_match($pattern, $version, $m)) {
throw new ToolboxException("Versión semver inválida: '{$version}'");
}
return [
'major' => (int) $m[1],
'minor' => (int) $m[2],
'patch' => (int) $m[3],
'pre' => $m[4] ?? '',
'meta' => $m[5] ?? '',
];
}
/**
* Completa versiones parciales (`1`, `1.2`) a `MAJOR.MINOR.PATCH`.
*/
public static function normalize(string $version): string
{
$version = ltrim(trim($version), 'vV');
if (preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?([-+].*)?$/', $version, $m) === 1) {
$core = $m[1] . '.' . ($m[2] ?? '0') . '.' . ($m[3] ?? '0');
return $core . ($m[4] ?? '');
}
return $version;
}
public static function compare(string $a, string $b): int
{
$pa = self::parse(self::normalize($a));
$pb = self::parse(self::normalize($b));
foreach (['major', 'minor', 'patch'] as $part) {
if ($pa[$part] !== $pb[$part]) {
return $pa[$part] <=> $pb[$part];
}
}
return self::comparePre($pa['pre'], $pb['pre']);
}
/**
* ¿La versión satisface la restricción? Soportado: *, vacío, exacta,
* =, ==, !=, >, >=, <, <=, ^ y ~. La restricción puede venir con el prefijo
* de paquete ya removido.
*/
public static function satisfies(string $version, string $constraint): bool
{
$constraint = trim($constraint);
if ($constraint === '' || $constraint === '*') {
return true;
}
if (str_contains($constraint, '||')) {
foreach (explode('||', $constraint) as $alternative) {
if (self::satisfies($version, trim($alternative))) {
return true;
}
}
return false;
}
if (preg_match('/^(>=|<=|!=|==|>|<|=)?\s*(.+)$/', $constraint, $m) !== 1) {
throw new ToolboxException("Restricción inválida: '{$constraint}'");
}
$operator = $m[1] ?: '=';
$target = trim($m[2]);
if ($target === '' || $target === '*') {
return true;
}
if ($target[0] === '^' || $target[0] === '~') {
return self::satisfiesRange($version, $target);
}
$cmp = self::compare($version, self::normalize($target));
return match ($operator) {
'>' => $cmp > 0,
'>=' => $cmp >= 0,
'<' => $cmp < 0,
'<=' => $cmp <= 0,
'!=' => $cmp !== 0,
default => $cmp === 0,
};
}
private static function satisfiesRange(string $version, string $range): bool
{
$operator = $range[0];
$lower = self::normalize(substr($range, 1));
$p = self::parse($lower);
if (self::compare($version, $lower) < 0) {
return false;
}
if ($operator === '~') {
$upper = $p['major'] . '.' . ($p['minor'] + 1) . '.0';
} else {
$upper = $p['major'] > 0
? ($p['major'] + 1) . '.0.0'
: '0.' . ($p['minor'] + 1) . '.0';
}
return self::compare($version, $upper) < 0;
}
private static function comparePre(string $a, string $b): int
{
if ($a === '' && $b === '') {
return 0;
}
if ($a === '') {
return 1;
}
if ($b === '') {
return -1;
}
return strcmp($a, $b) <=> 0;
}
}
@@ -0,0 +1,106 @@
<?php
namespace Toolbox;
/**
* Resuelve versiones de paquete a partir de tags prefijados del repositorio,
* verificando que el tag coincida con la versión del manifiesto en ese commit.
*/
final class TagResolver
{
/**
* @param list<string> $yanked versiones retiradas
*/
public function __construct(
private readonly ToolboxRepo $repo,
private readonly array $yanked = [],
) {
}
/**
* @return list<array{version:string, tag:string, commit:string, valid:bool}>
*/
public function versions(string $package): array
{
$prefix = $package . '-v';
$candidates = [];
foreach ($this->repo->tags($prefix) as $tag) {
$version = substr($tag, strlen($prefix));
try {
Semver::parse($version);
} catch (ToolboxException) {
continue;
}
$commit = $this->repo->commitOfTag($tag);
if ($commit === null) {
continue;
}
$candidates[] = [
'version' => $version,
'tag' => $tag,
'commit' => $commit,
'valid' => $this->isConsistent($package, $version, $commit),
];
}
usort($candidates, fn ($a, $b) => Semver::compare($b['version'], $a['version']));
return $candidates;
}
/**
* @return array{version:string, tag:string, commit:string}|null
*/
public function latest(
string $package,
?string $constraint = null,
bool $includePre = false
): ?array {
foreach ($this->versions($package) as $candidate) {
if (!$candidate['valid']) {
continue;
}
if (!$includePre && self::isPre($candidate['version'])) {
continue;
}
if (in_array($candidate['version'], $this->yanked, true)) {
continue;
}
if ($constraint !== null && !Semver::satisfies($candidate['version'], $constraint)) {
continue;
}
return [
'version' => $candidate['version'],
'tag' => $candidate['tag'],
'commit' => $candidate['commit'],
];
}
return null;
}
private function isConsistent(string $package, string $version, string $commit): bool
{
$raw = $this->repo->fileAt($commit, "packages/{$package}/duckbrain.json");
if ($raw === null) {
return false;
}
$data = json_decode($raw, true);
return is_array($data) && ($data['version'] ?? null) === $version;
}
private static function isPre(string $version): bool
{
return str_contains($version, '-');
}
}
@@ -0,0 +1,7 @@
<?php
namespace Toolbox;
class ToolboxException extends \RuntimeException
{
}
@@ -0,0 +1,107 @@
<?php
namespace Toolbox;
/**
* Caché clonada del toolbox. Mantiene historial completo (no superficial) para
* poder resolver cualquier commit registrado en un lock.
*/
final class ToolboxRepo
{
public function __construct(
private readonly string $dir,
private readonly string $url,
) {
}
public function dir(): string
{
return $this->dir;
}
public function isCloned(): bool
{
return is_dir($this->dir . '/.git');
}
public function ensure(): void
{
if (!$this->isCloned()) {
$parent = dirname($this->dir);
if (!is_dir($parent)) {
mkdir($parent, 0777, true);
}
$this->git(['clone', '--tags', $this->url, $this->dir]);
return;
}
$this->ensureFull();
$this->fetch();
}
public function fetch(): void
{
$this->git(['fetch', '--tags', '--force', 'origin']);
}
/**
* Si la caché es superficial, la completa.
*/
public function ensureFull(): void
{
if (is_file($this->dir . '/.git/shallow')) {
$this->git(['fetch', '--unshallow', '--tags']);
}
}
/**
* @return list<string>
*/
public function tags(?string $prefix = null): array
{
$tags = array_filter(explode("\n", trim($this->git(['tag', '-l'])[1])));
if ($prefix === null) {
return array_values($tags);
}
return array_values(array_filter($tags, fn ($tag) => str_starts_with($tag, $prefix)));
}
public function commitOfTag(string $tag): ?string
{
$result = $this->git(['rev-list', '-n', '1', $tag]);
return $result[0] === 0 && trim($result[1]) !== '' ? trim($result[1]) : null;
}
public function hasCommit(string $commit): bool
{
$result = $this->git(['cat-file', '-e', $commit . '^{commit}']);
return $result[0] === 0;
}
public function fileAt(string $ref, string $path): ?string
{
$result = $this->git(['show', $ref . ':' . $path]);
return $result[0] === 0 ? $result[1] : null;
}
/**
* @param list<string> $args
* @return array{0:int, 1:string, 2:string}
*/
public function git(array $args): array
{
$command = 'git -C ' . escapeshellarg($this->dir) . ' ' . implode(' ', array_map('escapeshellarg', $args)) . ' 2>&1';
$output = [];
$code = 0;
exec($command, $output, $code);
return [$code, implode("\n", $output), ''];
}
}