first commit.
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) : [];
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 . ')');
|
||||
}
|
||||
}
|
||||
@@ -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), ''];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user